堡垒机的核心武器:WebSSH录像实现

摘要:
WebSSH终端视频录制的实现终于到来了。前面写了两篇文章,“Asciinema:您的所有操作都将被记录”和“Ascinema文章更正和Web端使用介绍”,深入介绍了终端记录工具Asciinema。我们已经可以在终端下记录操作过程。如何在WebSSH中记录操作并提供后续回放审核?

WebSSH终端录像的实现终于来了

前边写了两篇文章『Asciinema:你的所有操作都将被录制』『Asciinema文章勘误及Web端使用介绍』深入介绍了终端录制工具Asciinema,我们已经可以实现在终端下对操作过程的录制,那么在WebSSH中的操作该如何记录并提供后续的回放审计呢?

一种方式是『Asciinema:你的所有操作都将被录制』文章最后介绍的自动录制审计日志的方法,在主机上添加个脚本,每次连接自动进行录制,但这样不仅要在每台远程主机添加脚本,会很繁琐,而且录制的脚本文件都是放在远程主机上的,后续播放也很麻烦

那该如何更好处理呢?下文介绍一种优雅的方式来实现,核心思想是不通过录制命令进行录制,而在Webssh交互执行的过程中直接生成可播放的录像文件

设计思路

通过上边两篇文章的阅读,我们已经知道了Asciinema录像文件主要由两部分组成:header头和IO流数据

header头位于文件的第一行,定义了这个录像的版本、宽高、开始时间、环境变量等参数,我们可以在websocket连接创建时将这些参数按照需要的格式写入到文件

header头数据如下,只有开头一行,是一个字典形式

{"version": 2, "width": 213, "height": 55, "timestamp": 1574155029.1815443, "env": {"SHELL": "/bin/bash", "TERM": "xterm-256color"}, "title": "ops-coffee"}

整个录像文件除了第一行的header头部分,剩下的就都是输入输出的IO流数据,从websocket连接建立开始,随着操作的进行,IO流数据是不断增加的,直到整个websocket长连接的结束,那就需要在整个WebSSH交互的过程中不断的往录像文件追加输入输出的内容

IO流数据如下,每一行一条,列表形式,分别表示操作时间,输入或输出(这里我们为了方便就写固定字符串输出),IO数据

