开发基于Django和Websocket的堡垒机

WebSSH有很多,基于Django的Web服务也有很多,使用Paramiko在Python中进行SSH访问的就更多了。但是通过gevent将三者结合起来,实现通过浏览器访问的堡垒机就很少见了。本文将简要介绍下我开发的IronFort堡垒机,其详细内容在我的官方网站liujiangblog.com的视频教程中。

一、堡垒机概述

百度百科:堡垒机,在一个特定的网络环境下,为了保障网络和数据不受来自外部和内部用户的入侵和破坏,而运用各种技术手段实时收集和监控网络环境中每一个组成部分的系统状态、安全事件、网络活动,以便集中报警、及时处理及审计定责。

对于一个中型以上的公司,当用户和职员人数较多,公司所属服务器也数量较大的情况下,其服务器上的帐号管理难度将急剧增加,参考下面的图片:

开发基于Django和Websocket的堡垒机

这其中必然存在很多问题,例如:

  • 用户、主机、账号数量太多,工作量大,管理混乱;
  • 每个人员的权限和可使用账号没有系统管理,等级区分不明;
  • 用户直接掌握主机的帐号密码;
  • 密码可能交叉使用;
  • 离职人员可能还可以使用公司的帐号;
  • 内部人员可以跳过防火墙,直接使用帐号在机房内访问;
  • 内部人员离职前设下木马或暗门,一段时间后再爆发;
  • 对人员的访问记录、过往操作没有日志和审计,缺乏事后追踪手段;
  • 其它风险

在运行初期,公司可能采取Excel表格等工具,使用人工管理的方式,靠‘人治’和道德水平约束,但当公司体量逐渐变大的时候,这种方式必然遭到淘汰,于是就出现了堡垒机的概念,如下图所示:

开发基于Django和Websocket的堡垒机

这种架构带来如下的好处:

  • 用户不能直接访问远程主机,而是需要通过堡垒机跳转;
  • 用户不再掌握远程主机的帐号密码,只有访问堡垒机的帐号;
  • 限制用户登录远程主机后的修改密码能力,不允许修改;
  • 堡垒机的用户、远程主机的用户、用户密码、用户权限等等都被统一集中管理,大量节省人工成本;
  • 用户在登录堡垒机后所进行的一切操作将被记录下来,用于后期的行为审计;
  • 由于没有远程主机帐号密码,即使进入机房也无法直连主机;
  • 还可以实现批量命令执行、文件分发等附带功能;
  • 其它收益。

堡垒机的核心概念是用户不再掌握帐号密码,用户的行为被记录用于审计。堡垒机主要针对的是内部网络和内部人员,对于人员流动性较强、体量大、行业风险高的企业需求特别强烈,比如金融行业。

堡垒机已经拥有商业产品,多数以硬件服务器为载体进行销售,价格几十万不等。也有开源的解决方案,但这些方案有的不是基于浏览器,界面不够友好,日志记录困难;有的基于Tornado,并且只能进行简单的命令执行功能,而公司使用的是Django;更多的情况是与公司需求不一致,需要二次开发,维护和升级困难,等等不一而足。

‘授人以鱼不如授人以渔’,自己掌握了开发堡垒机的核心技能,就可以快速、方便、灵活的针对公司具体需求进行定制开发,既为公司节省了购置硬件经费,又利于维护升级。

二、 IronFort堡垒机体系架构

IronFort堡垒机的体系架构如下图所示:

开发基于Django和Websocket的堡垒机

一个完整的通信过程如下:

  1. 用户通过使用支持HTML5的浏览器,在HTTP的基础上,向堡垒机发送websocket请求;
  2. 堡垒机上使用gevent接收websocket请求并转发给Django;
  3. Django接收请求后,调用paramiko建立与远程主机的ssh通道;
  4. 远程主机执行用户的命令后,通过ssh返回数据给Django;
  5. Django通过gevent以websocket的形式返回给用户浏览器;
  6. 用户浏览器使用term.js插件模拟Linux终端,显示远程主机返回的结果。

核心机制就是这样,下面我们来看下开发过程。

三、开发简介

1. 项目创建

堡垒机本身通常是布置在Linux主机上的,比ubuntu16.04,对外以HTTP的形式提供服务。

