Django + Nginx + Daphne實現webssh功能


前言:日常工作中經常要登錄服務器,我們最常用的就是用ssh終端軟件登錄到服務器操作,假如有一天我們電腦沒有安裝軟件,然后又不知道機器IP信息怎么辦,確實會不夠方便,今天分享下基於django實現前端頁面免密碼登錄服務器操作。
 
一、關鍵的技術
1.WebSocket
WebSocket是一種在單個TCP連接上進行全雙工通訊的協議。WebSocket允許服務端主動向客戶端推送數據。在WebSocket協議中,客戶端瀏覽器和服務器只需要完成一次握手就可以創建持久性的連接,並在瀏覽器和服務器之間進行雙向的數據傳輸。
WebSocket有什么用?
WebSocket區別於HTTP協議的一個最為顯著的特點是,WebSocket協議可以由服務端主動發起消息,對於瀏覽器需要及時接收數據變化的場景非常適合,例如在Django中遇到一些耗時較長的任務我們通常會使用Celery來異步執行,那么瀏覽器如果想要獲取這個任務的執行狀態,在HTTP協議中只能通過輪訓的方式由瀏覽器不斷的發送請求給服務器來獲取最新狀態,這樣發送很多無用的請求不僅浪費資源,還不夠優雅,如果使用WebSokcet來實現就很完美了
 
2.Channels
Django本身不支持WebSocket,但可以通過集成Channels框架來實現WebSocket
Channels是針對Django項目的一個增強框架,可以使Django不僅支持HTTP協議,還能支持WebSocket,MQTT等多種協議,同時Channels還整合了Django的auth以及session系統方便進行用戶管理及認證。
要是實現webssh功能要使用到channels模塊
 
二、配置后端Django
1.環境是Linux(centos6.9),后端語言為python3.6
pip install channels==2.0.0
pip install Django==2.1
pip install uWSGI==2.0.19.1
pip install paramiko==2.4.1
pip install daphne==2.2.5

2.打開django項目的setting.py文件,添加以下內容

INSTALLED_APPS = [
    'channels',
]

ASGI_APPLICATION = 'my_project_name.routing.application'

3.在setting.py同級目錄下添加routing.py文件,routing.py文件就相當於urls.py意思  

from channels.auth import AuthMiddlewareStack
from channels.routing import ProtocolTypeRouter, URLRouter
from assets.tools.channel import routing

application = ProtocolTypeRouter({
    'websocket': AuthMiddlewareStack(
        URLRouter(
            routing.websocket_urlpatterns
        )
    ),
})

4.在你的新建一個app應用下面添加一下目錄文件

tools目錄
    __init__.py
     ssh.py
     tools.py
channel目錄
    __init__.py
     routing.py
     websocket.py

routing.py文件

from django.urls import path
from assets.tools.channel import websocket

websocket_urlpatterns = [
    path('webssh/', websocket.WebSSH) 開頭是webssh請求交給websocket.WebSSH處理
]

websocket.py文件

from channels.generic.websocket import WebsocketConsumer
from assets.tools.ssh import SSH
from django.http.request import QueryDict
from django.utils.six import StringIO
from test_devops.settings import TMP_DIR
import os
import json
import base64


class WebSSH(WebsocketConsumer):
    message = {'status': 0, 'message': None}
    """
    status:
        0: ssh 連接正常, websocket 正常
        1: 發生未知錯誤, 關閉 ssh 和 websocket 連接

    message:
        status 為 1 時, message 為具體的錯誤信息
        status 為 0 時, message 為 ssh 返回的數據, 前端頁面將獲取 ssh 返回的數據並寫入終端頁面
    """

    def connect(self):
        """
        打開 websocket 連接, 通過前端傳入的參數嘗試連接 ssh 主機
        :return:
        """
        self.accept()
        query_string = self.scope.get('query_string')
        ssh_args = QueryDict(query_string=query_string, encoding='utf-8')

        width = ssh_args.get('width')
        height = ssh_args.get('height')
        port = ssh_args.get('port')

        width = int(width)
        height = int(height)
        port = int(port)

        auth = ssh_args.get('auth')
        ssh_key_name = ssh_args.get('ssh_key')
        passwd = ssh_args.get('password')

        host = ssh_args.get('host')
        user = ssh_args.get('user')

        if passwd:
            passwd = base64.b64decode(passwd).decode('utf-8')
        else:
            passwd = None


        self.ssh = SSH(websocker=self, message=self.message)

        ssh_connect_dict = {
            'host': host,
            'user': user,
            'port': port,
            'timeout': 30,
            'pty_width': width,
            'pty_height': height,
            'password': passwd
        }

        if auth == 'key':
            ssh_key_file = os.path.join(TMP_DIR, ssh_key_name)
            with open(ssh_key_file, 'r') as f:
                ssh_key = f.read()

            string_io = StringIO()
            string_io.write(ssh_key)
            string_io.flush()
            string_io.seek(0)

            ssh_connect_dict['ssh_key'] = string_io
            os.remove(ssh_key_file)

        self.ssh.connect(**ssh_connect_dict)

    def disconnect(self, close_code):
        try:
            self.ssh.close()
        except:
            pass

    def receive(self, text_data=None, bytes_data=None):
        data = json.loads(text_data)
        if type(data) == dict:
            status = data['status']
            if status == 0:
                data = data['data']
                self.ssh.shell(data)
            else:
                cols = data['cols']
                rows = data['rows']
                self.ssh.resize_pty(cols=cols, rows=rows)

