ICode9

精准搜索请尝试: 精确搜索
首页 > 其他分享> 文章详细

浅析如何使用Vue + Xterm.js + SpringBoot + Websocket / Stomp + JSch 实现一个 web terminal 网页版的终端工具

2021-07-17 22:35:46  阅读:252  来源: 互联网

标签:JSch web term sshConnectInfo webSSHData 连接 public 终端 浅析


  先看下具体效果:相当于就是一个网页版的 Xshell 工具,操作起来跟 Xshell 操作一样。前端主要使用 Vue + Xterm + Websocket/Stomp,后端主要使用 SpringBoot + Websocket/Stomp + JSch,下面可以看下具体实现代码,demo 代码主要是讲流程,真正在项目上的话肯定会有代码优化及修改或流程优化等。也可以按自己的理解去做,不要陷入在别人的解决思路里,最初对这方面不大了解,就是看的别人的博客,最后陷入别人的思路里乱搞了很多东西,最后只用了他的 JSch ,其他代码全部重构,就发现其实并不难,所以要有自己独立的思维很重要,这个方案也只能是 demo 实现,也并一定就是最佳的。

一、前端实现代码

  Vue + websocket / stomp + xterm.js ,不清楚的自己查资料咯,我主要说下具体要点:

1、xterm 容器 dom,及引入 xterm.js 及 xterm 的插件 xterm-addon-fit(内含元素自适应插件)

2、websocket / stomp ,连接  -  订阅 / 取消订阅 - 发送消息等,这个比较常见,不多说了

3、要点:我们不关注用户输入什么想输入什么,只要是用户输入的每一步,我们都发送给后台,后台去发送给终端,然后拿到终端的消息返回给我们,我们去 write() 在 xterm 里即可。

  说一下这里碰到的一个问题,也是一个关键点,就是之前博客我写 demo 的时候,是会想到用户输入的什么,我们前端应该先 write 显示在 xterm 上,然后去发送给后台,然后发现就是我输入一个字符会展示2个字符,因为后台会返回给我们那个字符,我在输入时 write 了一次,后台返回时又 write 一次导致重复。所以想到实际上我应该在用户输入时不write,而是直接发给后台,等后台返回我什么,我就 write 什么。如果我在用户输入时就 write,这样其实就会存在很多难以控制的问题,比如前台删除啊,左右移动删除啊,就会有很多坑,虽然在前面的博客有类似的解决,但是不是最好的方案。最好的方案就是上面的第3点。

  可以看下终端返回的数据都是这种带彩色的格式的,所以我们直接拿终端返回的数据去 write 是最合适的了。

<template>
  <div id="terminal" ref="terminal"></div>
</template>
<script>
import { Terminal } from "xterm"
import { FitAddon } from 'xterm-addon-fit'
import "xterm/css/xterm.css"
import Stomp from 'stompjs'
export default {
  data() {
    return {
      term: "", // 保存terminal实例
      rows: 40,
      cols: 100,
      stompClient: ''
    }
  },
  mounted() {
    this.initSocket()
  },
  methods: {
    initXterm() {
      let _this = this
      let term = new Terminal({
        rendererType: "canvas", //渲染类型
        rows: _this.rows, //行数
        cols: _this.cols, // 不指定行数,自动回车后光标从下一行开始
        convertEol: true, //启用时,光标将设置为下一行的开头
        // scrollback: 50, //终端中的回滚量
        disableStdin: false, //是否应禁用输入
        // cursorStyle: "underline", //光标样式
        cursorBlink: true, //光标闪烁
        theme: {
          foreground: "#ECECEC", //字体
          background: "#000000", //背景色
          cursor: "help", //设置光标
          lineHeight: 20
        }
      })
      // 创建terminal实例
      term.open(this.$refs["terminal"])
      // 换行并输入起始符 $
      term.prompt = _ => {
        term.write("\r\n\x1b[33m$\x1b[0m ")
      }
      // term.prompt()
      // canvas背景全屏
      const fitAddon = new FitAddon()
      term.loadAddon(fitAddon)
      fitAddon.fit()

      window.addEventListener("resize", resizeScreen)
      function resizeScreen() {
        try {
          fitAddon.fit()
        } catch (e) {
          console.log("e", e.message)
        }
      }
      _this.term = term
      _this.runFakeTerminal()
    },
    runFakeTerminal() {
      let term = this.term
      if (term._initialized) return
      // 初始化
      term._initialized = true
      term.writeln("Welcome to \x1b[1;32m墨天轮\x1b[0m.")
      term.writeln('This is Web Terminal of Modb; Good Good Study, Day Day Up.')
      term.prompt()
      term.onData(key => {  // 输入与粘贴的情况
        this.sendShell(key)
      })
    },
    initSocket() {
      let _this = this
      // 建立连接对象
      let sockUrl = 'ws://127.0.0.1:8086/web-terminal'
      let socket = new WebSocket(sockUrl)
      // 获取STOMP子协议的客户端对象
      _this.stompClient = Stomp.over(socket)
      // 向服务器发起websocket连接
      this.stompClient.connect({}, (res) => {
        _this.initXterm()
        _this.stompClient.subscribe('/topic/1024', (frame) => {
          _this.writeShell(frame.body)
        })
        _this.sentFirst()
      }, (err) => {
        console.log('失败:' + err)
      })
      _this.stompClient.debug = null
    },
    sendShell (data) {
      let _bar = {
        operate:'command',
        command: data,
        userId: 1024
      }
      this.stompClient.send('/msg', {}, JSON.stringify(_bar))
    },
    writeShell(data) {
      this.term.write(data)
    },
    // 连接建立,首次发送消息连接 ssh
    sentFirst () {
      let _bar = {
        operate:'connect',
        host: '***',
        port: 22,
        username: '***',
        password: '***',
        userId: 1024
      }
      this.stompClient.send('/msg', {}, JSON.stringify(_bar))
    }
  }
}
</script>

