1. 程式人生 > >堡壘機的核心武器:WebSSH錄影實現

堡壘機的核心武器: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\r\r\n"]

似乎很完美,按照上邊的思路錄影檔案就應該沒有問題了,但還有一些細節需要處理

首先是需要歷史連線列表,在這個列表裡可以看到什麼時間,哪個使用者連線了哪臺主機,當然也需要提供回放功能,新建一張表來記錄這些資訊

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) + '\n')
    else:
        iodata = [time.time() - self.time, 'o', data]
        with open(RECORD_DIR + self.filename, 'a', buffering=1) as f:
            f.write((json.dumps(iodata) + '\n'))

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 class="modal fade" id="modalForm">
  <div class="modal-dialog modal-lg">
    <div class="modal-content">
      <div class="modal-body" id="play">
      </div>
    </div>
  </div>
</div>

// 播放錄影
function play(host,user,time,file) {
  $('#play').html(
    '<asciinema-player id="play" title="WebSSH Record" author="ops-coffee.cn" author-url="https://ops-coffee.cn" author-img-url="/static/img/logo.png" src="/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端使用介紹』

演示與總結

在寫入檔案的方案中,考慮了實時寫入和一次性寫入,實時寫入就像上邊這樣,所有的操作都會實時寫入錄影檔案,好處是錄影不丟失,且能在操作的過程中進行實時的播放,缺點也很明顯,就是會頻繁的寫檔案,造成IO開銷

一次性寫入可以在使用者操作的過程中將錄影資料寫入記憶體,在websocket關閉時一次性非同步寫入到檔案中,這種方案在最終寫入檔案時可能因為種種原因而失敗,從而導致錄影丟失,還有個缺點是當你WebSSH操作時間過長時,會導致記憶體的持續增加

兩種方案一種是對磁碟的消耗另一種是對記憶體的消耗,各有利弊,當然你也可以考慮批量寫入,例如每分鐘寫一次檔案,一分鐘之內的儲存在記憶體中,平衡記憶體和磁碟的消耗,期待你的實現


相關文章推薦閱讀:

  • Django實現WebSSH操作Kubernetes Pod
  • Django實現WebSSH操作物理機或虛擬機器