工具分享:Springboot+Netty+Xterm搭建一个网页版的SSH终端

news2024/11/30 12:31:49

一. 简述

搭建一个web ssh,主要是借助websocketxterm,可以实现一个类似于xshell的效果,如图:

web-shell

二. 技术栈

这里使用了springbootnettyjschreactTs,xterm

这里我用了springbootnetty实现了websocketjsch用来连接服务器,reactxterm实现终端的页面。

xterm这里有一个坑,吐槽下官方文档写的有点简单。

这里的使用的版本都是最新版的,给大家踩坑,下面看一下如何实现吧!

三. 搭建websocket

这里我用netty实现了一个websocket,很简单,只需要实现了心跳的处理器和ws消息处理器。

3.1. netty的server

@Slf4j
@Component
public class WebSocketServer {

    public void Run() {
        // 这里只是使用线程工厂创建线程池,
        EventLoopGroup boss = ThreadUtil.getEventLoop(BOSS_THREAD_NAME);
        EventLoopGroup worker = ThreadUtil.getEventLoop(WORKER_THREAD_NAME);
        try {
            ChannelFuture future = new ServerBootstrap()
                    .group(boss, worker)
                    .option(ChannelOption.SO_BACKLOG, BACKLOG)
                    .channel(NioServerSocketChannel.class)
                    .childOption(ChannelOption.SO_KEEPALIVE, true)
                    .childHandler(new WSChannelInitializer())
                    .bind(PORT)
                    .sync();
            log.info("WS服务器启动......");
            future.channel().closeFuture().sync();
        } catch (Exception e) {
            log.error("WS服务器发生异常: [{}]", e.getMessage(), e);
        } finally {
            log.info("WS服务器关闭......");
            worker.shutdownGracefully();
            boss.shutdownGracefully();
        }
    }

}

接着看下核心WSChannelInitializer定义了一系列处理器:

@Component
public class WSChannelInitializer extends ChannelInitializer<SocketChannel> {

    private final ClientMsgHandler clientMsgHandler;
    private final WsHeartBeatHandler heartBeatHandler;

    public WSChannelInitializer() {
        // SpringUtil是一个工具类,从容器中获取相关的Bean
        clientMsgHandler = SpringUtil.getBean(ClientMsgHandler.class);
        heartBeatHandler = SpringUtil.getBean(WsHeartBeatHandler.class);
    }


    @Override
    protected void initChannel(@NotNull SocketChannel ch) throws Exception {
        ChannelPipeline pipeline = ch.pipeline();
        // http编解码器
        pipeline.addLast(new HttpServerCodec());
        // 块写入
        pipeline.addLast(new ChunkedWriteHandler());
        // 将请求报文聚合为完整报文,设置最大请求报文 10M
        pipeline.addLast(new HttpObjectAggregator(10 * 1024 * 1024));
        // 心跳
        pipeline.addLast(new IdleStateHandler(10, 10, 30, TimeUnit.MINUTES));
        // 处理心跳
        pipeline.addLast(heartBeatHandler);
        // 处理ws信息
        pipeline.addLast(new WebSocketServerProtocolHandler("/api/ws"));
        pipeline.addLast(clientMsgHandler);
    }
}

3.2. 心跳包

心跳包主要是为了长时间没有处理关闭连接

@Slf4j
@Component
@ChannelHandler.Sharable
public class WsHeartBeatHandler  extends ChannelInboundHandlerAdapter {
    
    @Resource
    private ChannelService channelService;

    @Override
    public void userEventTriggered(ChannelHandlerContext ctx, Object evt) throws Exception {
        if (evt instanceof IdleStateEvent event) {
            if (event.state() == IdleState.READER_IDLE) {
                log.debug("没有收到读数据包");
            } else if (event.state() == IdleState.WRITER_IDLE) {
                log.debug("没有发送写数据包");
            } else if (event.state() == IdleState.ALL_IDLE) {
                Channel channel = ctx.channel();
                log.error("长时间没有读写,关闭连接: {}", channel.id().asLongText());
                channelService.remove(channel);
                channel.close();
            }
        }
    }

}

3.3. WebSocket处理器

这里我就放一些核心代码吧

@Slf4j
@Component
@ChannelHandler.Sharable
public class ClientMsgHandler extends SimpleChannelInboundHandler<TextWebSocketFrame> {

    @Resource
    private ChannelService channelService;