ssh.py文件

import paramiko
from threading import Thread
from assets.tools.tools import get_key_obj
import socket
import json


class SSH:
    def __init__(self, websocker, message):
        self.websocker = websocker
        self.message = message

    def connect(self, host, user, password=None, ssh_key=None, port=22, timeout=30,
                term='xterm', pty_width=80, pty_height=24):
        try:
            # 實例化SSHClient
            ssh_client = paramiko.SSHClient()
            # 當遠程服務器沒有本地主機的密鑰時自動添加到本地,這樣不用在建立連接的時候輸入yes或no進行確認
            ssh_client.set_missing_host_key_policy(paramiko.AutoAddPolicy())

            if ssh_key:
                key = get_key_obj(paramiko.RSAKey, pkey_obj=ssh_key, password=password) or \
                      get_key_obj(paramiko.DSSKey, pkey_obj=ssh_key, password=password) or \
                      get_key_obj(paramiko.ECDSAKey, pkey_obj=ssh_key, password=password) or \
                      get_key_obj(paramiko.Ed25519Key, pkey_obj=ssh_key, password=password)

                # 連接SSH服務器,這里以賬號密碼的方式進行認證,也可以用key
                ssh_client.connect(username=user, hostname=host, port=port, pkey=key, timeout=timeout)
            # else:
            #     ssh_client.connect(username=user, password=password, hostname=host, port=port, timeout=timeout)

            # 打開ssh通道,建立長連接
            transport = ssh_client.get_transport()
            self.channel = transport.open_session()

            # 獲取ssh通道,並設置term和終端大小
            self.channel.get_pty(term=term, width=pty_width, height=pty_height)

            # 激活終端,這樣就可以正常登陸了
            self.channel.invoke_shell()

            # 連接建立一次,之后交互數據不會再進入該方法
            for i in range(2):
                # SSH返回的數據需要轉碼為utf-8,否則json序列化會失敗
                recv = self.channel.recv(1024).decode('utf-8')
                self.message['status'] = 0
                self.message['message'] = recv
                message = json.dumps(self.message)
                self.websocker.send(message)
        except socket.timeout:
            self.message['status'] = 1
            self.message['message'] = 'ssh 連接超時'
            message = json.dumps(self.message)
            self.websocker.send(message)
            self.close()
        except Exception as e:
            self.close(e)

    # 動態調整終端窗口大小
    def resize_pty(self, cols, rows):
        self.channel.resize_pty(width=cols, height=rows)

    def django_to_ssh(self, data):
        try:
            self.channel.send(data)
        except Exception as e:
            self.close(e)

    def websocket_to_django(self):
        try:
            while True:
                data = self.channel.recv(1024).decode('utf-8')
                if not len(data):
                    return
                self.message['status'] = 0
                self.message['message'] = data
                message = json.dumps(self.message)
                self.websocker.send(message)
        except Exception as e:
            self.close(e)

    def close(self,error=None):
        self.message['status'] = 1
        self.message['message'] = f'{error}'
        message = json.dumps(self.message)
        self.websocker.send(message)
        try:
            self.websocker.close()
            self.channel.close()
        except Exception as e:
            pass

    def shell(self, data):
        Thread(target=self.django_to_ssh, args=(data,)).start()
        Thread(target=self.websocket_to_django).start()

tools.py文件

import time
import random
import hashlib

def get_key_obj(pkeyobj, pkey_file=None, pkey_obj=None, password=None):
    if pkey_file:
        with open(pkey_file) as fo:
            try:
                pkey = pkeyobj.from_private_key(fo, password=password)
                return pkey
            except:
                pass
    else:
        try:
            pkey = pkeyobj.from_private_key(pkey_obj, password=password)
            return pkey
        except:
            pkey_obj.seek(0)

def unique():
    ctime = str(time.time())
    salt = str(random.random())
    m = hashlib.md5(bytes(salt, encoding='utf-8'))
    m.update(bytes(ctime, encoding='utf-8'))
    return m.hexdigest()

三、前端頁面代碼

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>webssh</title>
    <link rel="stylesheet" href="/static/css/ssh/xterm/xterm.css"/>
    <link rel="stylesheet" href="/static/css/ssh/xterm/style.css"/>
    <link rel="stylesheet" href="/static/css/toastr/toastr.min.css">
    <link rel="stylesheet" href="/static/css/bootstrap.min.css"/>
