1. nodejs使用ssh2连接shell,简单交互
示例:使用conn.shell
import { Client } from 'ssh2';const conn = new Client();
conn
.on('ready', () => {console.log('Client :: ready');conn.shell({ term: 'xterm' }, (err, stream) => {if (err) {throw err;}// stream.end('ls -l\nexit\n');// 向 shell 中写入命令// stream.write('ls -l');// 监听 shell 的输出stream.on('data', data => {console.log('STDOUT: ' + data);}).on('close', () => {console.log('Stream :: close');conn.end();}).on('exit', (code, signal) => {const msg = 'Stream :: exit :: code: ' + code + ', signal: ' + signal;console.log(msg);conn.end();});});
})
.on('error', async err => {console.log('Client conn Error:' + err.toString());conn.end();
})
.connect({host: ip, // 机器ipport: 22,username: username, // 用户名password: password, // 密码readyTimeout: 3000, // 握手超时实现限制 - 暂时设置3s
});
2. 使用xterm设置宽高
// 初始化
conn.shell({ term: 'xterm', rows: rows, cols: cols }
// 监听窗口改变后设置宽高
conn.setWindow(rows, cols)
3.结合egg-socket.io实现前后端交互
import { Controller } from 'egg';
import { Client } from 'ssh2';export default class InteractiveShellController extends Controller {async loginByAccount() {const { ctx } = this;const params = ctx.args[0];const { ip, eventName, defaultCommand, timeout, is_cluster, cols = 200, rows = 40 } = params;let { username, password } = params;// ctx.logger.info(// '+++++++++++++++ loginByAccount +++++++++++',// `eventName:${eventName}, ip:${ip}, username: ${username}, password: ${password}`// );// 获取用户密码if (!username || !password) {const errorMsg = `执行命令机器信息缺失!`;ctx.socket.emit(eventName, { message: errorMsg, type: 'error' });throw new Error(errorMsg );}ctx.socket.emit(eventName, { message: `loginByAccount: ${username}, eventName: ${eventName}` });const sshConfig = {host: ip,port: 22,username: username,password: password,readyTimeout: timeout || 3000, // 握手超时实现限制 - 暂时设置3s};const rowsAndCols = {cols,rows,};this.initInteractiveShell(sshConfig, eventName, rowsAndCols, defaultCommand);}initInteractiveShell(sshConfig, eventName: string, rowsAndCols, defaultCommand = 'ls -l\n') {const { ctx } = this;// ctx.logger.info('++++++ initInteractiveShell ++++++', sshConfig);try {const conn = new Client();let connStream;let isReady = false;conn.on('ready', () => {ctx.logger.info('Client :: ready');isReady = true;conn.shell({ term: 'xterm', rows: rowsAndCols.rows, cols: rowsAndCols.cols }, (err, stream) => {if (err) {isReady = false;throw err;}connStream = stream;// stream.end('ls -l\nexit\n');// 向 shell 中写入命令stream.write(defaultCommand);// 监听 shell 的输出stream.on('data', data => {// ctx.logger.info('STDOUT: ' + data);ctx.socket.emit(eventName, { message: this.formatMachineMessage(data, '1') });}).on('close', () => {const msg = 'Stream :: close';// ctx.logger.info(msg);isReady = false;ctx.socket.emit(eventName, { message: this.formatMachineMessage(msg, '2') });ctx.socket.disconnect();conn.end();}).on('exit', (code, signal) => {// const msg = 'Stream :: exit :: code: ' + code + ', signal: ' + signal;// ctx.logger.info(msg);isReady = false;ctx.socket.disconnect();conn.end();});});}).on('error', async err => {// ctx.logger.info('Client conn Error:' + err.toString());ctx.socket.emit(eventName, { message: this.formatMachineMessage(err.message, '2'), type: 'error' });ctx.socket.disconnect();isReady = false;conn.end();}).connect(sshConfig);// 监控前端发送的消息ctx.socket.on(eventName, d => {const { message } = d;// ctx.logger.info('++Client onMessage++', message);// ctx.logger.info(message);const formatMsg = this.formatClientMessage(message);if (!message || !formatMsg) {return;}if (message[0] === '4') {// 设置宽高// ctx.logger.info('++Set Width Height++:', formatMsg);const termObj = JSON.parse(formatMsg);this.setRowsAndCols(termObj, connStream); // 设置宽高} else if (isReady && connStream) {// ctx.logger.info('++Cliend Send++:', formatMsg);connStream.write(formatMsg);} else {const msg = '++Conn.ready++' + isReady;// ctx.logger.info(msg);ctx.socket.emit(eventName, { message: msg, type: 'error' });}});// 监控前端断开ctx.socket.on('disconnect', msg => {// ctx.logger.info('++Client disconnect++', msg);// 向 shell 中写入退出命令, 前端断开前已发送exit// if (isReady && connStream) {// connStream.write('exit\n');// }// 断开与机器的连接conn.end();});} catch (e) {// ctx.logger.error(e);ctx.socket.emit(eventName, { message: e, type: 'error' });ctx.socket.disconnect();}}// 0,4客户端标识头formatClientMessage(message: string) {let res = '';const data = message.slice(1);switch (message[0]) {case '0':case '4':// 将 base64 转为 utf8res = Buffer.from(data, 'base64').toString('utf-8');break;}return res;}// 1-3服务端返回标识头formatMachineMessage(message: string, code: string) {return code + Buffer.from(message).toString('base64');}// 设置宽高setRowsAndCols(termObj, connStream) {const cols = termObj.Width;const rows = termObj.Height;if (connStream) {connStream.setWindow(rows, cols);}}
}
4.前端部分,使用vue2+elementUI+socket.io-client
视觉效果:

代码如下:
<template><div class="termWrapper"><el-tabsv-model="editableTabsValue"type="border-card"@tab-click="switchTerm"><el-tab-panev-for="(item, idx) in connectArr":label="item.name":name="idx+''":key="'container-' + idx"><!-- <el-button @click="toggleFullScreen">全屏</el-button> --><div :id="'term'+ idx" class="termMain" /></el-tab-pane></el-tabs></div>
</template>
// 初始化终端
import { Terminal } from 'xterm'
import 'xterm/css/xterm.css'
import { FitAddon } from 'xterm-addon-fit'
import io from 'socket.io-client'export default {name: 'Shell',props: {connectList: { // ip列表type: Array,default: () => []}},data() {return {editableTabsValue: '0',connectArr: [],throttleTimer: null}},watch: {connectList(v, oldVal) {this.onChangeValue(v, oldVal)}},mounted() {// 监听窗口改变window.addEventListener('resize', this.resizeScreen)},created() {this.bindBeforeUnload()},beforeDestroy() {this.closeConnectDgl()window.removeEventListener('resize', this.resizeScreen)},destroyed() {window.removeEventListener('beforeunload', this.beforeunloadFn)},methods: {onChangeValue(val, oldVal) {if (!val) returnthis.connectArr = []this.connectList.map(item => {const obj = {...item,term: '',websock: '',fitAddon: null}this.connectArr.push(obj)})this.$nextTick(() => {this.createConnect(this.connectArr[0], 0)})},bindBeforeUnload() {if (this.$route.name === '当前路由名称') {window.addEventListener('beforeunload', this.beforeunloadFn)}},// 刷新前断开socketasync beforeunloadFn() {if (this.$route.name === '当前路由名称') {this.closeConnectDgl()}},// toggleFullScreen() {// const obj = this.connectArr[this.editableTabsValue]// obj.term.toggleFullScreen(true)// },sizeTerminal(obj, cols, rows) {// console.log('cols:', cols, 'rows', rows)obj.term.resize(cols, rows)const ws = obj.websockif (ws && ws.io.readyState !== 'closed') {const msg = '4' + this.utf8_to_b64('{"Width":' + cols + ',"Height":' + rows + '}')ws.emit(obj.eventName, { message: msg })}},resizeScreen() {if (!this.throttleTimer) {this.throttleTimer = setTimeout(() => {this.throttleTimer = nullthis.connectArr.map(obj => {try {obj.fitAddon && obj.fitAddon.fit()// 窗口大小改变时触发xterm的resize方法,向后端发送行列数,格式由后端决定obj.term && obj.term.onResize((size) => {// console.log('size.cols', size.cols)const { cols, rows } = this.getRowsAndCols()this.sizeTerminal(obj, cols, rows)})} catch (e) {console.log('e', e.message)}})}, 1000)}},createConnect(obj, idx) {// Terminal.applyAddon(fullscreen)const { cols, rows } = this.getRowsAndCols()obj.term = new Terminal({cols,rows,cursorBlink: true, // 光标闪烁cursorStyle: 'underline', // 光标样式 null | 'block' | 'underline' | 'bar'scrollback: 1000, // 回滚tabStopWidth: 8, // 制表宽度fontSize: 14,disableStdin: false, // 是否应禁用输入theme: {background: '#000'},windowsMode: true,windowOptions: {fullscreenWin: true}})const fitAddon = new FitAddon()obj.fitAddon = fitAddonobj.term.loadAddon(fitAddon)fitAddon.fit()// 将term挂砸到dom节点上obj.term.open(document.getElementById('term' + idx))this.initSocketIo(obj, cols, rows)},initSocketIo(obj, cols, rows) {obj.websock = io('后端域名',{path: '/socket.io',transports: ['websocket'],secure: true})obj.command = []const { websock: socket, term, eventName } = objconst params = {...obj,cols,rows}delete params.websockdelete params.termdelete params.fitAddondelete params.commandsocket.on('connect', () => {console.log('connected success!')obj.connectSuccess = trueobj.aliveInter = window.setInterval(() => {socket.emit(eventName, { message: '0' })console.log('keep alive')}, 10 * 1000)term.writeln('connect success !!!')socket.emit('跟服务端约定事件名', params)this.$emit('connect', obj.name)})// 监听服务端发送消息this.onMessage(obj)// 系统事件-关闭socket.on('disconnect', msg => {console.log('#disconnect', msg)term.writeln('connect closed !!!')obj.connectSuccess = falsewindow.clearInterval(obj.aliveInter)obj.aliveInter = null})socket.on('error', (msg) => {console.log('#error', msg)// 失败重连// this.initSocketIo(obj, idx)})// 监听终端输入term.onData(data => {if (socket && socket.io.readyState !== 'closed') {const msg = '0' + this.utf8_to_b64(data)console.log(data)socket.emit(eventName, { message: msg })// term.write(data)}})// 添加事件监听器,支持输入方法term.onKey(e => {if (socket && socket.io.readyState !== 'closed') {// const printable = !e.domEvent.altKey && !e.domEvent.altGraphKey && !e.domEvent.ctrlKey && !e.domEvent.metaKeyif (e.domEvent.keyCode === 13) { // 回车this.handleCommandEnter(obj.name, [...obj.command])obj.command = []// term.prompt()} else if (e.domEvent.keyCode === 8) { // back 删除的情况obj.command.pop()// if (term._core.buffer.x > 2) {// term.write('\b \b')// }// } else if (printable) {// term.write(e.key)// }} else {obj.command.push(e.key)}}})},// 监听消息onMessage(obj) {obj.websock.on(obj.eventName, d => {const { message, type } = dif (message === 'WebSocket Client Connected') { // 跟服务端约定判断返回连接成功const { cols, rows } = this.getRowsAndCols()this.sizeTerminal(obj, cols, rows)}if (type === 'error') {console.error(message)} else {// console.log('服务端返回', message)}const data = message.slice(1)let first = true// 对返回内容做处理switch (message[0]) {case '1':case '2':case '3':obj.term.write(this.b64_to_utf8(data))if (first) {first = falseobj.term.cursorHidden = false// this.term.showCursor();if (obj.term.element) {obj.term.focus()}}breakdefault:console.log(message)break}})},// eslint-disable-next-line camelcaseutf8_to_b64(str) {return window.btoa(window.unescape(encodeURIComponent(str)))},// eslint-disable-next-line camelcaseb64_to_utf8(str) {return decodeURIComponent(window.escape(window.atob(str)))},getRowsAndCols() {const offsetWidth = document.documentElement.offsetWidth// 参数调整,兼容mac、pcconst ratio = offsetWidth > 1500 ? 7.6 : 8const adjustedValue = offsetWidth > 1500 ? 15 : 10return {cols: Math.max(Math.round((offsetWidth - adjustedValue) / ratio), 120),rows: Math.round((document.documentElement.clientHeight - 130) / 16)}},// 切换tabswitchTerm() {if (this.connectArr[this.editableTabsValue].websock) returnthis.$nextTick(() => {this.createConnect(this.connectArr[this.editableTabsValue],this.editableTabsValue)})},// 每次enter记录输入命令handleCommandEnter(name, command) {this.$emit('enter', name, command.join(''))},closeConnectDgl() {console.log('===============closeConnectDgl================')this.connectArr.map(item => {// 发送 exit 命令console.log('item.connectSuccess', item.connectSuccess)if (item.websock && item.connectSuccess) {const { websock, eventName } = item// 向服务端发送退出websock.emit(eventName, { message: '0' + this.utf8_to_b64('e') })websock.emit(eventName, { message: '0' + this.utf8_to_b64('x') })websock.emit(eventName, { message: '0' + this.utf8_to_b64('i') })websock.emit(eventName, { message: '0' + this.utf8_to_b64('t') })websock.emit(eventName, { message: '0' + this.utf8_to_b64('\n') })websock.disconnect()}// 销毁终端item.term && item.term.dispose()window.clearInterval(item.aliveInter)item.aliveInter = null})this.$emit('closeConnect', false)}}
}