    @Override
    protected void channelRead0(ChannelHandlerContext ctx, TextWebSocketFrame msg) throws Exception {
        String json = msg.text();
        log.info("收到数据:{}", json);
        WsMessage message = null;
        try {
            message = JSONObject.parseObject(json, WsMessage.class);
        } catch (Exception e) {
            log.error("{}", e.getMessage());
            return;
        }
        // TODO 后期优化
        if (message.getMsgType().equals(WsMessageEnum.AUTH.getType())) {
            // 用户认证
            checkoutUserHandler(message, ctx);
        } else if (message.getMsgType().equals(WsMessageEnum.KEEP.getType())) {
            // 心跳
            keepHandler(message);
        } else if (message.getMsgType().equals(WsMessageEnum.SYSTEM.getType())) {
            // 其他消息
            systemHandler(message, ctx);
        } else if (message.getMsgType().equals(WsMessageEnum.TERMINAL.getType())) {
            // xterm发送的消息
            terminalHandler(message, ctx);
        } else {
            log.info("[{}] => 当前消息类型未识别:[{}]", Thread.currentThread().getName(), message);
        }
    }
  
    /**
     * 校验用户认证信息
     * @param message
     * @return
     */
    private void checkoutUserHandler(WsMessage message, ChannelHandlerContext ctx) {
        log.info("[{}] => 当前消息是鉴权消息:[{}]", Thread.currentThread().getName(), message);
        DecodedJWT jwt = JwtUtil.verifyToken(message.getData());
        String data = jwt.getClaim("data").asString();
        User user = JSONObject.parseObject(data, User.class);
        channelService.add(user.getId(), ctx.channel());
        // 初始化jsch链接
        channelService.add(ctx.channel(), message.getId());
    }

}

3.4. shell和websocket的关联

这里我通过Jsch连接服务器,Jsch连接到服务器之后也是通过channel进行交互,这里可以将Jschchannelnettychannel进行关联。

@Slf4j
@Service
public class ChannelServiceImpl implements ChannelService {
 	  // 用户和netty的channel对应关联
    private final ConcurrentHashMap<Integer, Set<Channel>> useChannelMap = new ConcurrentHashMap<>(1 << 8);

    // netty的channel和Jsch上下文的映射关系
    private final ConcurrentHashMap<Channel, ServerTerminalVo> sshChannelMap = new ConcurrentHashMap<>(1 << 8);
    // 保持JSch的channel的线程池
    private final ExecutorService executorService = Executors.newCachedThreadPool();
    private final Lock lock = new ReentrantLock();
  
}

下面看一个如何进行关联,add方法将在checkoutUserHandler的方法中进行调用,创建连接。

@Slf4j
@Service
public class ChannelServiceImpl implements ChannelService {
    @Override
    public void add(Channel channel, Integer serverId) {
        // 获取server 
        Server server = serverMapper.selectOne(new LambdaQueryWrapper<Server>().eq(Server::getId, serverId).eq(Server::getCanView, true));
        
        try {
            // 创建jsch的连接
            Properties config = new Properties();
            // 账号密码连接需要在这里设置
            config.put("StrictHostKeyChecking", "no");
            Session session = new JSch().getSession(server.getUsername(), server.getHost(), server.getPort());
            session.setConfig(config);
            session.setPassword(server.getPassword());
            session.connect(30000);
            com.jcraft.jsch.Channel shell = session.openChannel("shell");
            shell.connect(30000);
            // 设置channel
            ServerTerminalVo result = new ServerTerminalVo(server, session, shell);
            // 启动线程获取数据
            sshChannelMap.put(channel, result);
            executorService.submit(new TerminalThread(result, channel));
        } catch (JSchException e) {
            log.error("连接服务器失败:{}", e.getMessage());
            throw new SystemException(ResultCode.SERVER_CONNECT_FAIL);
        }
    }
  
    // 保持jsch的连接,一旦有服务端数据发将其发送到指定netty的channel中,需要使用TextWebSocketFrame进行封装
    static class TerminalThread implements Runnable {

        private final ServerTerminalVo serverTerminal;

        private final Channel channel;

        public TerminalThread(ServerTerminalVo serverTerminal, Channel channel) {
            this.serverTerminal = serverTerminal;
            this.channel = channel;
        }

        @Override
        public void run() {
            try (InputStream inputStream = serverTerminal.getChannel().getInputStream()) {
                int i = 0;
                byte[] buffer = new byte[2048];
                while ((i = inputStream.read(buffer)) != -1) {
                    byte[] bytes = Arrays.copyOfRange(buffer, 0, i);
                    String msg = new String(bytes);
                    channel.writeAndFlush(new TextWebSocketFrame(msg)).addListener((ChannelFutureListener) future -> {
                        log.debug("[{}] => 发送websocket消息:{}", Thread.currentThread().getName(), msg);
                    });
                }
            } catch (Exception e) {
                log.error("[{}] 读取服务器数据失败:[{}]", Thread.currentThread().getName(), e.getMessage());
            }
        }
    }
}