</head>
<body>

<div id="django-webssh-terminal">
    <div id="terminal"></div>
</div>

<script src="/static/js/plugin/jquery.min.js"></script>
<script src="/static/js/plugin/ssh/xterm/xterm.js"></script>
<script src="/static/js/plugin/toastr/toastr.min.js"></script>
<script>
    host_data = {{ data | safe }}
    var port = host_data.port;
    var intrant_ip = host_data.intranet_ip;
    var user_name = host_data.login_user;
    var auth_type = host_data.auth_type;
    var user_key = host_data.ssh_key;

    function get_term_size() {
        var init_width = 9;
        var init_height = 17;

        var windows_width = $(window).width();
        var windows_height = $(window).height();
        return {
            cols: Math.floor(windows_width / init_width),
            rows: Math.floor(windows_height / init_height),
        }
    }

    var cols = get_term_size().cols;
    var rows = get_term_size().rows;
    var connect_info = 'host=' + intrant_ip+ '&port=' + port + '&user=' + user_name + '&auth='  + auth_type + '&password='  + '&ssh_key=' + user_key;


    var term = new Terminal(
        {
            cols: cols,
            rows: rows,
            useStyle: true,
            cursorBlink: true
        }
        ),
        protocol = (location.protocol === 'https:') ? 'wss://' : 'ws://',
        socketURL = protocol + location.hostname + ((location.port) ? (':' + location.port) : '') +
            '/webssh/?' + connect_info + '&width=' + cols + '&height=' + rows;

    var sock;
    sock = new WebSocket(socketURL);

    // 打開 websocket 連接, 打開 web 終端
    sock.addEventListener('open', function () {
        term.open(document.getElementById('terminal'));
    });

    // 讀取服務器端發送的數據並寫入 web 終端
    sock.addEventListener('message', function (recv) {
        var data = JSON.parse(recv.data);
        var message = data.message;
        var status = data.status;
        if (status === 0) {
            term.write(message)
        } else {
            toastr.error('連接失敗,錯誤:' + data.message)
        }
    });

    /*
    * status 為 0 時, 將用戶輸入的數據通過 websocket 傳遞給后台, data 為傳遞的數據, 忽略 cols 和 rows 參數
    * status 為 1 時, resize pty ssh 終端大小, cols 為每行顯示的最大字數, rows 為每列顯示的最大字數, 忽略 data 參數
    */
    var message = {'status': 0, 'data': null, 'cols': null, 'rows': null};

    // 向服務器端發送數據
    term.on('data', function (data) {
        message['status'] = 0;
        message['data'] = data;
        var send_data = JSON.stringify(message);
        sock.send(send_data)
    });

    // 監聽瀏覽器窗口, 根據瀏覽器窗口大小修改終端大小
    $(window).resize(function () {
        var cols = get_term_size().cols;
        var rows = get_term_size().rows;
        message['status'] = 1;
        message['cols'] = cols;
        message['rows'] = rows;
        var send_data = JSON.stringify(message);
        sock.send(send_data);
        term.resize(cols, rows)
    })
</script>
</body>
</html>

四、配置Daphne  

在生產環境一般用django + nginx + uwsgi,但是uwsgi只處理http協議請求,不處理websocket請求,所以要額外添加文件啟動進程,這里使用daphne,在setting.py文件同級目錄下添加asgi.py文件

補充小知識:Daphne 是一個純Python編寫的應用於UNIX環境的由Django項目維護的ASGI服務器。它扮演着ASGI參考服務器的角色。

"""
ASGI entrypoint. Configures Django and then runs the application
defined in the ASGI_APPLICATION setting.
"""

import os
import django
from channels.routing import get_default_application

os.environ.setdefault("DJANGO_SETTINGS_MODULE", "my_project_name.settings")
django.setup()
application = get_default_application()

啟動方式

#你應該在與 manage.py 文件相同的路徑中運行這個命令。
daphne -p 8001 my_project_name.asgi:application

五、配置Nginx  

    upstream wsbackend {
         server 127.0.0.1:8001;
    }

    server {
        listen       80;
        server_name  192.168.10.133;

        location /webssh {
               proxy_pass http://wsbackend;
               proxy_http_version 1.1;
               proxy_set_header Upgrade $http_upgrade;
               proxy_set_header Connection "upgrade";
               proxy_redirect off;
               proxy_set_header Host $host;
               proxy_set_header X-Real-IP $remote_addr;
               proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
               proxy_set_header X-Forwarded-Host $server_name;

        }
    }

六、效果展示  

結合自己的開發的后台,實現最終效果
1.用戶添加ssh私鑰

 

點擊登錄按鈕,如果用戶公鑰在這台機器上面就可以登錄

 

 2.如果用戶沒有權限登錄就連接失敗,關閉窗口連接也會斷開

 

 總結:如果后台配合權限整合webssh功能,對使用者來說帶來很多方便,不妨試試~ 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM