Vue+Xterm.js+WebSocket+JSch实现Web Shell终端

在线打码 2024-10-04 13:33:01 阅读 62

一、需求

在系统中使用Web Shell连接集群的登录节点

二、实现

前端使用Vue,WebSocket实现前后端通信,后端使用JSch ssh通讯包。

1. 前端核心代码

<code><template>

<div class="shell-container">code>

<div id="shell"/>code>

</div>

</template>

<script>

import 'xterm/css/xterm.css'

import { -- --> Terminal } from 'xterm'

import { FitAddon } from 'xterm-addon-fit'

export default {

name: 'WebShell',

props: {

socketURI: {

type: String,

default: ''

},

},

watch: {

socketURI: {

deep: true, //对象内部属性的监听,关键。

immediate: true,

handler() {

this.initSocket();

},

},

},

data() {

return {

term: undefined,

rows: 24,

cols: 80,

path: "",

isShellConn: false // shell是否连接成功

}

},

mounted() {

const { onTerminalResize } = this;

this.initSocket();

// 通过防抖函数

const resizedFunc = this.debounce(function() {

onTerminalResize();

}, 250); // 250毫秒内只执行一次

window.addEventListener('resize', resizedFunc);

},

beforeUnmount() {

this.socket.close();

this.term&&this.term.dispose();

window.removeEventListener('resize');

},

methods: {

initTerm() {

let term = new Terminal({

rendererType: "canvas", //渲染类型

rows: this.rows, //行数

cols: this.cols, // 不指定行数,自动回车后光标从下一行开始

convertEol: true, //启用时,光标将设置为下一行的开头

disableStdin: false, //是否应禁用输入

windowsMode: true, // 根据窗口换行

cursorBlink: true, //光标闪烁

theme: {

foreground: "#ECECEC", //字体

background: "#000000", //背景色

cursor: "help", //设置光标

lineHeight: 20,

},

});

this.term = term;

const fitAddon = new FitAddon();

this.term.loadAddon(fitAddon);

this.fitAddon = fitAddon;

let element = document.getElementById("shell");

term.open(element);

// 自适应大小(使终端的尺寸和几何尺寸适合于终端容器的尺寸),初始化的时候宽高都是对的

fitAddon.fit();

term.focus();

//监视命令行输入

this.term.onData((data) => {

let dataWrapper = data;

if (dataWrapper === "\r") {

dataWrapper = "\n";

} else if (dataWrapper === "\u0003") {

// 输入ctrl+c

dataWrapper += "\n";

}

// 将输入的命令通知给后台,后台返回数据。

this.socket.send(JSON.stringify({ type: "command", data: dataWrapper }));

});

},

onTerminalResize() {

this.fitAddon.fit();

this.socket.send(

JSON.stringify({

type: "resize",

data: {

rows: this.term.rows,

cols: this.term.cols,

}

})

);

},

initSocket() {

if (this.socketURI == "") {

return;

}

// 添加path、cols、rows

const uri = `${ this.socketURI}&path=${ this.path}&cols=${ this.cols}&rows=${ this.rows}`;

console.log(uri);

this.socket = new WebSocket(uri);

this.socketOnClose();

this.socketOnOpen();

this.socketOnmessage();

this.socketOnError();

},

socketOnOpen() {

this.socket.onopen = () => {

console.log("websocket链接成功");

this.initTerm();

};

},

socketOnmessage() {

this.socket.onmessage = (evt) => {

try {

if (typeof evt.data === "string") {

const msg = JSON.parse(evt.data);

switch(msg.type) {

case "command":

// 将返回的数据写入xterm,回显在webshell上

this.term.write(msg.data);

// 当shell首次连接成功时才发送resize事件

if (!this.isShellConn) {

// when server ready for connection,send resize to server

this.onTerminalResize();

this.isShellConn = true;

}

break;

case "exit":

this.term.write("Process exited with code 0");

break;

}

}

} catch (e) {

console.error(e);

console.log("parse json error.", evt.data);

}

};

},

socketOnClose() {

this.socket.onclose = () => {

this.socket.close();

console.log("关闭 socket");

window.removeEventListener("resize", this.onTerminalResize);

};

},

socketOnError() {

this.socket.onerror = () => {

console.log("socket 链接失败");

};

},

debounce(func, wait) {

let timeout;

return function() {

const context = this;

const args = arguments;

clearTimeout(timeout);

timeout = setTimeout(function() {

func.apply(context, args);

}, wait);

};

}

}

}