二、后端实现代码

1、后台开启 websocket + stomp

@Configuration
@Slf4j
@AllArgsConstructor
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
    private WebSSHService webSSHService;

    @Override
    public void registerStompEndpoints(StompEndpointRegistry registry ) {
        //路径"/web-terminal"被注册为STOMP端点,对外暴露,客户端通过该路径接入WebSocket服务
        registry.addEndpoint("web-terminal").setAllowedOrigins("*");
    }

    @Override
    public void configureMessageBroker(MessageBrokerRegistry config) {
        // 用户可以订阅来自以"/topic"为前缀的消息,客户端只可以订阅这个前缀的主题
        config.enableSimpleBroker("/topic");
    }

    @Override
    public void configureWebSocketTransport(final WebSocketTransportRegistration registration) {
        registration.addDecoratorFactory(new WebSocketHandlerDecoratorFactory() {
            @Override
            public WebSocketHandler decorate(final WebSocketHandler handler) {
                return new WebSocketHandlerDecorator(handler) {
                    // 上线相关操作
                    @Override
                    public void afterConnectionEstablished(final WebSocketSession session) throws Exception {
                        // 通过创建连接的url解析出userId
                        String query = session.getUri().getQuery();
                        Integer userId = 1024;
                        //调用初始化连接(后面改为创建容器)
                        webSSHService.initConnection(userId);
                        //上线相关操作
                        super.afterConnectionEstablished(session);
                    }
                    // 离线相关操作
                    @Override
                    public void afterConnectionClosed(WebSocketSession session, CloseStatus closeStatus) throws Exception {
                        // 通过创建连接的url解析出userId
                        String query = session.getUri().getQuery();
                        Integer userId = 1024;
                        // 移除连接
                        webSSHService.close(userId);
                        //离线相关操作
                        super.afterConnectionClosed(session, closeStatus);
                    }
                };
            }
        });
    }

}

2、提供接口给前端用来发送消息

@Slf4j
@EmcsController
@AllArgsConstructor
@RequestMapping("/websocket")
public class WebSocketController {
    private SimpMessagingTemplate template;
    private WebSSHService webSSHService;

    @MessageMapping("/msg")
    public void sendMessage(@RequestBody WebSSHData webSSHData) {
        webSSHService.recvHandle(webSSHData, template);  // 处理发送消息
    }
}

3、业务层 Service 用来处理业务,主要是:初始化 SSH 连接、使用 JSch 连接终端、同步发送命令给终端取得终端返回消息再发送给前台展示等

@Slf4j
@AllArgsConstructor
@EmcsService
public class WebSSHServiceImpl implements WebSSHService {
    // 存放ssh连接信息的map
    private static Map<Integer, Object> sshMap = new ConcurrentHashMap<>();
// 初始化 ssh 连接 @Override public void initConnection(Integer userId) { JSch jSch = new JSch(); SSHConnectInfo sshConnectInfo = new SSHConnectInfo(); sshConnectInfo.setJSch(jSch); //将这个ssh连接信息放入map中 sshMap.put(userId, sshConnectInfo); } // 处理客户端发送的数据 @Override public void recvHandle(WebSSHData webSSHData, SimpMessagingTemplate template) { // 连接 ssh:connect 指令 if (webSSHData!=null && ConstantPool.WEBSSH_OPERATE_CONNECT.equals(webSSHData.getOperate())) { //找到刚才存储的ssh连接对象 SSHConnectInfo sshConnectInfo = (SSHConnectInfo) sshMap.get(webSSHData.getUserId()); try { connectToSSH(sshConnectInfo, webSSHData, template); } catch (JSchException | IOException e) { log.error("webssh连接异常"); log.error("异常信息:{}", e.getMessage()); } } // 输入命令(把命令输到后台终端)command 指令 else if (webSSHData!=null && ConstantPool.WEBSSH_OPERATE_COMMAND.equals(webSSHData.getOperate())) { SSHConnectInfo sshConnectInfo = (SSHConnectInfo) sshMap.get(webSSHData.getUserId()); if (sshConnectInfo != null) { try { transToSSH(sshConnectInfo.getChannel(), webSSHData.getCommand()); } catch (IOException e) { log.error("webssh连接异常"); log.error("异常信息:{}", e.getMessage()); } } } else { log.error("不支持的操作"); } } // 使用jsch连接终端 private void connectToSSH(SSHConnectInfo sshConnectInfo, WebSSHData webSSHData, SimpMessagingTemplate template) throws JSchException, IOException { //获取jsch的会话 Session session = sshConnectInfo.getJSch().getSession(webSSHData.getUsername(), webSSHData.getHost(), webSSHData.getPort()); Properties config = new Properties(); config.put("StrictHostKeyChecking", "no"); session.setConfig(config); //设置密码 session.setPassword(webSSHData.getPassword()); //连接 超时时间30s session.connect(30000); //开启shell通道 Channel channel = session.openChannel("shell"); //通道连接 超时时间3s channel.connect(3000); //设置channel sshConnectInfo.setChannel(channel); //转发消息给终端 transToSSH(channel, "\r"); //读取终端返回的信息流 InputStream inputStream = channel.getInputStream(); try { //循环读取 byte[] buffer = new byte[1024]; int i = 0; //如果没有数据来,线程会一直阻塞在这个地方等待数据。 while ((i = inputStream.read(buffer)) != -1) { template.convertAndSend("/topic/" + webSSHData.getUserId(), new String(Arrays.copyOfRange(buffer, 0, i))); } } finally { //断开连接后关闭会话 session.disconnect(); channel.disconnect(); if (inputStream != null) { inputStream.close(); } } } // 将消息转发到终端 private void transToSSH(Channel channel, String command) throws IOException { if (channel != null) { OutputStream outputStream = channel.getOutputStream(); outputStream.write(command.getBytes()); outputStream.flush(); } } // 关闭连接 @Override public void close(Integer userId) { SSHConnectInfo sshConnectInfo = (SSHConnectInfo) sshMap.get(userId); if (sshConnectInfo != null) { //断开连接 if (sshConnectInfo.getChannel() != null) { sshConnectInfo.getChannel().disconnect(); } //map中移除 sshMap.remove(userId); } } }

  如上就是主要 demo 流程代码,其实还比较简单,总结一下就是:

(1)前端通过 websocket 与后端建立连接,在 websocket 上可以包一层 stomp;

(2)在 websocket 用户连接的同时,为该用户创建 SSH 连接

(3)前后端连接成功之后,前端就初始化 Xterm,订阅频道,同时携带服务器信息发送消息给后端请求连接终端服务器(JSch指令connect);JSch连接终端成功之后拿取终端返回的信息,后端将终端返回的信息发送给前端,前端 write 在 xterm 上;

(4)用户输入的每个操作,前端都发送给后台(JSch指令command),后台通过 JSch 发送给终端,拿取终端返回的信息,再返回给前端用于 write 在 Xterm 上即可。

  websocket连接成功  ——  后台建立 SSH 连接  ——  前端初始化 Xterm —— 前端订阅频道  ——  前端发消息请求连接终端  ——   后台收到 connect 指令则通过 JSch 连接终端,并将终端返回信息发送给前端展示  ——  前端发送用户的操作指令给后台  ——  后台转发 JSch 连接终端,并将终端返回信息发送给前端展示。

标签:JSch,web,term,sshConnectInfo,webSSHData,连接,public,终端,浅析
来源: https://www.cnblogs.com/goloving/p/15025262.html

本站声明: 1. iCode9 技术分享网(下文简称本站)提供的所有内容,仅供技术学习、探讨和分享;
2. 关于本站的所有留言、评论、转载及引用,纯属内容发起人的个人观点,与本站观点和立场无关;
3. 关于本站的所有言论和文字,纯属内容发起人的个人观点,与本站观点和立场无关;
4. 本站文章均是网友提供,不完全保证技术分享内容的完整性、准确性、时效性、风险性和版权归属;如您发现该文章侵犯了您的权益,可联系我们第一时间进行删除;
5. 本站为非盈利性的个人网站,所有内容不会用来进行牟利,也不会利用任何形式的广告来间接获益,纯粹是为了广大技术爱好者提供技术内容和技术思想的分享性交流网站。

专注分享技术,共同学习,共同进步。侵权联系[81616952@qq.com]

Copyright (C)ICode9.com, All Rights Reserved.

ICode9版权所有