首先需要建立虚拟环境,并安装Python3.6以及Django2.0,不再赘述。

使用django-admin startprojectpython manage.py startapp app_name分别创建项目和app。

此时,可以尝试运行Django服务,可以看到欢迎界面。

2. ORM模型

任何一个Web项目都必须在深入分析项目需求的情况下,首先设计好ORM模型,也就是数据库的表结构。

IronFort中设计了六个模型,分别是:

  • 远程主机
  • 远程主机用户
  • 远程主机绑定的用户
  • 堡垒机用户
  • 堡垒机用户组
  • 日志

这里需要提醒的是:

  • 每个远程主机账户可以绑定多个远程主机,两者实际是多对多的关系;
  • 堡垒机用户不能直接绑定远程主机;
  • 堡垒机用户绑定的实际是一个主机+主机账户的对象;
  • 考虑账户是否激活或者被经用的enabled属性;
  • 考虑某些字段的unique_together属性;

关于模型设计,每个人有每个人的需求和想法,这其中有很多坑和需要注意的地方,限于篇幅,无法展开论述。在我的个人网站liujiangblog.com的视频教程中有详细的讲解。

模型设计好了,可以同时注册Django的admin后台。然后makemigrations、migrate和createsuperuser,重启服务器后就可以在admin中创建测试用例了,如下图所示:

开发基于Django和Websocket的堡垒机

3. url和路由

url的设计并不复杂,没有太多的复杂页面,下面是项目中使用的一些url:

from django.contrib import admin
from django.urls import path, re_path
from fort import views

urlpatterns = [
    path('admin/', admin.site.urls),
    path('', views.login),
    path('login/', views.login),
    path('logout/', views.logout),
    path('index/', views.index),
    path('log/', views.get_log),
    path('host/<int:user_bind_host_id>/', views.connect),
]

Django2.0的url语法向flask等框架靠拢了,但依然可以使用正则模式。关于2.0和之前版本的区别,可以查看我曾经写过的一篇博文Django 2.0 新特性 抢先看!。其实不是重度使用者,基本感受不出变化来,该怎么用还是怎么用。最大的区别也就在url编写,和Python2及3的支持。

4. 前端框架AdminLTE

为了让用户界面美观,我这里使用了基于bootstrap的开源框架AdminLTE。

开发基于Django和Websocket的堡垒机

AdminLTE托管在GitHub上,可以通过下面的地址下载:

https://github.com/almasaeed2010/AdminLTE/releases

AdminLTE自带JQuery和Bootstrap3,无需另外下载。

AdminLTE自带多种配色皮肤,可根据需要实时调整。

AdminLTE是移动端自适应的,无需单独考虑。

AdminLTE自带大量插件,比如datatables,可根据需要载入。

但是AdminLTE的源文件包内,缺少font-awesome-4.6.3和ionicons-2.0.1这两个图标插件,它是通过CDN的形式加载的,如果网络不太好,加载可能比较困难或者缓慢,最好用本地静态文件的形式,请自定下载并引入项目内。

我们不需要AdminLTE那么多的功能,只需要它的基本框架。在其源码包内,对index文件进行裁剪和静态文件导入处理,形成一个基本的base.html用于拓展,在它的基础上,我们可以扩展出index和log页面。

5. 堡垒机用户登录页面

堡垒机用户登录页面不需要使用AdminLTE,最好是单独一个简单的页面,展示的内容越少越好。

开发基于Django和Websocket的堡垒机

而用户登录的处理视图就很简单了,直接使用Django内置的Auth认证系统。

使用Django自带的authenticate和login方法就可以完成用户验证和登录会话。

既然有了登录,必然就要有登出。为了限制未登录用户访问堡垒机系统,所有的相关视图都必须先使用装饰器进行是否登录验证。

通常而言,堡垒机不需要提供面向用户的注册页面。堡垒机用户的注册都是超级管理员掌控的,在后台进行!

6. 主机帐号页面

也就是我们堡垒机用户登录进系统后,显示的默认页面index。这里将通过表格的形式,列出当前堡垒机用户可以使用的远程主机帐号。视图很简单:

@login_required(login_url='/login/')
def index(request):
    # ...通过ORM的API查询可使用的帐号
    return render(request, 'fort/index.html', locals())

