实现思路

要想实现类似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作为存储,维护不同消息传递。

具体实现

  1. xterm.js 在浏览器端模拟 shell 终端, 监听用户输入通过 websocket 将用户输入的内容上传到 django
  2. django 接受到用户上传的内容, 将用户在前端页面输入的内容通过 paramiko 建立的 ssh 通道上传到远程服务器执行
  3. paramiko 将远程服务器的处理结果返回给 django
  4. django 将 paramiko 返回的结果通过 websocket 返回给用户
  5. 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" /> &lt;!&ndash; 终端视图容器 &ndash;&gt;-->
</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 os

from django.core.asgi import get_asgi_application

os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'devops_api.settings')

application = get_asgi_application()

配置路由

devops_api/devops_api/routing.py

from channels.auth import AuthMiddlewareStack
from channels.routing import ProtocolTypeRouter, URLRouter

from django.urls import re_path
from .consumers import SSHConsumer

application = 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),
])
),
})

# /server/terminal/192.168.1.10/22/2

websocket服务端消费

devops_api/devops_api/consumers.py

from channels.generic.websocket import WebsocketConsumer
import threading
import paramiko
import json
from io import StringIO
from system_config.models import Credential

class 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

# 处理客户端与WebSocket建立连接
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() # 接受与客户端建立WebSocket连接
self.ssh_stream() # 创建SSH连接和交互式Shell会话通道

def ssh_stream(self):
self.send(bytes_data='建立SSH连接中...\r\n'.encode('utf-8')) # 字符串转字节(必须)
try:
# 创建SSH连接
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)
# 创建SSH通道
self.ssh_channel = ssh.invoke_shell(term='xterm') # 创建交互式Shell会话,并指定伪终端的终端类型为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):
# 通过WebSocket向客户端发送消息
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

# 循环读取SSH通道返回的数据并发送给前端
def _loop_read(self):
while True:
data = self.ssh_channel.recv(32 * 1024)
if not data:
break
self.send(bytes_data=data)

# 处理从客户端接收到的 WebSocket 消息
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']) # Shell命令
except Exception as e:
print('接收前端终端窗口数据异常,通常是SSH通道建立异常')

# 关闭WebSocket连接时调用
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 = {
# 真实上线使用redis
# 'default': {
# 'BACKEND': 'channels_redis.core.RedisChannelLayer',
# 'CONFIG': {
# "hosts": [('127.0.0.1', 6379)], # 需修改redis的地址
# },
# },
# 测试阶段,放到内存中即可
'default': {
'BACKEND': 'channels.layers.InMemoryChannelLayer',
},
}

总结

用户打开浏览器–》浏览器发送websocket请求给Django建立长连接–》Django与要操作的服务器建立SSH通道,实时的将收到的用户数据发送给SSH后的主机,并将主机执行的结果数据返回给浏览器。