重构终端连接逻辑

This commit is contained in:
chaos-zhu 2024-08-15 07:24:02 +08:00
parent 5aaad74c57
commit 21a97bf375
2 changed files with 151 additions and 107 deletions

View File

@ -2,29 +2,31 @@ const { Server } = require('socket.io')
const { Client: SSHClient } = require('ssh2') const { Client: SSHClient } = require('ssh2')
const { readHostList, readSSHRecord, verifyAuthSync, AESDecryptSync } = require('../utils') const { readHostList, readSSHRecord, verifyAuthSync, AESDecryptSync } = require('../utils')
function createTerminal(socket, sshClient) { function createInteractiveShell(socket, sshClient) {
sshClient.shell({ term: 'xterm-color' }, (err, stream) => { return new Promise((resolve) => {
if (err) return socket.emit('output', err.toString()) sshClient.shell({ term: 'xterm-color' }, (err, stream) => {
// 终端输出 resolve(stream)
stream if (err) return socket.emit('output', err.toString())
.on('data', (data) => { // 终端输出
socket.emit('output', data.toString()) stream
}) .on('data', (data) => {
.on('close', () => { socket.emit('output', data.toString())
consola.info('关闭终端') })
sshClient.end() .on('close', () => {
}) consola.info('交互终端已关闭')
// web端输入 sshClient.end()
socket.on('input', key => { })
if (sshClient._sock.writable === false) return consola.info('终端连接已关闭') socket.emit('connect_terminal') // 已连接终端web端可以执行指令了
stream.write(key) // web端输入
}) // socket.on('input', key => {
socket.emit('connect_terminal') // 已连接终端web端可以执行指令了 // if (sshClient._sock.writable === false) return consola.info('终端连接已关闭,禁止输入')
// stream.write(key)
// 监听按键重置终端大小 // })
socket.on('resize', ({ rows, cols }) => { // 监听按键重置终端大小
// consola.info('更改tty终端行&列: ', { rows, cols }) // socket.on('resize', ({ rows, cols }) => {
stream.setWindow(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) => { module.exports = (httpServer) => {
const serverIo = new Server(httpServer, { const serverIo = new Server(httpServer, {
path: '/terminal', path: '/terminal',
@ -62,9 +119,8 @@ module.exports = (httpServer) => {
serverIo.on('connection', (socket) => { serverIo.on('connection', (socket) => {
// 前者兼容nginx反代, 后者兼容nodejs自身服务 // 前者兼容nginx反代, 后者兼容nodejs自身服务
let clientIp = socket.handshake.headers['x-forwarded-for'] || socket.handshake.address let clientIp = socket.handshake.headers['x-forwarded-for'] || socket.handshake.address
let sshClient = new SSHClient()
consola.success('terminal websocket 已连接') consola.success('terminal websocket 已连接')
let sshClient = null
socket.on('create', async ({ host: ip, token }) => { socket.on('create', async ({ host: ip, token }) => {
const { code } = await verifyAuthSync(token, clientIp) const { code } = await verifyAuthSync(token, clientIp)
if (code !== 1) { if (code !== 1) {
@ -72,57 +128,43 @@ module.exports = (httpServer) => {
socket.disconnect() socket.disconnect()
return return
} }
const hostList = await readHostList() sshClient = new SSHClient()
const targetHostInfo = hostList.find(item => item.host === ip) || {}
let { authType, host, port, username } = targetHostInfo // 尝试手动断开调试再次连接后终端输出内容为4份相同的输出导致异常
if (!host) return socket.emit('create_fail', `查找【${ ip }】凭证信息失败`) // setTimeout(() => {
let authInfo = { host, port, username } // sshClient.end()
// 统一使用commonKey解密 // }, 3000)
try { let stream = await createTerminal(ip, socket, sshClient)
// 解密放到try里面防止报错【commonKey必须配对, 否则需要重新添加服务器密钥】
if (authType === 'credential') { function listenerInput(key) {
let credentialId = await AESDecryptSync(targetHostInfo[authType]) if (sshClient._sock.writable === false) return consola.info('终端连接已关闭,禁止输入')
const sshRecordList = await readSSHRecord() stream.write(key)
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)
} }
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) => { socket.on('disconnect', (reason) => {
consola.info('终端连接断开:', reason) consola.info('终端socket连接断开:', reason)
sshClient.end()
sshClient.destroy()
sshClient = null
}) })
}) })
} }

View File

@ -55,6 +55,9 @@ const timer = ref(null)
const fitAddon = ref(null) const fitAddon = ref(null)
const searchBar = ref(null) const searchBar = ref(null)
const isManual = ref(false) const isManual = ref(false)
const isConnectFail = ref(false)
const isConnecting = ref(true)
const isReConnect = ref(false)
const terminal = ref(null) const terminal = ref(null)
const terminalRef = ref(null) const terminalRef = ref(null)
@ -109,6 +112,12 @@ const connectIO = () => {
console.log('/terminal socket已连接', socket.value.id) console.log('/terminal socket已连接', socket.value.id)
socket.value.emit('create', { host, token: token.value }) socket.value.emit('create', { host, token: token.value })
socket.value.on('connect_success', () => { socket.value.on('connect_success', () => {
isConnectFail.value = false
isConnecting.value = false
if (isReConnect.value) {
isReConnect.value = false
return //
}
onData() onData()
socket.value.on('connect_terminal', () => { socket.value.on('connect_terminal', () => {
onResize() onResize()
@ -121,35 +130,37 @@ const connectIO = () => {
// commandHistoryList.value = data // commandHistoryList.value = data
// }) // })
}) })
socket.value.on('create_fail', (message) => {
console.error(message)
$notification({
title: '创建失败',
message,
type: 'error'
})
})
socket.value.on('token_verify_fail', () => { socket.value.on('token_verify_fail', () => {
$notification({ $notification({ title: 'Error', message: 'token校验失败请重新登录', type: 'error' })
title: 'Error',
message: 'token校验失败请重新登录',
type: 'error'
})
$router.push('/login') $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) console.error(message)
$notification({ term.value.write(`\r\n创建失败: ${ message }\r\n`)
title: '终端连接失败', })
message, socket.value.on('connect_fail', (message) => {
type: 'error' isConnectFail.value = true
}) isConnecting.value = false
console.error('连接失败:', message)
term.value.write(`\r\n连接失败: ${ message }\r\n`)
}) })
}) })
socket.value.on('disconnect', () => { socket.value.on('disconnect', () => {
console.warn('terminal websocket 连接断开') 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) => { socket.value.on('connect_error', (err) => {
@ -162,21 +173,6 @@ const connectIO = () => {
}) })
} }
const reConnect = () => {
socket.value.close && socket.value.close()
$message.warning('终端连接断开')
// $messageBox.alert(
// '<strong></strong>',
// 'Error',
// {
// dangerouslyUseHTMLString: true,
// confirmButtonText: ''
// }
// ).then(() => {
// location.reload()
// })
}
const createLocalTerminal = () => { const createLocalTerminal = () => {
let terminalInstance = new Terminal({ let terminalInstance = new Terminal({
rendererType: 'dom', rendererType: 'dom',
@ -300,6 +296,7 @@ const onData = () => {
terminalText.value += str terminalText.value += str
// console.log(terminalText.value) // console.log(terminalText.value)
}) })
// term.value.off('data', listenerInput)
term.value.onData((key) => { term.value.onData((key) => {
let acsiiCode = key.codePointAt() let acsiiCode = key.codePointAt()
if (acsiiCode === 22) return handlePaste() if (acsiiCode === 22) return handlePaste()
@ -307,6 +304,11 @@ const onData = () => {
enterTimer.value = setTimeout(() => { enterTimer.value = setTimeout(() => {
if (enterTimer.value) clearTimeout(enterTimer.value) if (enterTimer.value) clearTimeout(enterTimer.value)
if (key === '\r') { // Enter 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)) let cleanText = applyBackspace(filterAnsiSequences(terminalText.value))
const lines = cleanText.split('\n') const lines = cleanText.split('\n')
// console.log('lines: ', lines) // console.log('lines: ', lines)