From 22c4e2cd4627a4b11cef52b7f085a2d04d608c8c Mon Sep 17 00:00:00 2001 From: chaos-zhu Date: Thu, 1 Aug 2024 23:14:47 +0800 Subject: [PATCH] =?UTF-8?q?:sparkles:=20=E6=94=AF=E6=8C=81=E6=9C=8D?= =?UTF-8?q?=E5=8A=A1=E5=99=A8=E6=89=B9=E9=87=8F=E5=90=8E=E5=8F=B0=E5=91=BD?= =?UTF-8?q?=E4=BB=A4=E5=90=8C=E6=AD=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/app/config/index.js | 1 + server/app/controller/onekey.js | 29 ++ server/app/db/README.md | 10 + server/app/router/routes.js | 15 +- server/app/server.js | 6 +- server/app/socket/onekey.js | 186 ++++++++ server/app/socket/terminal.js | 2 +- server/app/utils/db-class.js | 13 +- server/app/utils/index.js | 13 +- server/app/utils/storage.js | 50 +- server/app/utils/tools.js | 40 +- server/package.json | 2 +- web/.eslintrc.js | 1 + web/src/api/index.js | 6 + web/src/components/aside-box.vue | 10 +- web/src/views/onekey/index.vue | 446 +++++++++++++++++- .../views/terminal/components/terminal.vue | 8 +- 17 files changed, 804 insertions(+), 34 deletions(-) create mode 100644 server/app/controller/onekey.js create mode 100644 server/app/socket/onekey.js diff --git a/server/app/config/index.js b/server/app/config/index.js index c1224f2..a6f0985 100644 --- a/server/app/config/index.js +++ b/server/app/config/index.js @@ -15,6 +15,7 @@ module.exports = { groupConfDBPath: path.join(process.cwd(),'app/db/group.db'), emailNotifyDBPath: path.join(process.cwd(),'app/db/email.db'), scriptsDBPath: path.join(process.cwd(),'app/db/scripts.db'), + onekeyDBPath: path.join(process.cwd(),'app/db/onekey.db'), apiPrefix: '/api/v1', logConfig: { outDir: path.join(process.cwd(),'./app/logs'), diff --git a/server/app/controller/onekey.js b/server/app/controller/onekey.js new file mode 100644 index 0000000..1599221 --- /dev/null +++ b/server/app/controller/onekey.js @@ -0,0 +1,29 @@ +const { readOneKeyRecord, deleteOneKeyRecord } = require('../utils') + +async function getOnekeyRecord({ res }) { + let data = await readOneKeyRecord() + data = data.map(item => { + return { ...item, id: item._id } + }) + data?.sort((a, b) => Number(b.date) - Number(a.date)) + res.success({ data }) +} + +const removeOnekeyRecord = async ({ res, request }) => { + let { body: { ids } } = request + let onekeyRecord = await readOneKeyRecord() + if (ids === 'ALL') { + ids = onekeyRecord.map(item => item._id) + await deleteOneKeyRecord(ids) + res.success({ data: '移除全部成功' }) + } else { + if (!onekeyRecord.some(item => ids.includes(item._id))) return res.fail({ msg: '批量指令记录ID不存在' }) + await deleteOneKeyRecord(ids) + res.success({ data: '移除成功' }) + } +} + +module.exports = { + getOnekeyRecord, + removeOnekeyRecord +} diff --git a/server/app/db/README.md b/server/app/db/README.md index e844fa0..cf6c022 100644 --- a/server/app/db/README.md +++ b/server/app/db/README.md @@ -38,3 +38,13 @@ db目录,初始化后自动生成 **group.db** > 服务器分组配置 + + +**scripts.db** + +> 脚本库 + + +**onekey.db** + +> 批量指令记录 diff --git a/server/app/router/routes.js b/server/app/router/routes.js index d54065f..2fd4f01 100644 --- a/server/app/router/routes.js +++ b/server/app/router/routes.js @@ -4,6 +4,7 @@ const { login, getpublicKey, updatePwd, getLoginRecord } = require('../controlle const { getSupportEmailList, getUserEmailList, updateUserEmailList, removeUserEmail, pushEmail, getNotifyList, updateNotifyList } = require('../controller/notify') const { getGroupList, addGroupList, updateGroupList, removeGroup } = require('../controller/group') const { getScriptList, addScript, updateScriptList, removeScript } = require('../controller/scripts') +const { getOnekeyRecord, removeOnekeyRecord } = require('../controller/onekey') const ssh = [ { @@ -165,4 +166,16 @@ const scripts = [ } ] -module.exports = [].concat(ssh, host, user, notify, group, scripts) +const onekey = [ + { + method: 'get', + path: '/onekey', + controller: getOnekeyRecord + }, + { + method: 'post', + path: '/onekey', + controller: removeOnekeyRecord + } +] +module.exports = [].concat(ssh, host, user, notify, group, scripts, onekey) diff --git a/server/app/server.js b/server/app/server.js index f9b56a7..2309d9d 100644 --- a/server/app/server.js +++ b/server/app/server.js @@ -5,8 +5,9 @@ const { httpPort } = require('./config') const middlewares = require('./middlewares') const wsTerminal = require('./socket/terminal') const wsSftp = require('./socket/sftp') -const wsHostStatus = require('./socket/host-status') +// const wsHostStatus = require('./socket/host-status') const wsClientInfo = require('./socket/clients') +const wsOnekey = require('./socket/onekey') const { throwError } = require('./utils') const httpServer = () => { @@ -24,7 +25,8 @@ function serverHandler(app, server) { app.proxy = true // 用于nginx反代时获取真实客户端ip wsTerminal(server) // 终端 wsSftp(server) // sftp - wsHostStatus(server) // 终端侧边栏host信息 + // wsHostStatus(server) // 终端侧边栏host信息(单个host) + wsOnekey(server) // 一键指令 wsClientInfo(server) // 客户端信息 app.context.throwError = throwError // 常用方法挂载全局ctx上 app.use(compose(middlewares)) diff --git a/server/app/socket/onekey.js b/server/app/socket/onekey.js new file mode 100644 index 0000000..54154bf --- /dev/null +++ b/server/app/socket/onekey.js @@ -0,0 +1,186 @@ +const { Server } = require('socket.io') +const { Client: SSHClient } = require('ssh2') +const { readHostList, readSSHRecord, verifyAuthSync, AESDecryptSync, writeOneKeyRecord, throttle } = require('../utils') + +const execStatusEnum = { + connecting: '连接中', + connectFail: '连接失败', + executing: '执行中', + execSuccess: '执行成功', + execFail: '执行失败', + execTimeout: '执行超时', + socketInterrupt: '执行中断' +} + +let isExecuting = false +let execResult = [] +let execClient = [] + +function disconnectAllExecClient() { + execClient.forEach((sshClient) => { + if (sshClient) { + sshClient.end() + sshClient.destroy() + sshClient = null + } + }) +} + +function execShell(socket, sshClient, curRes, resolve) { + const throttledDataHandler = throttle((data) => { + curRes.status = execStatusEnum.executing + curRes.result += data?.toString() || '' + socket.emit('output', execResult) + }, 500) // 防止内存爆破 + sshClient.exec(curRes.command, function(err, stream) { + if (err) { + console.log(curRes.host, '命令执行失败:', err) + curRes.status = execStatusEnum.execFail + curRes.result += err.toString() + socket.emit('output', execResult) + return + } + stream + .on('close', () => { + throttledDataHandler.flush() + // console.log('onekey终端执行完成, 关闭连接: ', curRes.host) + if (curRes.status === execStatusEnum.executing) { + curRes.status = execStatusEnum.execSuccess + } + socket.emit('output', execResult) + resolve(curRes) + sshClient.end() + }) + .on('data', (data) => { + // console.log(curRes.host, '执行中: \n' + data) + // curRes.status = execStatusEnum.executing + // curRes.result += data.toString() + // socket.emit('output', execResult) + throttledDataHandler(data) + }) + .stderr + .on('data', (data) => { + // console.log(curRes.host, '命令执行过程中产生错误: ' + data) + // curRes.status = execStatusEnum.executing + // curRes.result += data.toString() + // socket.emit('output', execResult) + throttledDataHandler(data) + }) + }) +} + +module.exports = (httpServer) => { + const serverIo = new Server(httpServer, { + path: '/onekey', + cors: { + origin: '*' + } + }) + serverIo.on('connection', (socket) => { + // 前者兼容nginx反代, 后者兼容nodejs自身服务 + let clientIp = socket.handshake.headers['x-forwarded-for'] || socket.handshake.address + consola.success('onekey-terminal websocket 已连接') + if (isExecuting) { + socket.emit('create_fail', '正在执行中, 请稍后再试') + socket.disconnect() + return + } + isExecuting = true + socket.on('create', async ({ hosts, token, command, timeout }) => { + const { code } = await verifyAuthSync(token, clientIp) + if (code !== 1) { + socket.emit('token_verify_fail') + socket.disconnect() + return + } + setTimeout(() => { + // 超时未执行完成,断开连接 + disconnectAllExecClient() + const { connecting, executing } = execStatusEnum + execResult.forEach(item => { + // 连接中和执行中的状态设定为超时 + if ([connecting, executing].includes(item.status)) { + item.status = execStatusEnum.execTimeout + } + }) + socket.emit('timeout', { reason: `执行超时,已强制终止执行 - 超时时间${ timeout }秒`, result: execResult }) + socket.disconnect() + }, timeout * 1000) + console.log('hosts:', hosts) + // console.log('token:', token) + console.log('command:', command) + const hostList = await readHostList() + const targetHostsInfo = hostList.filter(item => hosts.some(ip => item.host === ip)) || {} + // console.log('targetHostsInfo:', targetHostsInfo) + if (!targetHostsInfo.length) return socket.emit('create_fail', `未找到【${ hosts }】服务器信息`) + // 查找 hostInfo -> 并发执行 + socket.emit('ready') + let execPromise = targetHostsInfo.map((hostInfo, index) => { + // eslint-disable-next-line no-async-promise-executor + return new Promise(async (resolve) => { + let { authType, host, port, username } = hostInfo + let authInfo = { host, port, username } + let curRes = { command, host, name: hostInfo.name, result: '', status: execStatusEnum.connecting, date: Date.now() - (targetHostsInfo.length - index) } // , execStatusEnum + execResult.push(curRes) + try { + if (authType === 'credential') { + let credentialId = await AESDecryptSync(hostInfo['credential']) + 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(hostInfo[authType]) + } + consola.info('准备连接终端执行一次性指令:', host) + consola.log('连接信息', { username, port, authType }) + let sshClient = new SSHClient() + execClient.push(sshClient) + sshClient + .on('ready', () => { + consola.success('连接终端成功:', host) + // socket.emit('connect_success', `已连接到终端:${ host }`) + execShell(socket, sshClient, curRes, resolve) + }) + .on('error', (err) => { + console.log(err) + consola.error('onekey终端连接失败:', err.level) + curRes.status = execStatusEnum.connectFail + curRes.result += err.message + resolve(curRes) + }) + .connect({ + ...authInfo + // debug: (info) => console.log(info) + }) + } catch (err) { + consola.error('创建终端错误:', err.message) + curRes.status = execStatusEnum.connectFail + curRes.result += err.message + resolve(curRes) + } + }) + }) + await Promise.all(execPromise) + consola.success('onekey执行完成') + socket.emit('exec_complete') + socket.disconnect() + }) + + socket.on('disconnect', async (reason) => { + consola.info('onekey终端连接断开:', reason) + disconnectAllExecClient() + const { execSuccess, connectFail, execFail, execTimeout } = execStatusEnum + execResult.forEach(item => { + // 非服务端手动断开连接且命令执行状态为非完成\失败\超时, 判定为客户端主动中断 + if (reason !== 'server namespace disconnect' && ![execSuccess, execFail, execTimeout, connectFail].includes(item.status)) { + item.status = execStatusEnum.socketInterrupt + } + }) + await writeOneKeyRecord(execResult) + isExecuting = false + execResult = [] + execClient = [] + }) + }) +} diff --git a/server/app/socket/terminal.js b/server/app/socket/terminal.js index 2621981..86fef1d 100644 --- a/server/app/socket/terminal.js +++ b/server/app/socket/terminal.js @@ -67,7 +67,7 @@ module.exports = (httpServer) => { authInfo[authType] = await AESDecryptSync(targetHostInfo[authType]) } consola.info('准备连接终端:', host) - targetHostInfo[targetHostInfo.authType] = await AESDecryptSync(targetHostInfo[targetHostInfo.authType]) + // targetHostInfo[targetHostInfo.authType] = await AESDecryptSync(targetHostInfo[targetHostInfo.authType]) consola.log('连接信息', { username, port, authType }) sshClient .on('ready', () => { diff --git a/server/app/utils/db-class.js b/server/app/utils/db-class.js index c2cdc3b..332b2ec 100644 --- a/server/app/utils/db-class.js +++ b/server/app/utils/db-class.js @@ -1,5 +1,5 @@ const Datastore = require('@seald-io/nedb') -const { credentialsDBPath, hostListDBPath, keyDBPath, emailNotifyDBPath, notifyConfDBPath, groupConfDBPath, scriptsDBPath } = require('../config') +const { credentialsDBPath, hostListDBPath, keyDBPath, emailNotifyDBPath, notifyConfDBPath, groupConfDBPath, scriptsDBPath, onekeyDBPath } = require('../config') module.exports.KeyDB = class KeyDB { constructor() { @@ -77,3 +77,14 @@ module.exports.ScriptsDB = class ScriptsDB { return ScriptsDB.instance } } + +module.exports.OnekeyDB = class OnekeyDB { + constructor() { + if (!OnekeyDB.instance) { + OnekeyDB.instance = new Datastore({ filename: onekeyDBPath, autoload: true }) + } + } + getInstance() { + return OnekeyDB.instance + } +} diff --git a/server/app/utils/index.js b/server/app/utils/index.js index bc85799..815abfe 100644 --- a/server/app/utils/index.js +++ b/server/app/utils/index.js @@ -14,11 +14,14 @@ const { readGroupList, writeGroupList, readScriptList, - writeScriptList + writeScriptList, + readOneKeyRecord, + writeOneKeyRecord, + deleteOneKeyRecord } = require('./storage') const { RSADecryptSync, AESEncryptSync, AESDecryptSync, SHA1Encrypt } = require('./encrypt') const { verifyAuthSync, isProd } = require('./verify-auth') -const { getNetIPInfo, throwError, isIP, randomStr, getUTCDate, formatTimestamp } = require('./tools') +const { getNetIPInfo, throwError, isIP, randomStr, getUTCDate, formatTimestamp, throttle } = require('./tools') const { emailTransporter, sendEmailToConfList } = require('./email') module.exports = { @@ -28,6 +31,7 @@ module.exports = { randomStr, getUTCDate, formatTimestamp, + throttle, verifyAuthSync, isProd, RSADecryptSync, @@ -51,5 +55,8 @@ module.exports = { readGroupList, writeGroupList, readScriptList, - writeScriptList + writeScriptList, + readOneKeyRecord, + writeOneKeyRecord, + deleteOneKeyRecord } diff --git a/server/app/utils/storage.js b/server/app/utils/storage.js index a257fa5..3259cd7 100644 --- a/server/app/utils/storage.js +++ b/server/app/utils/storage.js @@ -1,4 +1,4 @@ -const { KeyDB, HostListDB, SshRecordDB, NotifyDB, GroupDB, EmailNotifyDB, ScriptsDB } = require('./db-class') +const { KeyDB, HostListDB, SshRecordDB, NotifyDB, GroupDB, EmailNotifyDB, ScriptsDB, OnekeyDB } = require('./db-class') const readKey = async () => { return new Promise((resolve, reject) => { @@ -270,6 +270,49 @@ const writeScriptList = async (list = []) => { }) } +const readOneKeyRecord = async () => { + return new Promise((resolve, reject) => { + const onekeyDB = new OnekeyDB().getInstance() + onekeyDB.find({}, (err, docs) => { + if (err) { + consola.error('读取onekey record错误: ', err) + reject(err) + } else { + resolve(docs) + } + }) + }) +} + +const writeOneKeyRecord = async (records =[]) => { + return new Promise((resolve, reject) => { + const onekeyDB = new OnekeyDB().getInstance() + onekeyDB.insert(records, (err, newDocs) => { + if (err) { + consola.error('写入新的onekey记录出错:', err) + reject(err) + } else { + onekeyDB.compactDatafile() + resolve(newDocs) + } + }) + }) +} +const deleteOneKeyRecord = async (ids =[]) => { + return new Promise((resolve, reject) => { + const onekeyDB = new OnekeyDB().getInstance() + onekeyDB.remove({ _id: { $in: ids } }, { multi: true }, function (err, numRemoved) { + if (err) { + consola.error('Error deleting onekey record(s):', err) + reject(err) + } else { + onekeyDB.compactDatafile() + resolve(numRemoved) + } + }) + }) +} + module.exports = { readSSHRecord, writeSSHRecord, @@ -286,5 +329,8 @@ module.exports = { readUserEmailList, writeUserEmailList, readScriptList, - writeScriptList + writeScriptList, + readOneKeyRecord, + writeOneKeyRecord, + deleteOneKeyRecord } \ No newline at end of file diff --git a/server/app/utils/tools.js b/server/app/utils/tools.js index d67c2bc..45d95c4 100644 --- a/server/app/utils/tools.js +++ b/server/app/utils/tools.js @@ -204,6 +204,43 @@ function resolvePath(dir, path) { return path.resolve(dir, path) } +function throttle(func, limit) { + let lastFunc + let lastRan + let pendingArgs = null + + const runner = () => { + func.apply(this, pendingArgs) + lastRan = Date.now() + pendingArgs = null + } + + const throttled = function() { + const context = this + const args = arguments + pendingArgs = args + if (!lastRan || (Date.now() - lastRan >= limit)) { + if (lastFunc) { + clearTimeout(lastFunc) + } + runner.apply(context, args) + } else { + clearTimeout(lastFunc) + lastFunc = setTimeout(() => { + runner.apply(context, args) + }, limit - (Date.now() - lastRan)) + } + } + + throttled.flush = () => { + if (pendingArgs) { + runner.apply(this, pendingArgs) + } + } + + return throttled +} + module.exports = { getNetIPInfo, throwError, @@ -211,5 +248,6 @@ module.exports = { randomStr, getUTCDate, formatTimestamp, - resolvePath + resolvePath, + throttle } \ No newline at end of file diff --git a/server/package.json b/server/package.json index cbfe2af..eae4d89 100644 --- a/server/package.json +++ b/server/package.json @@ -4,7 +4,7 @@ "description": "easynode-server", "bin": "./bin/www", "scripts": { - "local": "cross-env EXEC_ENV=local nodemon ./app/index.js", + "local": "cross-env EXEC_ENV=local nodemon ./app/index.js --max-old-space-size=4096", "prod": "cross-env EXEC_ENV=production nodemon ./app/index.js", "start": "node ./index.js", "lint": "eslint . --ext .js,.vue", diff --git a/web/.eslintrc.js b/web/.eslintrc.js index 8f105df..466d4af 100644 --- a/web/.eslintrc.js +++ b/web/.eslintrc.js @@ -34,6 +34,7 @@ module.exports = { 'vue/singleline-html-element-content-newline': 0, // js + 'no-async-promise-executor': 0, 'import/no-extraneous-dependencies': 0, 'no-console': 'off', 'no-debugger': process.env.NODE_ENV === 'production' ? 'error' : 'off', diff --git a/web/src/api/index.js b/web/src/api/index.js index 5e242cb..3a3fd8d 100644 --- a/web/src/api/index.js +++ b/web/src/api/index.js @@ -99,5 +99,11 @@ export default { }, deleteScript(id) { return axios({ url: `/script/${ id }`, method: 'delete' }) + }, + getOnekeyRecord() { + return axios({ url: '/onekey', method: 'get' }) + }, + deleteOnekeyRecord(ids) { + return axios({ url: '/onekey', method: 'post', data: { ids } }) } } diff --git a/web/src/components/aside-box.vue b/web/src/components/aside-box.vue index a642b45..b9361ca 100644 --- a/web/src/components/aside-box.vue +++ b/web/src/components/aside-box.vue @@ -67,11 +67,11 @@ let menuList = reactive([ icon: markRaw(ArrowRight), index: '/scripts' }, - // { - // name: '批量指令', - // icon: markRaw(Pointer), - // index: '/onekey' - // }, + { + name: '批量指令', + icon: markRaw(Pointer), + index: '/onekey' + }, { name: '系统设置', icon: markRaw(Setting), diff --git a/web/src/views/onekey/index.vue b/web/src/views/onekey/index.vue index 1294743..7616805 100644 --- a/web/src/views/onekey/index.vue +++ b/web/src/views/onekey/index.vue @@ -1,19 +1,445 @@ - +.onekey_container { + padding: 20px; + .header { + padding: 15px; + display: flex; + align-items: center; + justify-content: end; + position: sticky; + top: 0; + z-index: 1; + background-color: #fff; + } + .detail_content_box { + max-height: 200px; + overflow: auto; + white-space: pre-line; + line-height: 1.1; + background: rgba(227, 230, 235, .7); + padding: 25px; + border-radius: 3px; + } + .select_host_wrap { + width: 100%; + display: flex; + .select { + flex: 1; + margin-right: 15px; + .tips { + color: #999; + font-size: 12px; + } + } + .btn { + width: 52px; + } + } + .command_wrap { + width: 100%; + padding-top: 8px; + display: flex; + flex-direction: column; + .scripts_menu { + :deep(.el-dropdown-menu) { + min-width: 120px; + max-width: 300px; + } + } + .link_text { + font-size: var(--el-font-size-base); + // color: var(--el-text-color-regular); + color: var(--el-color-primary); + cursor: pointer; + margin-right: 15px; + user-select: none; + } + .input { + margin-top: 10px; + } + } +} + \ No newline at end of file diff --git a/web/src/views/terminal/components/terminal.vue b/web/src/views/terminal/components/terminal.vue index d5d0f0e..c9df37c 100644 --- a/web/src/views/terminal/components/terminal.vue +++ b/web/src/views/terminal/components/terminal.vue @@ -143,7 +143,7 @@