四. 搭建xterm终端

这里我使用了抖音开源的React UI框架:semi design,视觉效果还是很不错的,使用起来和antd差不多,推荐大家用一下。

4.1. 版本

这里先列一下相关技术点的版本:

  • react:18.2.0
  • xterm:5.0.0
  • xterm-addon-attach:0.7.0
  • xterm-addon-fit:0.6.0
  • xterm-addon-web-links:0.7.0 (这个可以不加)
  • typescript:4.6.4

网上好多版本的xterm都是使用了4.x.x的版本,但是5的版本又一些api是无法使用的,并且很多写法都是基于js的。

4.2. 服务器连接管理

这里的服务器连接管理,就是一个CRUD,很简单,主要是为了管理服务器连接,如图:

web-ssh-server

4.3. 连接websocket

这儿先创建了weksocket的对象引用:

const ws = useRef<WebSocket | null>(null);

接着在useEffect中实例化WebSocket:

useEffect(() => {
  if (visible) {
    // 初始化ws
    try {
      const token = store.getState().user.token;
      ws.current = new WebSocket('ws://127.0.0.1:8081/api/ws')
      ws.current.onopen = () => {
        // 初始化连接的时候发送认证信息
        ws.current?.send(JSON.stringify({msgType: 1, data: token, id: id}))
        // 设置状态
        setReadyState(stateArr[ws.current?.readyState ?? 0]);
      }
      ws.current.onclose = () => {
        setReadyState(stateArr[ws.current?.readyState ?? 0])
      }
      ws.current.onerror = () => {
        setReadyState(stateArr[ws.current?.readyState ?? 0])
      }
      ws.current.onmessage = (e) => {
        console.log("e => ", e)
      }
    } catch (error) {
      console.log(error)
    }
  }
  return () => {
    // 组件销毁的之前,关闭websocket连接
    ws.current?.close();
  }
}, [visible])

这儿涉及到了websocket的认证,我这里采用的是,创建连接成功之后,发送一个包含认证信息指定格式的数据给后端进行认证。

