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.如果用户没有权限登录就连接失败,关闭窗口连接也会断开

 

需要注意地方,如果你用普通用户登录,需要把登录的公钥写到普通用户的.ssh/authorized_keys里面,并且要修改权限属性chmod 644 authorized_keys

 总结:如果后台配合权限整合webssh功能,对使用者来说带来很多方便,不妨试试~ 

posted @ 2021-06-04 17:39  lucky_tomato  阅读(1193)  评论(0编辑  收藏  举报