主机账户的前端页面index基于base.html,使用datatable插件,提供搜索、排序和分页等高级功能,其展示效果如下图:

开发基于Django和Websocket的堡垒机

7. 在浏览器中打开websocket通道

百度百科:WebSocket协议是基于TCP的一种新的网络协议。它实现了浏览器与服务器全双工(full-duplex)通信——允许服务器主动发送信息给客户端。

本文不打算成为一篇websocket的科普文,有兴趣深入研究的可以查看博客园的精华博文WebSocket协议:5分钟从入门到精通

简单的说,有以下几点:

  • HTTP本身是无状态连接,不支持实时通信;
  • websocket基于HTML5,需要浏览器支持;
  • 通过在http报头中添加upgrade属性,申请通信协议升级为websocket;
  • 升级成为websocket通信后,可以实现浏览器和远程服务器之间的全双工实时通信。

关于websocket的使用教程,可以参考阮一峰专家的博文WebSocket 教程

其具体API如下图所示:

开发基于Django和Websocket的堡垒机

开发基于Django和Websocket的堡垒机

要简单的创建并使用一个websocket,按下面的套路就可以了:

  • 使用new WebSocket(url, [protocol] );创建ws对象
  • 使用ws,调用onopen、onmessage、onerror和onclose方法处理通信过程中的数据
  • 使用ws,调用send方法发送数据给后端服务器
  • 使用ws,调用close方法,关闭websocket连接。

我们在主机帐号表格中隐藏一个主机帐号id的字段,通过js代码获取该字段的值,然后启动websocket通信,传递这个id作为参数之一,用于构造websocket通信使用的url。

在浏览器模拟Linux终端方面,我使用的是term.js插件。这是一个开源在github上的浏览器模拟Linux终端的js插件,地址为:https://github.com/chjj/term.js。其官方文档比较简单,有兴趣的同学可以深入研读其源代码,或者使用xterm作为替代。

最终效果如下:

开发基于Django和Websocket的堡垒机

因为此时后端还没有完成,所以是连接不上任何主机的。

8. 创建websocket服务器

Django本身是一个同步Web框架,也不支持websocket。所以你使用它的runserver,是无法接收和处理websocket请求的。为了解决这个问题,可以使用gevent这个Python的第三方异步网络框架。

gevent基于greelet协程库,自带有WSGI服务器,并且其扩展库gevent-websocket支持websocket通信。

请先用pip install gevent gevent-websocket安装这两个库。

在IronFort项目根目录下创建一个start_ironfort.py脚本,以后这就是我们的服务启动脚本了。

from gevent import monkey
monkey.patch_all()

from gevent.pywsgi import WSGIServer
from geventwebsocket.handler import WebSocketHandler
from ironfort.wsgi import application

print('ironfort is running ......')

ws_server = WSGIServer(
    (host, port),
    application,
    log=None,
    handler_class=WebSocketHandler
)

try:
    ws_server.serve_forever()
except KeyboardInterrupt:
    print('服务器关闭......')
    pass

核心要点是,使用gevent的WSGIServer服务器代替DJango的runserver,使用geventwebsocket的WebSocketHandler来处理浏览器发送过来的websocket通信请求,并将其转发到Django的application。

我们知道Django的通信入口就存在于from ironfort.wsgi import application中的这个方法。通过gevent的帮助,我们让Django具备了接收websocket通信请求的能力。

运行python start_ironfort可以启动新的服务器,在浏览器验证一下,都可以正常访问。

9. 在Django中创建视图处理websocket请求

我们前面的根路由中已经写了相关的url,这里再贴出来:

path('host/<int:user_bind_host_id>/', views.connect),

这样,以ws://ip:port/host/15/形式的url请求,将被转发到connect视图进行处理,这其中传递了‘15’这个主机帐号id的参数。具体connect视图局部代码如下:

