一、需求
在系统中使用Web Shell连接集群的登录节点
二、实现
前端使用Vue,WebSocket实现前后端通信,后端使用JSch ssh通讯包。
1. 前端核心代码
<template>
<div class="shell-container">
<div id="shell"/>
</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());
}
}
}
}