</script>

<!-- Add "scoped" attribute to limit CSS to this component only -->

<style scoped>

#shell {

width: 100%;

height: 100%;

}

.shell-container {

height: 100%;

}

</style>

2. 后端核心代码

package com.example.webshell.service.impl;

import com.alibaba.fastjson.JSONObject;

import com.example.webshell.constant.Constant;

import com.example.webshell.entity.LoginNodeInfo;

import com.example.webshell.entity.ShellConnectInfo;

import com.example.webshell.entity.SocketData;

import com.example.webshell.entity.WebShellParam;

import com.example.webshell.service.WebShellService;

import com.example.webshell.utils.ThreadPoolUtils;

import com.example.webshell.utils.WebShellUtil;

import com.fasterxml.jackson.databind.ObjectMapper;

import com.jcraft.jsch.*;

import lombok.extern.slf4j.Slf4j;

import org.springframework.stereotype.Service;

import java.io.IOException;

import java.io.InputStream;

import java.io.OutputStream;

import java.util.Arrays;

import java.util.Map;

import java.util.Properties;

import java.util.concurrent.ConcurrentHashMap;

import static com.example.webshell.constant.Constant.*;

@Slf4j

@Service

public class WebShellServiceImpl implements WebShellService {

/**

* 存放ssh连接信息的map

*/

private static final Map<String, Object> SSH_MAP = new ConcurrentHashMap<>();

/**

* 初始化连接

*/

@Override

public void initConnection(javax.websocket.Session webSocketSession, WebShellParam webShellParam) {

JSch jSch = new JSch();

ShellConnectInfo shellConnectInfo = new ShellConnectInfo();

shellConnectInfo.setJsch(jSch);

shellConnectInfo.setSession(webSocketSession);

String uuid = WebShellUtil.getUuid(webSocketSession);

// 根据集群和登录节点查询IP TODO

LoginNodeInfo loginNodeInfo = new LoginNodeInfo("demo_admin", "demo_admin", "192.168.88.102", 22);

//启动线程异步处理

ThreadPoolUtils.execute(() -> {

try {

connectToSsh(shellConnectInfo, webShellParam, loginNodeInfo, webSocketSession);

} catch (JSchException e) {

log.error("web shell连接异常: {}", e.getMessage());

sendMessage(webSocketSession, new SocketData(OPERATE_ERROR, e.getMessage()));

close(webSocketSession);

}

});

//将这个ssh连接信息放入缓存中

SSH_MAP.put(uuid, shellConnectInfo);

}

/**

* 处理客户端发送的数据

*/

@Override

public void handleMessage(javax.websocket.Session webSocketSession, String message) {

ObjectMapper objectMapper = new ObjectMapper();

SocketData shellData;

try {

shellData = objectMapper.readValue(message, SocketData.class);

String userId = WebShellUtil.getUuid(webSocketSession);

//找到刚才存储的ssh连接对象

ShellConnectInfo shellConnectInfo = (ShellConnectInfo) SSH_MAP.get(userId);

if (shellConnectInfo != null) {

if (OPERATE_RESIZE.equals(shellData.getType())) {

ChannelShell channel = shellConnectInfo.getChannel();

Object data = shellData.getData();

Map map = objectMapper.readValue(JSONObject.toJSONString(data), Map.class);

System.out.println(map);

channel.setPtySize(Integer.parseInt(map.get("cols").toString()), Integer.parseInt(map.get("rows").toString()), 0, 0);

} else if (OPERATE_COMMAND.equals(shellData.getType())) {

String command = shellData.getData().toString();

sendToTerminal(shellConnectInfo.getChannel(), command);

// 退出状态码

int exitStatus = shellConnectInfo.getChannel().getExitStatus();

System.out.println(exitStatus);

} else {

log.error("不支持的操作");

close(webSocketSession);

}

}

} catch (Exception e) {

e.printStackTrace();

log.error("消息处理异常: {}", e.getMessage());

}

}

/**

* 关闭连接

*/

private void close(javax.websocket.Session webSocketSession) {

String userId = WebShellUtil.getUuid(webSocketSession);

ShellConnectInfo shellConnectInfo = (ShellConnectInfo) SSH_MAP.get(userId);

if (shellConnectInfo != null) {

//断开连接

if (shellConnectInfo.getChannel() != null) {

shellConnectInfo.getChannel().disconnect();

}

//map中移除

SSH_MAP.remove(userId);

}

}

/**

* 使用jsch连接终端

*/

private void connectToSsh(ShellConnectInfo shellConnectInfo, WebShellParam webShellParam, LoginNodeInfo loginNodeInfo, javax.websocket.Session webSocketSession) throws JSchException {

Properties config = new Properties();

// SSH 连接远程主机时,会检查主机的公钥。如果是第一次该主机,会显示该主机的公钥摘要,提示用户是否信任该主机

config.put("StrictHostKeyChecking", "no");

//获取jsch的会话

Session session = shellConnectInfo.getJsch().getSession(loginNodeInfo.getUsername(), loginNodeInfo.getHost(), loginNodeInfo.getPort());

session.setConfig(config);

//设置密码

session.setPassword(loginNodeInfo.getPassword());

//连接超时时间30s

session.connect(30 * 1000);

//查询上次登录时间

showLastLogin(session, webSocketSession, loginNodeInfo.getUsername());

//开启交互式shell通道

ChannelShell channel = (ChannelShell) session.openChannel("shell");

//设置channel

shellConnectInfo.setChannel(channel);

//通道连接超时时间3s

channel.connect(3 * 1000);

channel.setPty(true);

//读取终端返回的信息流

try (InputStream inputStream = channel.getInputStream()) {

//循环读取

byte[] buffer = new byte[Constant.BUFFER_SIZE];

int i;

//如果没有数据来,线程会一直阻塞在这个地方等待数据。

while ((i = inputStream.read(buffer)) != -1) {

sendMessage(webSocketSession, new SocketData(OPERATE_COMMAND, new String(Arrays.copyOfRange(buffer, 0, i))));

}

} catch (IOException e) {

log.error("读取终端返回的信息流异常:", e);

} finally {

//断开连接后关闭会话

session.disconnect();

channel.disconnect();

}

}

/**

* 向前端展示上次登录信息

*/

private void showLastLogin(Session session, javax.websocket.Session webSocketSession, String username) throws JSchException {

ChannelExec channelExec = (ChannelExec) session.openChannel("exec");

channelExec.setCommand("lastlog -u " + username);

channelExec.connect();

channelExec.setErrStream(System.err);

try (InputStream inputStream = channelExec.getInputStream()) {

byte[] buffer = new byte[Constant.BUFFER_SIZE];

int i;

StringBuilder sb = new StringBuilder();

while ((i = inputStream.read(buffer)) != -1) {

sb.append(new String(Arrays.copyOfRange(buffer, 0, i)));

}

// 解析结果

String[] split = sb.toString().split("\n");

if (split.length > 1) {

String[] items = split[1].split("\\s+", 4);

String msg = String.format("Last login: %s from %s\n", items[3], items[2]);

sendMessage(webSocketSession, new SocketData(OPERATE_COMMAND, msg));

}

} catch (IOException e) {

log.error("读取终端返回的信息流异常:", e);

} finally {

channelExec.disconnect();

}

}

/**

* 数据写回前端

*/

private void sendMessage(javax.websocket.Session webSocketSession, SocketData data) {

try {

webSocketSession.getBasicRemote().sendText(JSONObject.toJSONString(data));

} catch (IOException e) {

log.error("数据写回前端异常:", e);

}

}

/**

* 将消息转发到终端

*/

private void sendToTerminal(Channel channel, String command) {

if (channel != null) {

try {

OutputStream outputStream = channel.getOutputStream();

outputStream.write(command.getBytes());

outputStream.flush();

} catch (IOException e) {

log.error("web shell将消息转发到终端异常:{}", e.getMessage());

}

}

}

}

三、效果展示

在这里插入图片描述



声明

本文内容仅代表作者观点,或转载于其他网站,本站不以此文作为商业用途
如有涉及侵权,请联系本站进行删除
转载本站原创文章,请注明来源及作者。