開發基於Django和Websocket的堡壘機

大江東流發表於2020-10-07

WebSSH有很多,基於Django的Web服務也有很多,使用Paramiko在Python中進行SSH訪問的就更多了。但是通過gevent將三者結合起來,實現通過瀏覽器訪問的堡壘機就很少見了。本文將簡要介紹下我開發的IronFort堡壘機,其詳細內容在我的官方網站liujiangblog.com的視訊教程中。

一、堡壘機概述

百度百科:堡壘機,在一個特定的網路環境下,為了保障網路和資料不受來自外部和內部使用者的入侵和破壞,而運用各種技術手段實時收集和監控網路環境中每一個組成部分的系統狀態、安全事件、網路活動,以便集中報警、及時處理及審計定責。

對於一箇中型以上的公司,當使用者和職員人數較多,公司所屬伺服器也數量較大的情況下,其伺服器上的帳號管理難度將急劇增加,參考下面的圖片:

這其中必然存在很多問題,例如:

  • 使用者、主機、賬號數量太多,工作量大,管理混亂;
  • 每個人員的許可權和可使用賬號沒有系統管理,等級區分不明;
  • 使用者直接掌握主機的帳號密碼;
  • 密碼可能交叉使用;
  • 離職人員可能還可以使用公司的帳號;
  • 內部人員可以跳過防火牆,直接使用帳號在機房內訪問;
  • 內部人員離職前設下木馬或暗門,一段時間後再爆發;
  • 對人員的訪問記錄、過往操作沒有日誌和審計,缺乏事後追蹤手段;
  • 其它風險

在執行初期,公司可能採取Excel表格等工具,使用人工管理的方式,靠‘人治’和道德水平約束,但當公司體量逐漸變大的時候,這種方式必然遭到淘汰,於是就出現了堡壘機的概念,如下圖所示:

這種架構帶來如下的好處:

  • 使用者不能直接訪問遠端主機,而是需要通過堡壘機跳轉;
  • 使用者不再掌握遠端主機的帳號密碼,只有訪問堡壘機的帳號;
  • 限制使用者登入遠端主機後的修改密碼能力,不允許修改;
  • 堡壘機的使用者、遠端主機的使用者、使用者密碼、使用者許可權等等都被統一集中管理,大量節省人工成本;
  • 使用者在登入堡壘機後所進行的一切操作將被記錄下來,用於後期的行為審計;
  • 由於沒有遠端主機帳號密碼,即使進入機房也無法直連主機;
  • 還可以實現批量命令執行、檔案分發等附帶功能;
  • 其它收益。

堡壘機的核心概念是使用者不再掌握帳號密碼,使用者的行為被記錄用於審計。堡壘機主要針對的是內部網路和內部人員,對於人員流動性較強、體量大、行業風險高的企業需求特別強烈,比如金融行業。

堡壘機已經擁有商業產品,多數以硬體伺服器為載體進行銷售,價格幾十萬不等。也有開源的解決方案,但這些方案有的不是基於瀏覽器,介面不夠友好,日誌記錄困難;有的基於Tornado,並且只能進行簡單的命令執行功能,而公司使用的是Django;更多的情況是與公司需求不一致,需要二次開發,維護和升級困難,等等不一而足。

‘授人以魚不如授人以漁’,自己掌握了開發堡壘機的核心技能,就可以快速、方便、靈活的針對公司具體需求進行定製開發,既為公司節省了購置硬體經費,又利於維護升級。

二、 IronFort堡壘機體系架構

IronFort堡壘機的體系架構如下圖所示:

一個完整的通訊過程如下:

  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中建立測試用例了,如下圖所示:

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。

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內建的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外掛,提供搜尋、排序和分頁等高階功能,其展示效果如下圖:

7. 在瀏覽器中開啟websocket通道

百度百科:WebSocket協議是基於TCP的一種新的網路協議。它實現了瀏覽器與伺服器全雙工(full-duplex)通訊——允許伺服器主動傳送資訊給客戶端。

本文不打算成為一篇websocket的科普文,有興趣深入研究的可以檢視部落格園的精華博文WebSocket協議:5分鐘從入門到精通

簡單的說,有以下幾點:

  • HTTP本身是無狀態連線,不支援實時通訊;
  • websocket基於HTML5,需要瀏覽器支援;
  • 通過在http報頭中新增upgrade屬性,申請通訊協議升級為websocket;
  • 升級成為websocket通訊後,可以實現瀏覽器和遠端伺服器之間的全雙工實時通訊。

關於websocket的使用教程,可以參考阮一峰專家的博文WebSocket 教程

其具體API如下圖所示:

要簡單的建立並使用一個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作為替代。

最終效果如下:

因為此時後端還沒有完成,所以是連線不上任何主機的。

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通道之間的一發一收,一收一發的通訊機制。

這一步完成後,重啟伺服器,我們就可以來展示整個通訊過程了。

首先是,連線成功:

其次是類似Python這種互動式命令:

然後是top這種動態命令結果返回:

最後是vim這種編輯環境:

可以看到,我們是支援彩色輸出的:

11. 日誌記錄和行為審計

關於使用者操作,在資料由websocket往ssh傳送過程中,可以儲存使用者通過前端Linux模擬器終端所敲擊的所有按鍵記錄,並且很規整的以Enter鍵進行分隔,非常容易判別。

我們只需要建立一個日誌模型,編寫一個儲存日誌的方法,然後在需要的位置儲存日誌即可。

日誌展示頁面非常類似主機賬戶的頁面,同樣使用datatable外掛進行處理,最終效果如下圖所示:

至此,基於Webssh的堡壘機核心功能就開發完畢了。限於篇幅,不可能點點滴滴、枝葉不漏的全部敘述,我這裡也只是一個拋磚引玉的過程。

四、總結

遠端主機的建立、主機賬號的管理、堡壘機使用者和使用者組的管理,這一系列的工作,目前我還是放在admin後臺中進行。後期,大家可以將它遷移到堡壘機頁面中一起管理。如果將IronFort用於生產環境,新增批量命令執行、檔案分發功能,進行系統部署上線、結合Linux運維等等,必然需要大量的額外工作和安全機制,這些就留給大家自己去研究了。

相關文章