@login_required(login_url='/login/')
def connect(request, user_bind_host_id):
    # 如果当前请求不是websocket请求则退出
    # ...省略
    # 获取remote_user_bind_host

    bridge = WSSHBridge(request.environ.get('wsgi.websocket'), request.user)

    try:
        bridge.open(
            host_ip=remote_user_bind_host.host.ip,
            port=remote_user_bind_host.host.port,
            username=remote_user_bind_host.remote_user.remote_user_name,
            password=remote_user_bind_host.remote_user.password
        )
    except Exception as e:
        message = '尝试连接{0}的过程中发生错误:\n {1}'.format(
            remote_user_bind_host.remote_user.remote_user_name, e)
        print(message)
        add_log(request.user, message, log_type='2')
        return HttpResponse("错误!无法建立SSH连接!")

    bridge.shell()

    request.environ.get('wsgi.websocket').close()
    print('用户断开连接.....')
    return HttpResponse("200, ok") 

说明:

  • 获取id对应的远程帐号;
  • 调用WSSHBridge()方法,传入websocket对象和当前用户,创建一个websocket和ssh通信的桥接类,这个类一会我们会介绍。
  • 调用open方法启动ssh通信;
  • 调用shell方法启动终端环境;
  • 通信结束后调用close方法,关闭通道。

那么这里的WSSHBridge类是什么呢?

10. WSSHBridge桥接通信类

WSSHBridge:

import gevent
from gevent.socket import wait_read, wait_write
import paramiko
import json


class WSSHBridge:
    """
    桥接websocket和SSH的核心类
    """

    def __init__(self, websocket, user):
        self.user = user
        self._websocket = websocket
        self._tasks = []
        #...

    def open(self, host_ip, port=22, username=None, password=None):
        """        建立SSH连接        """
        pass

    def _forward_inbound(self, channel):
        """        正向数据转发,websocket ->  ssh        """
        pass

    def _forward_outbound(self, channel):
        """        反向数据转发,ssh -> websocket        """
        pass

    def _bridge(self, channel):
        """        桥接websocket和ssh        """
        pass

    def close(self):
        """        结束桥接会话        """
        pass

    def shell(self):
        """        启动一个shell通信界面        """
       pass

首先需要pip install paramiko安装模块。

WSSHBridge类,本质上就是桥接websocket通道和paramiko打开的ssh通道,进行数据双向转发。

open方法调用paramiko的相关API,传入主机ip、port、用户名和密码,打开ssh通道,_forward_inbound_forward_outbound方法分别实现数据的正向和反向转发。

核心的关键是_bridge方法:

self._tasks = [
            gevent.spawn(self._forward_inbound, channel),
            gevent.spawn(self._forward_outbound, channel),
        ]
        gevent.joinall(self._tasks)

使用gevent的spawn方法创建了两个协同任务,然后调用joinall方法等待它们任务结束。这样就实现了数据在websocket通道和ssh通道之间的一发一收,一收一发的通信机制。

这一步完成后,重启服务器,我们就可以来展示整个通信过程了。

首先是,连接成功:

开发基于Django和Websocket的堡垒机

其次是类似Python这种交互式命令:

开发基于Django和Websocket的堡垒机

然后是top这种动态命令结果返回:

开发基于Django和Websocket的堡垒机

最后是vim这种编辑环境:

开发基于Django和Websocket的堡垒机

可以看到,我们是支持彩色输出的:

开发基于Django和Websocket的堡垒机

11. 日志记录和行为审计

关于用户操作,在数据由websocket往ssh发送过程中,可以保存用户通过前端Linux模拟器终端所敲击的所有按键记录,并且很规整的以回车键进行分隔,非常容易判别。

我们只需要创建一个日志模型,编写一个保存日志的方法,然后在需要的位置保存日志即可。

日志展示页面非常类似主机账户的页面,同样使用datatable插件进行处理,最终效果如下图所示:

开发基于Django和Websocket的堡垒机

至此,基于Webssh的堡垒机核心功能就开发完毕了。限于篇幅,不可能点点滴滴、枝叶不漏的全部叙述,我这里也只是一个抛砖引玉的过程。

四、总结

远程主机的创建、主机账号的管理、堡垒机用户和用户组的管理,这一系列的工作,目前我还是放在admin后台中进行。后期,大家可以将它迁移到堡垒机页面中一起管理。如果将IronFort用于生产环境,添加批量命令执行、文件分发功能,进行系统部署上线、结合Linux运维等等,必然需要大量的额外工作和安全机制,这些就留给大家自己去研究了。

上一篇:python – 用于Stream Server的数据库连接池的Gevent


下一篇:python – 捕获greenlets中引发的异常