一. 简述

建立一个web ssh,首要是凭借websocketxterm,能够完成一个类似于xshell的效果,如图:

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

二. 技能栈

这儿运用了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,很简单,首要是为了办理服务器衔接,如图:

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

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上查看:gitee.com/molonglove/…