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

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 uvicorn

sio = 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: 连接的sid
    data: 连接信息 可以传输用户名和密码 {'username': 'root', 'password': '123456'}

    """
    global open_terminals, open_client
    try:
        # 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] = client
            open_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:
                        break
                except 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]
                    break
        except 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_client
    if 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] = None
            open_client[sid] = None
    else:
        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 response
                console.log(err.description);
                // some additional context, for example the XMLHttpRequest object
                console.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-tabs
    v-model="editableTabsValue"
    type="card"
    editable
    class="demo-tabs"
    @edit="handleTabsEdit"
  >
    <el-tab-pane
      v-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-tabs
      v-model="editableTabsValue"
      type="card"
      editable
      @edit="handleTabsEdit"
  >
    <template #add-icon>
      <el-icon>
        <Plus/>
      </el-icon>
    </template>
    <el-tab-pane
        v-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].offsetWidth
    var height = domInstance.refs[terminalInfo.terminalXtermRef][0].offsetHeight
    if (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.id
  if (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的actions
    terminalInfo.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>

至此,前端也完成了。

posted @ 2025-02-10 15:25  zuq  阅读(18)  评论(0)    收藏  举报