SocketIO+FastAPI实现终端命令行窗口

news/2025/2/10 15:26:26/文章来源:https://www.cnblogs.com/xiaozuq/p/18704523

SocketIO+FastAPI实现终端命令行窗口

要实现什么样的功能:

  1. 像PuTTY和Xshell一样的远程终端功能
  2. 可同时连接多台机器
  3. 一台机器可同时打开多个终端窗口
  4. 窗口调整大小是后端也应该对应调整换行

前置

采用如下技术

所需框架 子模块 用途
vue3 前端框架
vue-router 路由跳转
vuex 存储终端链接、确保路由跳转不断开连接
element-plus el-tabs 实现多个终端窗口间切换
xterm.js 前端展示终端窗口
socket.io-client 前端连接后端的websocket框架
FastAPI 后端框架
socketio websocket后端框架
paramiko SSHClient 实现SSH功能

以上的框架就足够满足需求了

框架间拼接

后端框架拼接

后端框架采用FastAPI,FastAPI集成了对WebSocket的支持,但是需要自己手动实现的地方太多,前端框架使用SocketIO,那后端能不能也采用SocketIO框架呢,显然是可以的,但是除了实现本文章的终端需求外,还要再添加登录、注册、权限管理等等功能,http方式的请求也要处理。

那如何将socketIO嵌入到FastAPI里边呢?答案是没找到解决方案。但是!找到了把FastAPI嵌入SocketIO里边的案例。

import socketio
from fastapi import FastAPI
import uvicornsio = socketio.AsyncServer(async_mode="asgi",cors_allowed_origins='*',cors_credentials=True,logger=False, engineio_logger=False)app = FastAPI(title=f"fast",description=f"后端",docs_url="/docs",openapi_url="/openapi")combined_asgi_app = socketio.ASGIApp(sio, app)uvicorn.run(app=combined_asgi_app, host='0.0.0.0', port=8890, workers=1)

socketio.AsyncServer的代码注释中看到

This class implements a fully compliant Socket.IO web server with support

for websocket and long-polling transports, compatible with the asyncio

framework.

这个AsyncServer兼容asyncio 框架

image

有兴趣的朋友可以阅读ASGI的文档https://asgi.readthedocs.io/en/latest/introduction.html

反正甭管了,现在FastAPI和SocketIO配合的好好的

SocketIO还提前预定了几个事件:

image

我们可以在连接时候进行权限认证,断开连接时候进行销毁终端窗口

"""
open_terminals = {'sid1': paramiko.SSHClient(),'sid2': paramiko.SSHClient(),
}
"""
open_terminals = {}
namespace = '/terminal'@sio.on('connect', namespace=namespace)
async def connect(sid, environ, auth):if validate_user_ws(auth) is False:# 验证不通过# print(f'{sid} 验证不通过 {auth}')await sio.disconnect(sid=sid, namespace=namespace)@sio.on('disconnect', namespace=namespace)
async def disconnect(sid):if open_terminals.get(sid):open_terminals[sid].close()# print(f'{sid}退出连线!')

接下来可以通过paramiko.SSHClient()的方式创建一个客户端

大体步骤为
1:创建终端
2:存储终端信息
3:监听前端发过来的字符
4:while True 反复监听终端的输出,一旦有输出,通过SocketIO的emit立马返回给前端
5:其他类型的错误也要断开连接并通知前端

