实现思路
要想实现类似SSH终端功能并非易事,主要难点在于页面与连接的目标是实时交互的。说起实时交互,相信大家都有接触过,例如qq、微信、在线客服这些都是,像一些网页版的在线聊天系统常用的实现方案就是websocket。
WebSocket协议与HTTP的主要区别:HTTP是无状态协议,由客户端发起请求,客户端与服务器“一问一答”,因此服务器端无法主动向客户端发送信息。而WebSocket是基于TCP长连接的协议,客户端与服务器建立连接后,服务器端随时能向客户端发送信息。
WebSocket协议的主要价值在于其与HTTP的差异(服务器端与客户端能够保持实时的双向通信),使其在某些应用情景下比HTTP更能满足技术需求。
Django Web框架实现WebSocket主要有两种方式:channels和dwebsocket。
Channels是针对Django项目的一个增强框架,使得Django不仅支持HTTP协议,还能支持WebSocket协议。
为了更好的模拟shell终端,还需要一个前端库xterm.js ,这是一个比较成熟的shell终端模拟库,目前大部分公司实现的webssh都是用的这个。
官网:https://xtermjs.org/
所需技术
channels: 是Django的扩展模块,用于处理WebSocket。
xterm.js:前端模拟 shell 终端的一个库
paramiko:python下对 ssh2 封装的一个库
channels_redis: 使用redis作为存储,维护不同消息传递。
具体实现
xterm.js 在浏览器端模拟 shell 终端, 监听用户输入通过 websocket 将用户输入的内容上传到 django
django 接受到用户上传的内容, 将用户在前端页面输入的内容通过 paramiko 建立的 ssh 通道上传到远程服务器执行
paramiko 将远程服务器的处理结果返回给 django
django 将 paramiko 返回的结果通过 websocket 返回给用户
xterm.js 接收 django 返回的数据并将其写入前端页面
前端(vue)
安装
npm install xterm npm install xterm-addon-fit
前端页面:devops_web/src/views/server/TerminalCreate.vue
<template> <el-dialog :model-value="visible" title="终端" @close="dialogClose" width="30%" > <el-form :model="form" ref="formRef" :rules="formRules" label-width="100px"> <el-form-item label="SSH IP" prop="ssh_ip"> <!-- pod 名称输入框 --> <el-input v-model="form.ssh_ip" /> </el-form-item> <el-form-item label="SSH 端口" prop="ssh_port"> <!-- pod 名称输入框 --> <el-input v-model="form.ssh_port" /> </el-form-item> <el-form-item label="用户名" prop="ssh_username"> <!-- namespace 输入框 --> <el-input v-model="form.ssh_username" /> </el-form-item> <el-form-item label="密码" prop="ssh_password"> <!-- pod 名称输入框 --> <el-input v-model="form.ssh_password" show-password/> </el-form-item> <!-- <div ref="xterm" /> <!– 终端视图容器 –>--> </el-form> <template #footer> <span class="dialog-footer"> <el-button @click="dialogClose">取消</el-button> <el-button type="primary" @click="onSubmit">连接</el-button> </span> </template> </el-dialog> <el-dialog :model-value="xtermvisible" width="60%" title="SSH终端" @close="xtermdialogClose" style="min-width: 1000px;" > <div class="host-info"> <el-tag size="large" type="primary">用户名:{{xtermform.ssh_username}}</el-tag> <!-- <el-tag size="large" type="success">主机名:{{xtermform.hostname}}</el-tag>--> <el-tag size="large" type="warning">IP地址:{{xtermform.ssh_ip}}</el-tag> <el-tag size="large" type="success">端口:{{xtermform.ssh_port}}</el-tag> </div> <div ref="xterm" class="terminal" style="height: 500px;margin-top: 20px"/> </el-dialog> </template> <script> import 'xterm/css/xterm.css' import { Terminal } from 'xterm' import { FitAddon } from 'xterm-addon-fit' // xterm窗口自适应 export default { name: "Terminal", props: { visible: Boolean, row: '', // 当前行内容 }, data() { return { term: '', // term实例 ws: '', // ws连接 form: { ssh_username: '', ssh_ip: '', ssh_port: '', }, xtermform: { ssh_username: '', ssh_ip: '', ssh_port: '', }, xtermvisible: false, formRules: { ssh_username: [ {required: true, message: '请输入用户名', trigger: 'blur'}, ], ssh_password: [ {required: true, message: '请输入密码', trigger: 'blur'}, ], ssh_ip: [ {required: true, message: '请输入SSH IP地址', trigger: 'blur'}, ], ssh_port: [ {required: true, message: '请输入SSH端口', trigger: 'blur'}, ], }, } }, methods: { onRest(){ this.$refs.formRef.resetFields(); }, dialogClose() { this.$emit('update:visible', false); // 当对话框关闭,通过父组件更新为false this.onRest(); //重置表单 // window.location.reload(); }, onSubmit() { this.$refs.formRef.validate((valid) => { if (valid) { this.xtermform.ssh_username = this.form.ssh_username; this.xtermform.ssh_ip = this.form.ssh_ip; this.xtermform.ssh_port = this.form.ssh_port; this.$emit('update:visible', false); this.xtermvisible = true; this.term = new Terminal({ fontSize: 18, convertEol: true, // 启用时,光标将设置为下一行的开头 rendererType: 'canvas', // 渲染类型 cursorBlink: true, // 光标闪烁 cursorStyle: 'bar', // 光标样式 underline theme: { background: '#060101', // 背景色 cursor: 'help' // 设置光标 } }) const fitPlugin = new FitAddon() fitPlugin.activate(this.term) // this.term.loadAddon(fitPlugin) // 建立ws连接 this.ws = new WebSocket(`ws://${process.env.VUE_APP_API_HOST}:${process.env.VUE_APP_API_PORT}/server/terminal/${this.form.ssh_ip}/${this.form.ssh_port}/${this.form.ssh_username}/${this.form.ssh_password}/`) // 建立ws连接成功后回调 this.ws.onopen = () => { // 将term挂载到标签上 this.term.open(this.$refs.xterm) this.term.focus() fitPlugin.fit() } // 获取后端传回的数据 this.ws.onmessage = (res) => { const reader = new window.FileReader() reader.onload = () => this.term.write(reader.result) reader.readAsText(res.data, 'utf-8') } // 用户输入发送到后端 this.term.onData(data => this.ws.send(JSON.stringify({data}))) // 动态设置终端窗口大小 this.term.onResize(({cols, rows}) => { this.ws.send(JSON.stringify({resize: [cols, rows]})) }) window.onresize = () => fitPlugin.fit() // ws关闭连接 this.ws.onclose = (e) => { console.log('Websocket关闭:' + e) // this.cleanupSocket(); } }else { this.$message.error('格式错误!') } }) }, //清理websocket和终端资源 cleanupSocket(){ if (this.ws){ this.ws.close(); } if (this.term){ this.term.dispose(); } }, xtermdialogClose() { this.cleanupSocket(); this.xtermvisible = false; // 父组件必须使用v-model window.location.reload() }, } } </script> <style scoped> </style>
服务端(Django Channels)
安装
pip install channels==2.4 .0 pip install channels_redis pip install paramiko
在settings.py文件中注册channels
INSTALLED_APPS = [ 'channels' , ] ASGI_APPLICATION = 'devops.routing.application'
devops_api/devops_api/asgi.py
""" ASGI config for devops_api project. It exposes the ASGI callable as a module-level variable named ``application``. For more information on this files, see https://docs.djangoproject.com/en/3.2/howto/deployment/asgi/ """ import osfrom django.core.asgi import get_asgi_applicationos.environ.setdefault('DJANGO_SETTINGS_MODULE' , 'devops_api.settings' ) application = get_asgi_application()
配置路由
devops_api/devops_api/routing.py
from channels.auth import AuthMiddlewareStackfrom channels.routing import ProtocolTypeRouter, URLRouterfrom django.urls import re_pathfrom .consumers import SSHConsumerapplication = ProtocolTypeRouter({ 'websocket' : AuthMiddlewareStack( URLRouter([ re_path(r'^server/terminal/(?P<ssh_ip>.*)/(?P<ssh_port>\d+)/(?P<credential_id>\d+)/' , SSHConsumer), re_path(r'^server/terminal/(?P<ssh_ip>.*)/(?P<ssh_port>\d+)/(?P<ssh_username>.*)/(?P<ssh_password>.*)/' , SSHConsumer), ]) ), })
websocket服务端消费
devops_api/devops_api/consumers.py
from channels.generic.websocket import WebsocketConsumerimport threadingimport paramikoimport jsonfrom io import StringIOfrom system_config.models import Credentialclass SSHConsumer (WebsocketConsumer ): def __init__ (self, *args, **kwargs ): super ().__init__(*args, **kwargs) self.ssh_ip = None self.ssh_port = None self.credential_id = None self.ssh_channel = None self.ssh_username = None self.ssh_password = None def connect (self ): self.ssh_ip = self.scope['url_route' ]['kwargs' ]['ssh_ip' ] self.ssh_port = self.scope['url_route' ]['kwargs' ]['ssh_port' ] try : self.credential_id = self.scope['url_route' ]['kwargs' ]['credential_id' ] except Exception as e: self.ssh_username = self.scope['url_route' ]['kwargs' ]['ssh_username' ] self.ssh_password = self.scope['url_route' ]['kwargs' ]['ssh_password' ] self.accept() self.ssh_stream() def ssh_stream (self ): self.send(bytes_data='建立SSH连接中...\r\n' .encode('utf-8' )) try : ssh = paramiko.SSHClient() ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy()) try : credential = Credential.objects.get(id =self.credential_id) username = credential.username if credential.auth_mode == 1 : password = credential.password ssh.connect(self.ssh_ip, self.ssh_port, username, password=password, timeout=5 ) else : cache = StringIO(credential.private_key) private_key = paramiko.RSAKey.from_private_key(cache) ssh.connect(self.ssh_ip, self.ssh_port, username, pkey=private_key, timeout=5 ) except Exception as e: ssh.connect(self.ssh_ip, self.ssh_port, self.ssh_username, self.ssh_password, timeout=5 ) self.ssh_channel = ssh.invoke_shell(term='xterm' ) self.ssh_channel.transport.set_keepalive(30 ) thread = threading.Thread(target=self._loop_read) thread.start() except Exception as e: if "timed out" in str (e): self.send(bytes_data=f'建立SSH连接超时!\r\n' .encode('utf-8' )) self.close() else : self.send(bytes_data=f'建立SSH连接失败!错误: {e} \r\n' .encode('utf-8' )) self.close() return def _loop_read (self ): while True : data = self.ssh_channel.recv(32 * 1024 ) if not data: break self.send(bytes_data=data) def receive (self, text_data=None , bytes_data=None ): data = text_data or bytes_data if data: data = json.loads(data) resize = data.get('resize' ) try : if resize and len (resize) == 2 : self.ssh_channel.resize_pty(*resize) else : self.ssh_channel.send(data['data' ]) except Exception as e: print ('接收前端终端窗口数据异常,通常是SSH通道建立异常' ) def disconnect (self, close_code ): try : self.ssh_channel.close() except Exception as e: print ('关闭SSH通道异常,通常是SSH通道建立异常' )
配置channels存储
docker run --name redis -d -p 6379:6379 redis:5
在settings.py配置文件添加redis配置,内容如下:
CHANNEL_LAYERS = { 'default' : { 'BACKEND' : 'channels.layers.InMemoryChannelLayer' , }, }
总结
用户打开浏览器–》浏览器发送websocket请求给Django建立长连接–》Django与要操作的服务器建立SSH通道,实时的将收到的用户数据发送给SSH后的主机,并将主机执行的结果数据返回给浏览器。