一、需求
在系統中使用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+cdataWrapper += "\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、rowsconst 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 serverthis.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<>();/*** 初始化連接*/@Overridepublic 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 TODOLoginNodeInfo 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);}/*** 處理客戶端發送的數據*/@Overridepublic 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());//連接超時時間30ssession.connect(30 * 1000);//查詢上次登錄時間showLastLogin(session, webSocketSession, loginNodeInfo.getUsername());//開啟交互式shell通道ChannelShell channel = (ChannelShell) session.openChannel("shell");//設置channelshellConnectInfo.setChannel(channel);//通道連接超時時間3schannel.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());}}}
}