From 21a97bf37500dc522b19e3b59bb1447489694c16 Mon Sep 17 00:00:00 2001 From: chaos-zhu Date: Thu, 15 Aug 2024 07:24:02 +0800 Subject: [PATCH] =?UTF-8?q?:sparkles:=20=E9=87=8D=E6=9E=84=E7=BB=88?= =?UTF-8?q?=E7=AB=AF=E8=BF=9E=E6=8E=A5=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/app/socket/terminal.js | 186 +++++++++++------- .../terminal/components/terminal-tab.vue | 72 +++---- 2 files changed, 151 insertions(+), 107 deletions(-) diff --git a/server/app/socket/terminal.js b/server/app/socket/terminal.js index 9ec9449..9c3006d 100644 --- a/server/app/socket/terminal.js +++ b/server/app/socket/terminal.js @@ -2,29 +2,31 @@ const { Server } = require('socket.io') const { Client: SSHClient } = require('ssh2') const { readHostList, readSSHRecord, verifyAuthSync, AESDecryptSync } = require('../utils') -function createTerminal(socket, sshClient) { - sshClient.shell({ term: 'xterm-color' }, (err, stream) => { - if (err) return socket.emit('output', err.toString()) - // 终端输出 - stream - .on('data', (data) => { - socket.emit('output', data.toString()) - }) - .on('close', () => { - consola.info('关闭终端') - sshClient.end() - }) - // web端输入 - socket.on('input', key => { - if (sshClient._sock.writable === false) return consola.info('终端连接已关闭') - stream.write(key) - }) - socket.emit('connect_terminal') // 已连接终端,web端可以执行指令了 - - // 监听按键重置终端大小 - socket.on('resize', ({ rows, cols }) => { - // consola.info('更改tty终端行&列: ', { rows, cols }) - stream.setWindow(rows, cols) +function createInteractiveShell(socket, sshClient) { + return new Promise((resolve) => { + sshClient.shell({ term: 'xterm-color' }, (err, stream) => { + resolve(stream) + if (err) return socket.emit('output', err.toString()) + // 终端输出 + stream + .on('data', (data) => { + socket.emit('output', data.toString()) + }) + .on('close', () => { + consola.info('交互终端已关闭') + sshClient.end() + }) + socket.emit('connect_terminal') // 已连接终端,web端可以执行指令了 + // web端输入 + // socket.on('input', key => { + // if (sshClient._sock.writable === false) return consola.info('终端连接已关闭,禁止输入') + // stream.write(key) + // }) + // 监听按键重置终端大小 + // socket.on('resize', ({ rows, cols }) => { + // // consola.info('更改tty终端行&列: ', { rows, cols }) + // stream.setWindow(rows, cols) + // }) }) }) } @@ -52,6 +54,61 @@ function execShell(sshClient, command = '', callback) { }) } +async function createTerminal(ip, socket, sshClient) { + // eslint-disable-next-line no-async-promise-executor + return new Promise(async (resolve) => { + const hostList = await readHostList() + const targetHostInfo = hostList.find(item => item.host === ip) || {} + let { authType, host, port, username } = targetHostInfo + if (!host) return socket.emit('create_fail', `查找【${ ip }】凭证信息失败`) + let authInfo = { host, port, username } + // 统一使用commonKey解密 + try { + // 解密放到try里面,防止报错【commonKey必须配对, 否则需要重新添加服务器密钥】 + if (authType === 'credential') { + let credentialId = await AESDecryptSync(targetHostInfo[authType]) + const sshRecordList = await readSSHRecord() + const sshRecord = sshRecordList.find(item => item._id === credentialId) + authInfo.authType = sshRecord.authType + authInfo[authInfo.authType] = await AESDecryptSync(sshRecord[authInfo.authType]) + } else { + authInfo[authType] = await AESDecryptSync(targetHostInfo[authType]) + } + consola.info('准备连接终端:', host) + // targetHostInfo[targetHostInfo.authType] = await AESDecryptSync(targetHostInfo[targetHostInfo.authType]) + consola.log('连接信息', { username, port, authType }) + sshClient + .on('ready', async() => { + consola.success('终端连接成功:', host) + socket.emit('connect_success', `终端连接成功:${ host }`) + let stream = await createInteractiveShell(socket, sshClient) + resolve(stream) + // execShell(sshClient, 'history', (data) => { + // data = data.split('\n').filter(item => item) + // console.log(data) + // socket.emit('terminal_command_history', data) + // }) + }) + .on('close', () => { + consola.info('终端连接断开close') + socket.emit('connect_close') + }) + .on('error', (err) => { + consola.log(err) + consola.error('连接终端失败:', err.level) + socket.emit('connect_fail', err.message) + }) + .connect({ + ...authInfo + // debug: (info) => console.log(info) + }) + } catch (err) { + consola.error('创建终端失败:', err.message) + socket.emit('create_fail', err.message) + } + }) +} + module.exports = (httpServer) => { const serverIo = new Server(httpServer, { path: '/terminal', @@ -62,9 +119,8 @@ module.exports = (httpServer) => { serverIo.on('connection', (socket) => { // 前者兼容nginx反代, 后者兼容nodejs自身服务 let clientIp = socket.handshake.headers['x-forwarded-for'] || socket.handshake.address - let sshClient = new SSHClient() consola.success('terminal websocket 已连接') - + let sshClient = null socket.on('create', async ({ host: ip, token }) => { const { code } = await verifyAuthSync(token, clientIp) if (code !== 1) { @@ -72,57 +128,43 @@ module.exports = (httpServer) => { socket.disconnect() return } - const hostList = await readHostList() - const targetHostInfo = hostList.find(item => item.host === ip) || {} - let { authType, host, port, username } = targetHostInfo - if (!host) return socket.emit('create_fail', `查找【${ ip }】凭证信息失败`) - let authInfo = { host, port, username } - // 统一使用commonKey解密 - try { - // 解密放到try里面,防止报错【commonKey必须配对, 否则需要重新添加服务器密钥】 - if (authType === 'credential') { - let credentialId = await AESDecryptSync(targetHostInfo[authType]) - const sshRecordList = await readSSHRecord() - const sshRecord = sshRecordList.find(item => item._id === credentialId) - authInfo.authType = sshRecord.authType - authInfo[authInfo.authType] = await AESDecryptSync(sshRecord[authInfo.authType]) - } else { - authInfo[authType] = await AESDecryptSync(targetHostInfo[authType]) - } - consola.info('准备连接终端:', host) - // targetHostInfo[targetHostInfo.authType] = await AESDecryptSync(targetHostInfo[targetHostInfo.authType]) - consola.log('连接信息', { username, port, authType }) - sshClient - .on('ready', () => { - consola.success('连接终端成功:', host) - socket.emit('connect_success', `已连接到终端:${ host }`) - createTerminal(socket, sshClient) - // execShell(sshClient, 'history', (data) => { - // data = data.split('\n').filter(item => item) - // console.log(data) - // socket.emit('terminal_command_history', data) - // }) - }) - .on('error', (err) => { - console.log(err) - consola.error('连接终端失败:', err.level) - socket.emit('connect_fail', err.message) - }) - .connect({ - ...authInfo - // debug: (info) => console.log(info) - }) - } catch (err) { - consola.error('创建终端失败:', err.message) - socket.emit('create_fail', err.message) + sshClient = new SSHClient() + + // 尝试手动断开调试,再次连接后终端输出内容为4份相同的输出,导致异常 + // setTimeout(() => { + // sshClient.end() + // }, 3000) + let stream = await createTerminal(ip, socket, sshClient) + + function listenerInput(key) { + if (sshClient._sock.writable === false) return consola.info('终端连接已关闭,禁止输入') + stream.write(key) } + function resizeShell({ rows, cols }) { + // consola.info('更改tty终端行&列: ', { rows, cols }) + stream.setWindow(rows, cols) + } + socket.on('input', listenerInput) + socket.on('resize', resizeShell) + // 重连 + socket.on('reconnect_terminal', async () => { + socket.off('input', listenerInput) // 取消监听,重新注册监听,操作新的stream + socket.off('resize', resizeShell) + sshClient?.end() + sshClient?.destroy() + sshClient = null + setTimeout(async () => { + // 初始化新的SSH客户端对象 + sshClient = new SSHClient() + stream = await createTerminal(ip, socket, sshClient) + socket.on('input', listenerInput) + socket.on('resize', resizeShell) + }, 3000) + }) }) socket.on('disconnect', (reason) => { - consola.info('终端连接断开:', reason) - sshClient.end() - sshClient.destroy() - sshClient = null + consola.info('终端socket连接断开:', reason) }) }) } diff --git a/web/src/views/terminal/components/terminal-tab.vue b/web/src/views/terminal/components/terminal-tab.vue index 86f9afb..feb1e41 100644 --- a/web/src/views/terminal/components/terminal-tab.vue +++ b/web/src/views/terminal/components/terminal-tab.vue @@ -55,6 +55,9 @@ const timer = ref(null) const fitAddon = ref(null) const searchBar = ref(null) const isManual = ref(false) +const isConnectFail = ref(false) +const isConnecting = ref(true) +const isReConnect = ref(false) const terminal = ref(null) const terminalRef = ref(null) @@ -109,6 +112,12 @@ const connectIO = () => { console.log('/terminal socket已连接:', socket.value.id) socket.value.emit('create', { host, token: token.value }) socket.value.on('connect_success', () => { + isConnectFail.value = false + isConnecting.value = false + if (isReConnect.value) { + isReConnect.value = false + return // 重连不需要再注册监听事件 + } onData() socket.value.on('connect_terminal', () => { onResize() @@ -121,35 +130,37 @@ const connectIO = () => { // commandHistoryList.value = data // }) }) - socket.value.on('create_fail', (message) => { - console.error(message) - $notification({ - title: '创建失败', - message, - type: 'error' - }) - }) socket.value.on('token_verify_fail', () => { - $notification({ - title: 'Error', - message: 'token校验失败,请重新登录', - type: 'error' - }) + $notification({ title: 'Error', message: 'token校验失败,请重新登录', type: 'error' }) $router.push('/login') }) - socket.value.on('connect_fail', (message) => { + socket.value.on('connect_close', () => { + if (isConnectFail.value) return + isReConnect.value = true // 重连状态标记为true + isConnecting.value = true + console.warn('连接断开') + term.value.write('\r\n连接断开,3秒后自动重连...\r\n') + // socket.value.removeAllListeners() + // socket.value.off('output') // 取消output监听,取消onData输入监听,重新注册 + socket.value.emit('reconnect_terminal') + }) + socket.value.on('create_fail', (message) => { + isConnectFail.value = true + isConnecting.value = false console.error(message) - $notification({ - title: '终端连接失败', - message, - type: 'error' - }) + term.value.write(`\r\n创建失败: ${ message }\r\n`) + }) + socket.value.on('connect_fail', (message) => { + isConnectFail.value = true + isConnecting.value = false + console.error('连接失败:', message) + term.value.write(`\r\n连接失败: ${ message }\r\n`) }) }) socket.value.on('disconnect', () => { console.warn('terminal websocket 连接断开') - if (!isManual.value) reConnect() + if (!isManual.value) $notification({ title: '与面板socket连接断开', message: `${ props.host }-请检查socket服务是否稳定`, type: 'error' }) }) socket.value.on('connect_error', (err) => { @@ -162,21 +173,6 @@ const connectIO = () => { }) } -const reConnect = () => { - socket.value.close && socket.value.close() - $message.warning('终端连接断开') - // $messageBox.alert( - // '终端连接断开', - // 'Error', - // { - // dangerouslyUseHTMLString: true, - // confirmButtonText: '刷新页面' - // } - // ).then(() => { - // location.reload() - // }) -} - const createLocalTerminal = () => { let terminalInstance = new Terminal({ rendererType: 'dom', @@ -300,6 +296,7 @@ const onData = () => { terminalText.value += str // console.log(terminalText.value) }) + // term.value.off('data', listenerInput) term.value.onData((key) => { let acsiiCode = key.codePointAt() if (acsiiCode === 22) return handlePaste() @@ -307,6 +304,11 @@ const onData = () => { enterTimer.value = setTimeout(() => { if (enterTimer.value) clearTimeout(enterTimer.value) if (key === '\r') { // Enter + if (isConnectFail.value && !isConnecting.value) { // 连接失败&&未正在连接,按回车可触发重连 + term.value.write('\r\n连接中...\r\n') + socket.value.emit('reconnect_terminal') + return + } let cleanText = applyBackspace(filterAnsiSequences(terminalText.value)) const lines = cleanText.split('\n') // console.log('lines: ', lines)