def receive_output(channel):# 获取终端的输出output = ''while channel.recv_ready():try:output += channel.recv(1024).decode('utf-8')except UnicodeDecodeError as e:output += str(channel.recv(1024))return output@sio.on('connect_terminal', namespace=namespace)
async def connect_terminal(sid, data):"""sid: 连接的siddata: 连接信息 可以传输用户名和密码 {'username': 'root', 'password': '123456'}"""global open_terminals, open_clienttry:# 1:创建终端client = paramiko.SSHClient()client.set_missing_host_key_policy(paramiko.AutoAddPolicy())try:client.connect(hostname='localhost', username=data['username'], password=data['password'],auth_timeout=10)# 2:存储终端信息open_client[sid] = clientopen_terminals[sid] = client.invoke_shell()await sio.emit('reply_connect_terminal', '连接成功')# 4:while True 反复监听终端的输出,一旦有输出,通过SocketIO的emit立马返回给前端while True:try:# 判断对应sid的终端会话是否存在if open_terminals.get(sid) and open_client.get(sid):res = receive_output(open_terminals[sid])if len(res):# print(f'返回长度: {len(res)} 返回内容:|||{res}|||')await sio.emit('reply_cmd_res', data=res, to=sid, namespace=namespace)else:await asyncio.sleep(0.1)else:breakexcept Exception as e:# 5:其他类型的错误也要断开连接并通知前端await sio.emit('reply_cmd_res', data=f'连接已断开: {e}', namespace=namespace)print(traceback.format_exc())client.close()# 删除发生报错的指定的会话终端del open_terminals[sid]del open_client[sid]breakexcept paramiko.ssh_exception.AuthenticationException:await sio.emit('reply_cmd_res', data='用户名或密码错误', to=sid, namespace=namespace)except Exception as e:print('链接错误')print(traceback.format_exc())except Exception as e:print(traceback.format_exc())await sio.emit('reply_connect_terminal', f'错误:{e}')

以上过程类似于打开了一个终端,啥也没干,但此时终端已经有返回了,如果程序运行在windows机器上此时会通过

await sio.emit('reply_cmd_res', data=res, to=sid, namespace=namespace)

前端返回以下内容

Microsoft Windows [版本 10.0.19045.3086]
(c) Microsoft Corporation。保留所有权利。C:\Users\user>

做完了上述步骤,似乎还有一条没有实现就是3:监听前端发过来的字符

@sio.on('send_cmd', namespace=namespace)
async def send_cmd(sid, cmds):global open_terminals, open_clientif open_terminals.get(sid) and open_client.get(sid):try:open_terminals[sid].send(cmds)except Exception as e:await sio.emit('reply_cmd_res', data=f'连接已断开: {e}', namespace=namespace)print(traceback.format_exc())open_client[sid].close()open_terminals[sid] = Noneopen_client[sid] = Noneelse:await sio.emit('reply_cmd_res', data='尚未连接该机器', namespace=namespace)

open_terminals[sid] 实际就是client.invoke_shell()返回的Channel

我们使用Channelsend方法接收前端传过来的字符,并将字符发送到终端会话。

而在步骤4中,我们已经对终端会话进行监听并返回前端。

目前为止后端已经实现了创建、删除、监听和返回的基本功能

并向前端约定了以下几个接口:

事件名 备注 数据类型
connect_terminal 前端发送 创建终端并监听 dict:
send_cmd 前端发送 监听字符输入 str
reply_connect_terminal 前端接收 返回终端是否创建成功 str
reply_cmd_res 前端接收 终端监听内容 str

至于connectdisconnect 是SocketIO定义的

connect中的auth参数,根据自己的需求可以设置不同的验证手段

小功告成!

前端框架拼接

使用Vue3 + element-plus + vuex + vue-router + xterm 来实现前端的展示

至于以上框架的安装这里就不提了

那么我需要实现以下需求:

1:连接后端

2:报错处理

3:实现一个黢黑的控制台窗口

4:可以接收后端的返回,并显示在控制台窗口上

5:可以向后端发送websocket请求,将按键的内容发送到后端

6:当关闭窗口时通知后端

好了,先来实现vuex store部分,我们选择将连接保存在state中,然后创建窗口和连接websocket在actions里边实现

import { io } from "socket.io-client";
import { createStore } from "vuex";
import {getXtrem} from "@/utils/trem.ts";const store = createStore({state: {token: undefined | "" | null,terminals: [],terminalSocket: undefined,// 命令列表terminalCmds: [],// 连接状态terminalIsInitialized: false,},actions: {async initTerminal(content, config) {const socket = io(`ws://${config.ip}:${config.port}/terminal`, {autoConnect: false,withCredentials: false,auth: content.state.token,// path: process.env.NODE_ENV === "production" ? "/api/socket.io/" : "/socket.io/"path: "/socket.io/"})await socket.connect()const terminalInfo = {tid: config.tid,id: config.id,ip: config.ip,name: config.host,terminalIsInitialized: false,terminal: getXtrem(),terminalSocket: socket,terminalXtermRef: `terminalXterm${config.tid}`}content.state.terminals.push(terminalInfo)// 绑定事件socket.on("connect_error", (err) => {// the reason of the error, for example "xhr poll error"console.log(err.message);// some additional description, for example the status code of the initial HTTP responseconsole.log(err.description);// some additional context, for example the XMLHttpRequest objectconsole.log(err.context);});socket.on('reply_cpu_status', (data) =>{content.state.cpuInfo = data})socket.on('reply_connect_terminal', (data) => {console.log('连接成功', data)if (data == '连接成功') {content.state.terminals.filter((item) => {if (item.tid == config.tid) {item.terminalIsInitialized = true}})}})socket.on('reply_cmd_res', (data) =>{if (data) {terminalInfo.terminal.write(data)}})console.log(content.state.terminals)await socket.emit('connect_terminal', {username: config.user, password: config.password})return terminalInfo},// async getTerminalByTid(content, tid: Number){//     return content.state.terminals.filter(item => item.tid == tid)// },async writeTerminal(content, kw) {content.state.terminal.write(kw)},async connect_terminal(content, data: any) {await socket.emit('connect_terminal', data)},async send_cmd(content, {terminalSocket, data}) {await terminalSocket.emit('send_cmd', data)},async terminalDestroy(content, terminalSocket) {await terminalSocket.emit('terminal_destroy', terminalSocket.id)store.state.terminals = store.state.terminals.filter(item => item.terminalSocket.id !== terminalSocket.id)},async terminalWidthChange(content, {terminalSocket, width, height}) {await terminalSocket.emit('terminal_width_change', {width, height})},getTerminalByTid(content, tid: Number) {const info = content.state.terminals.filter(item => item.tid == tid)return info.length ? info[0]: null},}
})export default store;

我们接下来element-plus官网来挑个组件,看看有符合的组件没有

image

这个el-tabs不错,还带着关闭按钮,到时候只需要把Tab 1 content的内容替换成xtrem的命令窗口就完事了
那还等什么,直接把这个代码复制下来,删点无关的逻辑,加上自己的

<template><el-tabsv-model="editableTabsValue"type="card"editableclass="demo-tabs"@edit="handleTabsEdit"><el-tab-panev-for="item in store.state.terminals":key="item.tid":label="item.ip + ' (' + (item.tid+1) + ')'":name="item.tid"><div class="terminal-container" :ref="item.terminalXtermRef"></div></el-tab-pane></el-tabs>
</template><script lang="ts" setup>
import { ref, onMounted } from 'vue'
import type { TabPaneName } from 'element-plus'const editableTabsValue = ref(0)function handleTabsEdit(targetName: TabPaneName | undefined, action: 'remove' | 'add') {if (action === 'add') {// 创建一个终端// 将标签页切换到刚打开的终端} else if (action === 'remove') {// 1.通知后端销毁终端// 2.将标签页切换到最后一个打开的终端}
}onMounted(() => {// 连接后端websocket// 监听前端按键
})
</script><style>
</style>

这个就是大体逻辑,接下来,把store的给补到这个里边

<template><el-tabsv-model="editableTabsValue"type="card"editable@edit="handleTabsEdit"><template #add-icon><el-icon><Plus/></el-icon></template><el-tab-panev-for="item in store.state.terminals":key="item.tid":label="item.ip + ' (' + (item.tid+1) + ')'":name="item.tid"><div :ref="item.terminalXtermRef"></div></el-tab-pane><div v-if="store.state.terminals.length == 0"> 暂未有打开的命令行,请切换至机器页面选择命令行</div></el-tabs>
</template>
<script lang="ts" setup>
import {ref, watch, onMounted, getCurrentInstance, onUnmounted, toRaw, reactive} from 'vue'import "xterm/css/xterm.css";
import store from "@/store/index.ts";
import {useRouter} from "vue-router";
import type {TabPaneName} from 'element-plus'const route = useRouter()
const { proxy } = getCurrentInstance()
const domInstance = getCurrentInstance()const termWidth = ref(0)
const editableTabsValue = ref(0)function handleWinwosSize() {store.state.terminals.forEach(terminalInfo=> {// 获取终端窗口div的大小,单位为像素var width = domInstance.refs[terminalInfo.terminalXtermRef][0].offsetWidthvar height = domInstance.refs[terminalInfo.terminalXtermRef][0].offsetHeightif (width && height) {// 终端字体大小为14px,反复尝试,平均每个字符高18px、宽8.8px可以让后端的返回不会让元素溢出var cols = Math.floor(width / 8.8);var rows = Math.floor(height / 18);// 前端更改xterm窗口大小terminalInfo.terminal.resize(cols, rows);// 通知后端更改终端窗口大小terminalInfo.terminalSocket.emit('terminal_width_change', {'width': cols, 'height': rows})}})
}onMounted(() => {// 监听前端窗口大小变化事件window.addEventListener('resize', handleWinwosSize);// 将store存储的所有打开的窗口渲染store.state.terminals.forEach(terminalInfo=> {terminalInfo.terminal.open(proxy.$refs[terminalInfo.terminalXtermRef][0])})// 如果是新建窗口,会在url中传递主机的id,并新建一个终端;如果是页面切换则不新建终端const host_id = route.currentRoute.value.query.idif (host_id) {let terminalInfo = await store.dispatch("initTerminal", {id: host_id,host: name,ip: ip, port: port,user: ssh_user,password: ssh_passwd,tid: store.state.terminals.length })terminalInfo.terminal.open(proxy.$refs[terminalInfo.terminalXtermRef][0])// 绑定按键事件全部发送到store的actionsterminalInfo.terminal.onData((data) => {store.dispatch('send_cmd', {terminalSocket: terminalInfo.terminalSocket, data: data})})handleWinwosSize()return}
})onUnmounted(() => {window.removeEventListener('resize', handleWinwosSize)
})function handleTabsEdit(targetName: TabPaneName | undefined, action: 'remove' | 'add') {if (action === 'remove') {// 关闭指定标签页store.dispatch('getTerminalByTid', targetName).then(terminalInfo => {if (terminalInfo) {// 断开指定标签页的websocket链接store.dispatch('terminalDestroy', terminalInfo.terminalSocket)}})editableTabsValue.value = store.state.terminals[store.state.terminals.length - 1].tid}
}
</script>
<style>
</style>

至此,前端也完成了。

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.hqwc.cn/news/881701.html

如若内容造成侵权/违法违规/事实不符,请联系编程知识网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

容器应用与集群管理ACK Serverless

完成网站应用的部署之后,在这一步中,我们体验一下通过ACK Serverless可以如何对应用容器集群进行基本的运维和管理。 查看应用集群查看集群在ACK Serverless控制台点击集群名称,可以查看容器集群信息、无状态应用信息、服务信息、容器组信息等。 查看集群的基本信息,我们还…

selenium框架使用最佳实践

安装chromdriver插件 selenium框架使用需要下载chromdriver插件,版本需要和chrome浏览器的大版本一致,查看浏览器版本方法:chrome://version/ChromeDriver的主版本号(即120)与Chrome浏览器主版本号匹配就可以了,不需要小版本号完全一致。 chromdriver的官方下载页面:htt…

004 字符串的扩展

1、字符串Unicode表示法ES6加强了对Unicode的支持,允许采用\uxxx形式表示一个字符,其中xxxx表示字符的Unicode码点。 Unicode统一吗(Unicode),也叫万国码、单一码,是计算机科学领域里的一项业界标准,包括字符集、编码方案等。Unicode是为了解决传统的字符编码方案的局限而…

大模型推理服务全景图

推理性能的提升涉及底层硬件、模型层,以及其他各个软件中间件层的相互协同,因此了解大模型技术架构的全局视角,有助于我们对推理性能的优化方案进行评估和选型。作者:望宸 随着 DeepSeek R1 和 Qwen2.5-Max 的发布,国内大模型推理需求激增,性能提升的主战场将从训练转移到…

三菱变频器与西门子PLC的高效通讯之道:EtherNet/IP 转 ModbusTCP配置实战

三菱变频器与西门子PLC的高效通讯之道:EtherNet/IP 转 ModbusTCP配置实战一、案例背景 某汽车制造公司拥有一条高度自动化的生产线,该生产线集成了来自不同品牌的机器人、传感器和检测设备。这些设备分别采用MODBUS TCP和EtherNet/IP协议进行通信,但由于协议不兼容,导致数据…

windows镜像esd转iso

背景 经常在三方网站下载到精简系统,但是这些系统的格式不仅仅是iso,还有可能是esd。 虽然两者几乎等价,但是有些平台 比如虚拟机、mac转换助理不能识别esd格式的镜像。 windows下转换 准备工作 首先要先下载所需的ISO外壳和Ultraiso软碟通软件。 把你要安装的ESD系统改名,…

alice.ws的VPS怎么样?

这是是香港 1美元的机器,延迟可以,但速度他标1Gbps,但我广东移动网络,测的速度, 垃圾地离谱,怀疑是限制速度,是我见过最垃圾的,其它VPS节点是正常的50Mbps左右,不是我网络有问题。

003 对象解构赋值

解构可以用于对象 let{name,age}={name:"iwen",age:20}; 温馨提示:对象的属性没有次序,变量必须与属性同名,才能取到正确的值 let {age,name}={name:"mingzi",age:20}; age//20 let{sex,age,name}={name:"mingzi",age:20}; sex//undefind 对…

【EasyExcel】 easyExcel 3.2.1 生成多sheet的xlsx文件

pom依赖:<dependency><groupId>com.alibaba</groupId><artifactId>easyexcel</artifactId><version>3.2.1</version></dependency> 核心util写法:import com.alibaba.excel.EasyExcel; import com.alibaba.excel.ExcelWrit…

CVE-2024-41592 of DrayTek vigor3910 复现

getcgi接口存在堆栈溢出CVE-2024-41592 of DrayTek vigor3910 复现 漏洞简介DrayTek Vigor3910 devices through 4.3.2.6 have a stack-based overflow when processing query string parameters because GetCGI mishandles extraneous ampersand characters and long key-valu…

一次挂载磁盘经验

确认情况 查看当前磁盘设备 lsblk发现md124,md125有四块硬盘,md126,md127有3块硬盘。 判断md124是四块硬盘做的raid,md125是md124的元数据 同理,md126是三块盘做的raid,md127是元数据 创建挂载点 mkdir /mnt/md124试图直接mount失败 mount /dev/md124 /mnt/md124直接执行挂…

spring项目启动后,直接停止

在启动一个新的项目后,项目启动了,但是直接停止了。 这是该项目的application目录 没有原始的application.yml文件。所以项目没有查找到对应的配置文件,启动直接停止了。 本地启动中可以在IDEA中配置选择程序实参指定读取哪个配置文件