[0.2341010570526123, "o", "Last login: Tue Nov 19 17:11:30 2019 from 192.168.105.91

"]

似乎很完美,按照上边的思路录像文件就应该没有问题了,但还有一些细节需要处理

首先是需要历史连接列表,在这个列表里可以看到什么时间,哪个用户连接了哪台主机,当然也需要提供回放功能,新建一张表来记录这些信息

class Record(models.Model):
    create_time = models.DateTimeField(auto_now_add=True, verbose_name='创建时间')

    host = models.ForeignKey(Host, on_delete=models.CASCADE, verbose_name='主机')
    user = models.ForeignKey(User, on_delete=models.CASCADE, verbose_name='用户')

    filename = models.CharField(max_length=128, verbose_name='录像文件名称')

    def __str__(self):
        return self.host

其次还需要考虑的一个问题是header和后续IO数据流要写入同一个文件,这就需要在整个websocket的连接过程中有一个固定的文件名可被读取,这里我使用了主机+用户+当前时间作为文件名,同一用户在同一时间不能多次连接同一主机,这样可保证文件名不重复,同时避免操作写入错误的录像文件,文件名在websocket建立时初始化

def __init__(self, host, user, websocket):
    self.host = host
    self.user = user

    self.time = time.time()
    self.filename = '%s.%s.%d.cast' % (host, user, self.time)

IO流数据会持续不断的写入文件,这里以一个独立的方法来处理写入

def record(self, type, data):
    RECORD_DIR = settings.BASE_DIR + '/static/record/'
    if not os.path.isdir(RECORD_DIR):
        os.makedirs(RECORD_DIR)

    if type == 'header':
        Record.objects.create(
            host=Host.objects.get(id=self.host),
            user=self.user,
            filename=self.filename
        )

        with open(RECORD_DIR + self.filename, 'w') as f:
            f.write(json.dumps(data) + '
')
    else:
        iodata = [time.time() - self.time, 'o', data]
        with open(RECORD_DIR + self.filename, 'a', buffering=1) as f:
            f.write((json.dumps(iodata) + '
'))

record接收两个参数type和data,type标识本次写入的是header头还是IO流,data则是具体的数据

header只需要执行一次写入,所以将其放在ssh的connect方法中,只在ssh连接建立时执行一次,在执行header写入时同时往数据库插入新的历史记录数据

调用record方法写入header

def connect(self, host, port, username, authtype, password=None, pkey=None,
            term='xterm-256color', cols=80, rows=24):
    ...

    # 构建录像文件header
    self.record('header', {
        "version": 2,
        "width": cols,
        "height": rows,
        "timestamp": self.time,
        "env": {
            "SHELL": "/bin/bash",
            "TERM": term
        },
        "title": "ops-coffee"
    })

IO流数据则需要与返回给前端的数据保持一致,这样就能保证前端显示什么录像就播放什么了,所以所有需要返回前端数据的地方都同时写入录像文件即可

调用record方法写入io流数据

def connect(self, host, port, username, authtype, password=None, pkey=None,
            term='xterm-256color', cols=80, rows=24):
    ...

    # 连接建立一次,之后交互数据不会再进入该方法
    for i in range(2):
        recv = self.ssh_channel.recv(65535).decode('utf-8', 'ignore')
        message = json.dumps({'flag': 'success', 'message': recv})
        self.websocket.send(message)

        self.record('iodata', recv)

...

def _ssh_to_ws(self):
    try:
        with self.lock:
            while not self.ssh_channel.exit_status_ready():
                data = self.ssh_channel.recv(1024).decode('utf-8', 'ignore')
                if len(data) != 0:
                    message = {'flag': 'success', 'message': data}
                    self.websocket.send(json.dumps(message))

                    self.record('iodata', data)
                else:
                    break
    except Exception as e:
        message = {'flag': 'error', 'message': str(e)}
        self.websocket.send(json.dumps(message))
        self.record('iodata', str(e))
        
        self.close()

由于命令执行与返回都是多线程的操作,这就会导致在写入文件时出现文件乱序影响播放的问题,典型的操作有vim、top等,通过加锁self.lock可以顺利解决

最后历史记录页面,当用户点击播放按钮时,调用js弹出播放窗口

<div   id="modalForm">
  <div class="modal-dialog modal-lg">
    <div class="modal-content">
      <div   id="play">
      </div>
    </div>
  </div>
</div>

// 播放录像
function play(host,user,time,file) {
  $('#play').html(
    '<asciinema-player   title="WebSSH Record" author="ops-coffee.cn" author-url="https://ops-coffee.cn" author-img-url="/static/img/logo.png" src="http://t.zoukankan.com/static/record/'+file+'" speed="3" '+
    'idle-time-limit="2" poster="data:text/plain,x1b[1;32m'+time+
    'x1b[1;0m用户x1b[1;32m'+user+
    'x1b[1;0m连接主机x1b[1;32m'+host+
    'x1b[1;0m的录像记录"></asciinema-player>'
  )

  $('#modalForm').modal('show');
}

asciinema-player标签的详细参数介绍可以看这篇文章『Asciinema文章勘误及Web端使用介绍』

演示与总结

堡垒机的核心武器:WebSSH录像实现第1张

在写入文件的方案中,考虑了实时写入和一次性写入,实时写入就像上边这样,所有的操作都会实时写入录像文件,好处是录像不丢失,且能在操作的过程中进行实时的播放,缺点也很明显,就是会频繁的写文件,造成IO开销

一次性写入可以在用户操作的过程中将录像数据写入内存,在websocket关闭时一次性异步写入到文件中,这种方案在最终写入文件时可能因为种种原因而失败,从而导致录像丢失,还有个缺点是当你WebSSH操作时间过长时,会导致内存的持续增加

两种方案一种是对磁盘的消耗另一种是对内存的消耗,各有利弊,当然你也可以考虑批量写入,例如每分钟写一次文件,一分钟之内的保存在内存中,平衡内存和磁盘的消耗,期待你的实现


扫码关注公众号查看更多实用文章

相关文章推荐阅读:

免责声明:文章转载自《堡垒机的核心武器:WebSSH录像实现》仅用于学习参考。如对内容有疑问,请及时联系本站处理。

上篇linux 操作笔记AAC ADTS格式分析下篇

宿迁高防,2C2G15M,22元/月;香港BGP,2C5G5M,25元/月 雨云优惠码:MjYwNzM=

相关文章

virtualbox中centos系统配置nat+host only上网

以前一直使用的是virtualbox的桥接模式,桥接模式的特点: 虚拟机和宿主机处于同等地位,就像是一台真实主机一样存在于局域网中,可以分配到一个网络中独立的IP。 虚拟机和宿主机之间能够互访。 如果网络断开,即便虚拟机和宿主机其实是在一台物理机器上,宿主机也不能够访问虚拟机。 配置方式看:http://www.cnblogs.com/leezhxing/...

PHP使用header方式实现文件下载

php文件下载可以使用http的请求头加上php的IO可以实现,很久之前写过这么一个功能,后来代码没了,今天记录一下 1、先看一下一个正常的http请求 HTTP/1.1 200 OK Server: Tengine Content-Type: application/octet-stream Content-Length: 5050697 Con...

解决Android Studio无法下载sdk的问题

因为google被墙了,android sdk无法下载。然后各种百度,都是说让设置代理,给的代理地址一般都是用的下面这个代理服务器: 大连东软信息学院镜像服务器地址: mirrors.neusoft.edu.cn 端口:80 可是我设了上面这个代理,根本还是下载不了sdk,崩溃~~~ 然后终于在这个网址找到了解决办法:http://ping.chinaz....

跨域无法获取自定义header的问题

同域的时候,header里面的参数可以随便自己定义。服务端都是可以获取的。 但是跨域的时候,除了设置 <add name="Access-Control-Allow-Origin" value="*" /> 如果要自定义header参数的话,要设置。例如要做token验证的时候,要传Authorization的时候要定义这个参数: <ad...

oracle之 单实例监听修改端口

Oracle 单一主机多个实例多个监听器配置要点   1. 一台服务器主机, 有多个实例, 如: TSDB/ORCL; 又需要配置多个监听器 2. 需要指定不同的LISTENER端口   3.pmon只会动态注册port等于1521的监听器, 其它端口则pmon不能动态注册listener, 要想让pmon动态注册listener, 需要设置local_l...

HostOnly Cookie和HttpOnly Cookie

怎么使用Cookie? 通常我们有两种方式给浏览器设置或获取Cookie,分别是HTTP Response Headers中的Set-Cookie Header和HTTP Request Headers中的Cookie Header,以及通过JavaScript对document.cookie进行赋值或取值。 rfc6265第5.2节定义的Set-Cook...