网上有好些人用new WebSocket(‘ws://127.0.0.1:8081/api/ws’, [token])这样去进行认证,我试了不行。有成功的可以留言给我

接着还需要一个websocket的心跳处理,这里可以使用定时任务,但是需要注意在组件销毁之时清理定时器。

useEffect(() => {
  let timer: number | null = null;
  // 确保ws状态是1
  if (readyState.key === 1) {
    timer = setInterval(() => {
      // 每隔10s发送一个心跳包
      ws.current?.send(JSON.stringify({msgType: 2, data: "ping"}))
    }, 10000);
  }
  // 确保ws状态是关闭状态的时候清理定时器
  if ((readyState.key === 2 || readyState.key === 3) && timer) {
    clearInterval(timer);
  }
  return () => {
    if (timer) {
      // 清理定时器
      clearInterval(timer);
    }
  }
}, [readyState])

4.4. 对接xterm

上面已经将websocket对接成功了,接着在去初始化xterm。这里需要引入xterm,添加一些必要的引用:

import { Terminal } from 'xterm'; // 必须
import { WebLinksAddon } from 'xterm-addon-web-links';
import { FitAddon } from 'xterm-addon-fit'; // 缩放
import { AttachAddon } from 'xterm-addon-attach'; // 必须
import 'xterm/css/xterm.css'; // 这个不引入样式不对

接着就可以初始化xterm了:

const divRef: any = useRef(null);

useEffect(() => {
  if (visible) {
    // 初始化ws ......

    // 初始化xterm
    terminal.current = new Terminal({
      cursorBlink: true, // 光标闪烁
      allowProposedApi: true,
      disableStdin: false, //是否应禁用输入
      cursorStyle: "underline", //光标样式
      theme: { // 设置主题
        foreground: "yellow", //字体
        background: "#060101", //背景色
        cursor: "help", //设置光标
      },
    });
    const webLinksAddon = new WebLinksAddon();
    const fitAddon = new FitAddon();
    // 将ws载入
    const attachAddon = new AttachAddon(ws.current!);
                                        terminal.current.loadAddon(webLinksAddon);
    terminal.current.loadAddon(fitAddon);
    terminal.current.loadAddon(attachAddon);
    // 在有键盘按键输入数据的时候发送指定格式的数据
    terminal.current?.onData(e => {
      ws.current?.send(JSON.stringify({msgType: 4, data: e}))
    })
    // 将div元素的引入挂在入xterm中
    terminal.current.open(divRef.current);
    fitAddon.fit();

  }
  return () => {
    // 关闭ws
    ws.current?.close();
    // 销毁xterm
    terminal.current?.dispose()
  }
}, [visible])

对应的div元素:

<div style={{ marginTop: 10, width: 1250, height: 600 }} ref={divRef} />

此时大体就完成了!

五. 源码

上面的代码并不全,可到gitee上查看:https://gitee.com/molonglove/server-manage.git

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

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

稳了,我用 Python 可视化分析和预测了 2022 年 FIFA 世界杯

许多人称足球为 “不可预测的游戏”&#xff0c;因为一场足球比赛有太多不同的因素可以改变最后的比分。 预测一场比赛的最终比分或赢家确实是很难的&#xff0c;但在预测一项比赛的赢家时就不是这样了。在过去的5年中&#xff0c;Bayern Munich 赢得了所有的德甲联赛&#xf…

如何发布一个属于自己的 npm 包

如何发布一个属于自己的 npm 包 start 在日常的工作中&#xff0c;我们会接触很多 npm 包。 例如&#xff1a; npm install jquerynpm install vue/clinpm install axios# ... 等等有时候会想到&#xff0c;构建一个属于自己的 npm 包&#xff0c;应该超级酷吧&#xff1f; …

zabbix基础环境部署

目录 一、环境准备 二、部署LNMP 1、安装Nginx及其依赖包 2、修改nginx配置 3、测试页面 三、部署zabbix服务端 1、下载zabbix 2、安装源码zabbix 3、为zabbix创建数据库与数据库账户 4、搭建Zabbix页面 4.1、第1步 Check of pre-requisites 4.2、第2步 Configure D…

Python代码的编写运行方式

Python代码的编写运行方式介绍 python官方运行环境可到网站https://www.python.org/downloads/找到合适版本下载安装。 安装比较容易&#xff0c;特别强调&#xff0c;安装过程建议勾选“Add Python to PATH”&#xff08;将Python添加到PATH环境变量&#xff09;【注1】&…

Docker安装Minio

寻找Minio镜像 Docker Hub 查找官方镜像 下载Minio镜像 下载最新版Minio镜像 docker pull minio/minio等同于 docker pull minio/minio:latest下载指定版本的Minio镜像 docker pull minio/minio:RELEASE.2022-11-26T22-43-32Z.fips检查当前所有Docker下载的镜像 docker …

第六章TCP/IP——网络传输硬件设备

个人简介&#xff1a;云计算网络运维专业人员&#xff0c;了解运维知识&#xff0c;掌握TCP/IP协议&#xff0c;每天分享网络运维知识与技能。个人爱好: 编程&#xff0c;打篮球&#xff0c;计算机知识个人名言&#xff1a;海不辞水&#xff0c;故能成其大&#xff1b;山不辞石…

Linux进程通信之消息队列

目录 1.消息队列的原理&#xff1a; 2.消息队列的接口&#xff1a; &#xff08;1&#xff09;创建消息队列 &#xff08;2&#xff09;向消息队列发送消息 &#xff08;3&#xff09;接收消息 &#xff08;4&#xff09;操作消息队列的接口 1.消息队列的原理&#xff1a; 消…

docker 安装 Heimdall 导航页

前言 随着群晖中使用的功能越来越多&#xff0c;各种端口太多容易忘&#xff0c;所以就有了使用导航页的想法&#xff08;使用收藏夹的朋友除外&#xff09;。群晖也有自带的WEB套件管理&#xff0c;不过个人感觉相对麻烦&#xff0c;使用 Heimdall 导航页可以设置密码登录&am…

【数字信号调制】16QAM信号调制解调【含Matlab源码 2050期】

⛄一、QAM调制与解调&#xff08;附实验题目说明&#xff09; 1 引 言 数字振幅调制、数字频率调制和数字相位调制是数字调制的基础,然而,这3种数字调制方式都存在不足之处。如频谱利用率低、抗多径衰落能力差、功率谱衰减慢、带外辐射严重等。为了改善这些不足,几十年来人们不…

yocto machine class解析之st-partitions-image

yocto machine class解析之st-partitions-image stm32mp157 yocto的meta-st-stm32mp layer中提供了几个class,后续几篇文章重点分析这些class文件&#xff1a; 第一篇就从st-partitions-image.bbclass 开始&#xff0c;st所有创建image的bb文件都会引用st-partitions-image&am…

「UWB」精准定位黑科技,开启座舱雷达新蓝海

基于厘米级定位、超低功率、强抗干扰、超大容量等技术特点&#xff0c;UWB&#xff08;超宽带&#xff09;技术在消费电子、智能汽车等领域的应用前景被赋予厚望。 值得一提的是&#xff0c;利用UWB雷达还可实现舱内活体检测、脚踢尾箱等&#xff0c;这意味着新一轮座舱感知革…

【Vue】ref引用,插槽

一、ref 什么是ref&#xff1f; ref用来辅助开发者在不依赖于jQuery 的情况下&#xff0c;获取DOM元素或组件的引用。 每个vue的组件实例上&#xff0c;都包含一个$refs对象&#xff0c;里面存储着对应的DOM元素或组件的引用。默认情况下&#xff0c;组件的$refs 指向一个空对…

Ubuntu20.04下安装nvidia驱动

ubuntu-drivers devices会显示你的电脑上可用的nvidia驱动。只需要安装推荐的版本即可&#xff08;后面有recommend字样&#xff09; 打开电脑里的软件和更新app&#xff08;这里建议换提前换源&#xff0c;阿里源或者清华源&#xff09; 来到附加驱动这个页面&#xff0c;选…

HTML初识-概念和基本知识

1 . HTML初识-基础认知 HTML标签 1.1 目录 ◆ 基础认知 ◆ HTML标签学习 ◆ 综合案例 1.2 学习目标 ◆ 能够理解HTML的 基本语法 和标签的关系 ◆ 能够使用 排版标签 实现网页中标题、段落等效果 ◆ 能够使用 相对路径 选择不同目录下的文件 ◆ 能够使用 媒体标签 在网页中显示…

ES进阶教程

1.分片Shards 一个索引可以存储超出单个结点硬件限制的大量数据,es提供了将索引划分为多份的能力,每一份都称之为分片.当创建索引时,可以指定想要的分片数量.每个分片本身也是一个功能完善并且相对独立的索引.这个索引可以被放在集群中的任何结点上. 分片的重要性 1.允许水平切…

相控阵天线(九):平面阵列天线综合(不可分离型切比雪夫分布、圆口径泰勒综合、可分离型分布、配相抵消法)

目录简介不可分离型分布不可分离型切比雪夫圆口径泰勒综合可分离型分布可分离切比雪夫综合可分离泰勒综合平面阵列配相抵消法简介 按行、列排列的可分离型矩形平面阵&#xff0c;其阵因子是两个正交排列的直线阵阵因子的乘积。可分离的平面阵方向图在两个主面内是满足预期副瓣…

Docker创建Spring容器【方便服务迁移】

&#x1f4c3;目录跳转&#x1f4da;简介&#xff1a;&#x1f383; 1.上传jar包&#x1f389;2.创建Dockerfile文件&#x1f5fa;️3.生成容器&#x1f365;4.查看本地镜像&#x1f680;5.运行镜像&#x1f52d;6.使用工具访问接口&#x1f3c6;总结&#x1f4da;简介&#xf…

网上预约挂号系统的设计与实现

项目描述 临近学期结束&#xff0c;还是毕业设计&#xff0c;你还在做java程序网络编程&#xff0c;期末作业&#xff0c;老师的作业要求觉得大了吗?不知道毕业设计该怎么办?网页功能的数量是否太多?没有合适的类型或系统?等等。这里根据疫情当下&#xff0c;你想解决的问…

si9000 单端(线)差分(动)线板层结构与阻抗计算

常见的单端(线)阻抗计算模式&#xff1a; Surface Microstrip 1B 在下图(表面&#xff0c;或暴露&#xff0c;微带)信号线暴露(空气)和参考电源或接地平面。根据电介质相对于迹的排列(在迹的下方或上方)对结构进行分类。下图显示了在信号轨迹(指定为1B)以下使用单一介电层的表…

elasticsearch数据存储结构,springboot集成elasticsearch

一、数据存储结构 结合数据库的结构理解起来就会比较清楚&#xff1a; 1&#xff09;索引&#xff08;Index&#xff09;>数据库&#xff08;Database&#xff09;。 2&#xff09;类型&#xff08;Type&#xff09;>表&#xff08;Table&#xff09;。 3&#xff09;文档…