支持服务器批量指令下发

This commit is contained in:
chaos-zhu 2024-08-02 11:28:38 +08:00
parent 22c4e2cd46
commit 7513825d28
12 changed files with 110 additions and 90 deletions

View File

@ -151,8 +151,8 @@ function initScriptsDB() {
// eslint-disable-next-line no-async-promise-executor // eslint-disable-next-line no-async-promise-executor
return new Promise(async (resolve) => { return new Promise(async (resolve) => {
let scriptList = await readScriptList() let scriptList = await readScriptList()
let clientInstallScript = 'wget https://mirror.ghproxy.com/https://raw.githubusercontent.com/chaos-zhu/easynode/main/client/easynode-client-install.sh | bash' let clientInstallScript = 'wget https://mirror.ghproxy.com/https://raw.githubusercontent.com/chaos-zhu/easynode/main/client/easynode-client-install.sh && sh easynode-client-install.sh'
let clientUninstallScript = 'wget https://mirror.ghproxy.com/https://raw.githubusercontent.com/chaos-zhu/easynode/main/client/easynode-client-uninstall.sh | bash' let clientUninstallScript = 'wget https://mirror.ghproxy.com/https://raw.githubusercontent.com/chaos-zhu/easynode/main/client/easynode-client-uninstall.sh && sh easynode-client-uninstall.sh'
let clientVersion = process.env.CLIENT_VERSION let clientVersion = process.env.CLIENT_VERSION
consola.info('客户端版本:', clientVersion) consola.info('客户端版本:', clientVersion)
let installId = `clientInstall${ clientVersion }` let installId = `clientInstall${ clientVersion }`
@ -162,12 +162,18 @@ function initScriptsDB() {
let isClientUninstall = scriptList?.find(script => script._id = uninstallId) let isClientUninstall = scriptList?.find(script => script._id = uninstallId)
let writeFlag = false let writeFlag = false
if (!isClientInstall) { if (!isClientInstall) {
scriptList.push({ _id: installId, name: `easynode-client-${ clientVersion }安装脚本`, remark: '系统内置|重启生成', content: clientInstallScript, index: 99 }) console.info('初始化客户端安装脚本')
scriptList.push({ _id: installId, name: `easynode-客户端-${ clientVersion }安装脚本`, remark: '系统内置|重启生成', content: clientInstallScript, index: 1 })
writeFlag = true writeFlag = true
} else {
console.info('客户端安装脚本已存在')
} }
if (!isClientUninstall) { if (!isClientUninstall) {
scriptList.push({ _id: uninstallId, name: `easynode-client-${ clientVersion }卸载脚本`, remark: '系统内置|重启生成', content: clientUninstallScript, index: 98 }) console.info('初始化客户端卸载脚本')
scriptList.push({ _id: uninstallId, name: `easynode-客户端-${ clientVersion }卸载脚本`, remark: '系统内置|重启生成', content: clientUninstallScript, index: 0 })
writeFlag = true writeFlag = true
} else {
console.info('客户端卸载脚本已存在')
} }
if (writeFlag) await writeScriptList(scriptList) if (writeFlag) await writeScriptList(scriptList)
resolve() resolve()

View File

@ -1,6 +1,6 @@
const { Server } = require('socket.io') const { Server } = require('socket.io')
const { Client: SSHClient } = require('ssh2') const { Client: SSHClient } = require('ssh2')
const { readHostList, readSSHRecord, verifyAuthSync, AESDecryptSync, writeOneKeyRecord, throttle } = require('../utils') const { readHostList, readSSHRecord, verifyAuthSync, AESDecryptSync, writeOneKeyRecord, shellThrottle } = require('../utils')
const execStatusEnum = { const execStatusEnum = {
connecting: '连接中', connecting: '连接中',
@ -27,10 +27,17 @@ function disconnectAllExecClient() {
} }
function execShell(socket, sshClient, curRes, resolve) { function execShell(socket, sshClient, curRes, resolve) {
const throttledDataHandler = throttle((data) => { const throttledDataHandler = shellThrottle(() => {
curRes.status = execStatusEnum.executing
curRes.result += data?.toString() || ''
socket.emit('output', execResult) socket.emit('output', execResult)
// const memoryUsage = process.memoryUsage()
// const formattedMemoryUsage = {
// rss: (memoryUsage.rss / 1024 / 1024).toFixed(2) + ' MB', // Resident Set Size: total memory allocated for the process execution
// heapTotal: (memoryUsage.heapTotal / 1024 / 1024).toFixed(2) + ' MB', // Total size of the allocated heap
// heapUsed: (memoryUsage.heapUsed / 1024 / 1024).toFixed(2) + ' MB', // Actual memory used during the execution
// external: (memoryUsage.external / 1024 / 1024).toFixed(2) + ' MB', // Memory used by "external" components like V8 external memory
// arrayBuffers: (memoryUsage.arrayBuffers / 1024 / 1024).toFixed(2) + ' MB' // Memory allocated for ArrayBuffer and SharedArrayBuffer, including all Node.js Buffers
// }
// console.log(formattedMemoryUsage)
}, 500) // 防止内存爆破 }, 500) // 防止内存爆破
sshClient.exec(curRes.command, function(err, stream) { sshClient.exec(curRes.command, function(err, stream) {
if (err) { if (err) {
@ -41,8 +48,9 @@ function execShell(socket, sshClient, curRes, resolve) {
return return
} }
stream stream
.on('close', () => { .on('close', async () => {
throttledDataHandler.flush() // ssh连接关闭后再执行一次输出防止最后一次节流函数发生在延迟时间内导致终端的输出数据丢失
await throttledDataHandler.last() // 等待最后一次节流函数执行完成,再执行一次数据输出
// console.log('onekey终端执行完成, 关闭连接: ', curRes.host) // console.log('onekey终端执行完成, 关闭连接: ', curRes.host)
if (curRes.status === execStatusEnum.executing) { if (curRes.status === execStatusEnum.executing) {
curRes.status = execStatusEnum.execSuccess curRes.status = execStatusEnum.execSuccess
@ -53,16 +61,16 @@ function execShell(socket, sshClient, curRes, resolve) {
}) })
.on('data', (data) => { .on('data', (data) => {
// console.log(curRes.host, '执行中: \n' + data) // console.log(curRes.host, '执行中: \n' + data)
// curRes.status = execStatusEnum.executing curRes.status = execStatusEnum.executing
// curRes.result += data.toString() curRes.result += data.toString()
// socket.emit('output', execResult) // socket.emit('output', execResult)
throttledDataHandler(data) throttledDataHandler(data)
}) })
.stderr .stderr
.on('data', (data) => { .on('data', (data) => {
// console.log(curRes.host, '命令执行过程中产生错误: ' + data) // console.log(curRes.host, '命令执行过程中产生错误: ' + data)
// curRes.status = execStatusEnum.executing curRes.status = execStatusEnum.executing
// curRes.result += data.toString() curRes.result += data.toString()
// socket.emit('output', execResult) // socket.emit('output', execResult)
throttledDataHandler(data) throttledDataHandler(data)
}) })
@ -147,6 +155,7 @@ module.exports = (httpServer) => {
consola.error('onekey终端连接失败:', err.level) consola.error('onekey终端连接失败:', err.level)
curRes.status = execStatusEnum.connectFail curRes.status = execStatusEnum.connectFail
curRes.result += err.message curRes.result += err.message
socket.emit('output', execResult)
resolve(curRes) resolve(curRes)
}) })
.connect({ .connect({
@ -157,6 +166,7 @@ module.exports = (httpServer) => {
consola.error('创建终端错误:', err.message) consola.error('创建终端错误:', err.message)
curRes.status = execStatusEnum.connectFail curRes.status = execStatusEnum.connectFail
curRes.result += err.message curRes.result += err.message
socket.emit('output', execResult)
resolve(curRes) resolve(curRes)
} }
}) })

View File

@ -21,7 +21,7 @@ const {
} = require('./storage') } = require('./storage')
const { RSADecryptSync, AESEncryptSync, AESDecryptSync, SHA1Encrypt } = require('./encrypt') const { RSADecryptSync, AESEncryptSync, AESDecryptSync, SHA1Encrypt } = require('./encrypt')
const { verifyAuthSync, isProd } = require('./verify-auth') const { verifyAuthSync, isProd } = require('./verify-auth')
const { getNetIPInfo, throwError, isIP, randomStr, getUTCDate, formatTimestamp, throttle } = require('./tools') const { getNetIPInfo, throwError, isIP, randomStr, getUTCDate, formatTimestamp, shellThrottle } = require('./tools')
const { emailTransporter, sendEmailToConfList } = require('./email') const { emailTransporter, sendEmailToConfList } = require('./email')
module.exports = { module.exports = {
@ -31,7 +31,7 @@ module.exports = {
randomStr, randomStr,
getUTCDate, getUTCDate,
formatTimestamp, formatTimestamp,
throttle, shellThrottle,
verifyAuthSync, verifyAuthSync,
isProd, isProd,
RSADecryptSync, RSADecryptSync,

View File

@ -204,40 +204,25 @@ function resolvePath(dir, path) {
return path.resolve(dir, path) return path.resolve(dir, path)
} }
function throttle(func, limit) { let shellThrottle = (fn, delay = 1000) => {
let lastFunc let timer = null
let lastRan let args = null
let pendingArgs = null function throttled() {
args = arguments
const runner = () => { if (!timer) {
func.apply(this, pendingArgs) timer = setTimeout(() => {
lastRan = Date.now() fn(...args)
pendingArgs = null timer = null
} }, delay)
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))
} }
} }
function delayMs() {
throttled.flush = () => { return new Promise(resolve => setTimeout(resolve, delay))
if (pendingArgs) { }
runner.apply(this, pendingArgs) throttled.last = async () => {
} await delayMs()
fn(...args)
} }
return throttled return throttled
} }
@ -249,5 +234,5 @@ module.exports = {
getUTCDate, getUTCDate,
formatTimestamp, formatTimestamp,
resolvePath, resolvePath,
throttle shellThrottle
} }

View File

@ -4,8 +4,8 @@
"description": "easynode-server", "description": "easynode-server",
"bin": "./bin/www", "bin": "./bin/www",
"scripts": { "scripts": {
"local": "cross-env EXEC_ENV=local nodemon ./app/index.js --max-old-space-size=4096", "local": "cross-env EXEC_ENV=local nodemon index.js",
"prod": "cross-env EXEC_ENV=production nodemon ./app/index.js", "prod": "cross-env EXEC_ENV=production nodemon index.js",
"start": "node ./index.js", "start": "node ./index.js",
"lint": "eslint . --ext .js,.vue", "lint": "eslint . --ext .js,.vue",
"lint:fix": "eslint . --ext .js,.jsx,.cjs,.mjs --fix" "lint:fix": "eslint . --ext .js,.jsx,.cjs,.mjs --fix"

View File

@ -61,35 +61,31 @@ const useStore = defineStore({
// console.log('scriptList:', scriptList) // console.log('scriptList:', scriptList)
this.$patch({ scriptList }) this.$patch({ scriptList })
}, },
getHostPing() { // getHostPing() {
setTimeout(() => { // setInterval(() => {
this.hostList.forEach((item) => { // this.hostList.forEach((item) => {
const { host } = item // const { host } = item
ping(`http://${ host }:${ this.$clientPort }`) // ping(`http://${ host }:${ this.$clientPort }`)
.then((res) => { // .then((res) => {
item.ping = res // item.ping = res
}) // })
}) // })
// console.clear() // }, 2000)
// console.warn('Please tick \'Preserve Log\'') // },
}, 1500)
},
async wsHostStatus() { async wsHostStatus() {
if (this.HostStatusSocket) this.HostStatusSocket.close() if (this.HostStatusSocket) this.HostStatusSocket.close()
let socketInstance = io(this.serviceURI, { let socketInstance = io(this.serviceURI, {
path: '/clients', path: '/clients',
forceNew: true, forceNew: true,
reconnectionDelay: 5000, reconnectionDelay: 5000,
reconnectionAttempts: 2 reconnectionAttempts: 3
}) })
this.HostStatusSocket = socketInstance this.HostStatusSocket = socketInstance
socketInstance.on('connect', () => { socketInstance.on('connect', () => {
let flag = 5
console.log('clients websocket 已连接: ', socketInstance.id) console.log('clients websocket 已连接: ', socketInstance.id)
let token = this.token let token = this.token
socketInstance.emit('init_clients_data', { token }) socketInstance.emit('init_clients_data', { token })
socketInstance.on('clients_data', (data) => { socketInstance.on('clients_data', (data) => {
if ((flag++ % 5) === 0) this.getHostPing()
this.hostList.forEach(item => { this.hostList.forEach(item => {
const { host } = item const { host } = item
if (data[host] === null) return { ...item } if (data[host] === null) return { ...item }

View File

@ -36,15 +36,15 @@
> >
<el-form-item label="凭证名称" prop="name"> <el-form-item label="凭证名称" prop="name">
<el-input <el-input
v-model.trim="sshForm.name" v-model="sshForm.name"
clearable clearable
placeholder="" placeholder=""
autocomplete="off" autocomplete="off"
/> />
</el-form-item> </el-form-item>
<el-form-item label="认证方式" prop="type"> <el-form-item label="认证方式" prop="type">
<el-radio v-model.trim="sshForm.authType" value="privateKey">密钥</el-radio> <el-radio v-model="sshForm.authType" value="privateKey">密钥</el-radio>
<el-radio v-model.trim="sshForm.authType" value="password">密码</el-radio> <el-radio v-model="sshForm.authType" value="password">密码</el-radio>
</el-form-item> </el-form-item>
<el-form-item v-if="sshForm.authType === 'privateKey'" prop="privateKey" label="密钥"> <el-form-item v-if="sshForm.authType === 'privateKey'" prop="privateKey" label="密钥">
<el-button type="primary" size="small" @click="handleClickUploadBtn"> <el-button type="primary" size="small" @click="handleClickUploadBtn">
@ -58,7 +58,7 @@
@change="handleSelectPrivateKeyFile" @change="handleSelectPrivateKeyFile"
> >
<el-input <el-input
v-model.trim="sshForm.privateKey" v-model="sshForm.privateKey"
type="textarea" type="textarea"
:rows="5" :rows="5"
clearable clearable

View File

@ -54,7 +54,7 @@
> >
<el-form-item label="分组名称" prop="name"> <el-form-item label="分组名称" prop="name">
<el-input <el-input
v-model.trim="groupForm.name" v-model="groupForm.name"
clearable clearable
placeholder="" placeholder=""
autocomplete="off" autocomplete="off"

View File

@ -1,10 +1,20 @@
<template> <template>
<div class="onekey_container"> <div class="onekey_container">
<div class="header"> <div class="header">
<el-button type="primary" @click="addOnekey"> <el-button
批量下发指令 type="primary"
:disabled="isExecuting"
:loading="isExecuting"
@click="addOnekey"
>
{{ isExecuting ? `执行中,剩余${timeRemaining}` : '批量下发指令' }}
</el-button> </el-button>
<el-button v-show="recordList.length" type="danger" @click="handleRemoveAll"> <el-button
v-show="recordList.length"
:disabled="isExecuting"
type="danger"
@click="handleRemoveAll"
>
删除全部记录 删除全部记录
</el-button> </el-button>
</div> </div>
@ -28,7 +38,7 @@
<span style="letter-spacing: 2px;"> {{ row.host }} </span> <span style="letter-spacing: 2px;"> {{ row.host }} </span>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column prop="command" label="指令"> <el-table-column prop="command" label="指令" show-overflow-tooltip>
<template #default="{ row }"> <template #default="{ row }">
<span style="letter-spacing: 2px;background: rgba(227, 230, 235, 0.7);color: rgb(54, 52, 52);"> {{ row.command }} </span> <span style="letter-spacing: 2px;background: rgba(227, 230, 235, 0.7);color: rgb(54, 52, 52);"> {{ row.command }} </span>
</template> </template>
@ -125,7 +135,7 @@
:rows="5" :rows="5"
clearable clearable
autocomplete="off" autocomplete="off"
placeholder="shell script" placeholder="shell script, ex: ping -c 10 google.com"
/> />
</div> </div>
</el-form-item> </el-form-item>
@ -166,17 +176,21 @@ let penddingRecord = ref([])
let checkAll = ref(false) let checkAll = ref(false)
let indeterminate = ref(false) let indeterminate = ref(false)
const updateFormRef = ref(null) const updateFormRef = ref(null)
let timeRemaining = ref(0)
const isClient = ref(false)
let formData = reactive({ let formData = reactive({
hosts: [], hosts: [],
command: 'ping -c 10 google.com', command: '',
timeout: 60 timeout: 120
}) })
const token = computed(() => $store.token) const token = computed(() => $store.token)
const hostList = computed(() => $store.hostList) const hostList = computed(() => $store.hostList)
let scriptList = computed(() => $store.scriptList) let scriptList = computed(() => $store.scriptList)
let isExecuting = computed(() => timeRemaining.value > 0)
const hasConfigHostList = computed(() => hostList.value.filter(item => item.isConfig)) const hasConfigHostList = computed(() => hostList.value.filter(item => item.isConfig))
const tableData = computed(() => { const tableData = computed(() => {
return penddingRecord.value.concat(recordList.value).map(item => { return penddingRecord.value.concat(recordList.value).map(item => {
item.loading = false item.loading = false
@ -210,12 +224,17 @@ watch(() => formData.hosts, (val) => {
const createExecShell = (hosts = [], command = 'ls', timeout = 60) => { const createExecShell = (hosts = [], command = 'ls', timeout = 60) => {
loading.value = true loading.value = true
timeRemaining.value = Number(formData.timeout)
let timer = null
socket.value = io($serviceURI, { socket.value = io($serviceURI, {
path: '/onekey', path: '/onekey',
forceNew: false, forceNew: false,
reconnectionAttempts: 1 reconnectionAttempts: 1
}) })
socket.value.on('connect', () => { socket.value.on('connect', () => {
timer = setInterval(() => {
timeRemaining.value -= 1
}, 1000)
console.log('onekey socket已连接', socket.value.id) console.log('onekey socket已连接', socket.value.id)
socket.value.on('ready', () => { socket.value.on('ready', () => {
@ -274,6 +293,10 @@ const createExecShell = (hosts = [], command = 'ls', timeout = 60) => {
socket.value.on('disconnect', () => { socket.value.on('disconnect', () => {
loading.value = false loading.value = false
timeRemaining.value = 0
if (isClient.value) $store.getHostList() // /host
isClient.value = false
clearInterval(timer)
console.warn('onekey websocket 连接断开') console.warn('onekey websocket 连接断开')
}) })
@ -302,6 +325,7 @@ let selectAllHost = (val) => {
} }
let handleImportScript = (scriptObj) => { let handleImportScript = (scriptObj) => {
isClient.value = scriptObj.id.startsWith('client')
formData.command = scriptObj.content formData.command = scriptObj.content
} }

View File

@ -34,7 +34,7 @@
> >
<el-form-item label="名称" prop="name"> <el-form-item label="名称" prop="name">
<el-input <el-input
v-model.trim="formData.name" v-model="formData.name"
clearable clearable
placeholder="" placeholder=""
autocomplete="off" autocomplete="off"
@ -42,7 +42,7 @@
</el-form-item> </el-form-item>
<el-form-item label="备注" prop="remark"> <el-form-item label="备注" prop="remark">
<el-input <el-input
v-model.trim="formData.remark" v-model="formData.remark"
clearable clearable
placeholder="" placeholder=""
autocomplete="off" autocomplete="off"
@ -58,7 +58,7 @@
</el-form-item> </el-form-item>
<el-form-item prop="content" label="内容"> <el-form-item prop="content" label="内容">
<el-input <el-input
v-model.trim="formData.content" v-model="formData.content"
type="textarea" type="textarea"
:rows="5" :rows="5"
clearable clearable

View File

@ -32,7 +32,7 @@
</el-form-item> </el-form-item>
<el-form-item key="name" label="名称" prop="name"> <el-form-item key="name" label="名称" prop="name">
<el-input <el-input
v-model.trim="hostForm.name" v-model="hostForm.name"
clearable clearable
placeholder="" placeholder=""
autocomplete="off" autocomplete="off"
@ -79,9 +79,9 @@
</el-autocomplete> </el-autocomplete>
</el-form-item> </el-form-item>
<el-form-item key="authType" label="认证方式" prop="authType"> <el-form-item key="authType" label="认证方式" prop="authType">
<el-radio v-model.trim="hostForm.authType" value="privateKey">密钥</el-radio> <el-radio v-model="hostForm.authType" value="privateKey">密钥</el-radio>
<el-radio v-model.trim="hostForm.authType" value="password">密码</el-radio> <el-radio v-model="hostForm.authType" value="password">密码</el-radio>
<el-radio v-model.trim="hostForm.authType" value="credential">凭据</el-radio> <el-radio v-model="hostForm.authType" value="credential">凭据</el-radio>
</el-form-item> </el-form-item>
<el-form-item <el-form-item
v-if="hostForm.authType === 'privateKey'" v-if="hostForm.authType === 'privateKey'"
@ -103,7 +103,7 @@
@change="handleSelectPrivateKeyFile" @change="handleSelectPrivateKeyFile"
> >
<el-input <el-input
v-model.trim="hostForm.privateKey" v-model="hostForm.privateKey"
type="textarea" type="textarea"
:rows="5" :rows="5"
clearable clearable
@ -206,7 +206,7 @@
</el-form-item> </el-form-item>
<el-form-item key="remark" label="备注" prop="remark"> <el-form-item key="remark" label="备注" prop="remark">
<el-input <el-input
v-model.trim="hostForm.remark" v-model="hostForm.remark"
type="textarea" type="textarea"
:rows="3" :rows="3"
clearable clearable

View File

@ -25,15 +25,14 @@
<!-- <div size="small">{{ ipInfo.country || '--' }} {{ ipInfo.regionName }} {{ ipInfo.city }}</div> --> <!-- <div size="small">{{ ipInfo.country || '--' }} {{ ipInfo.regionName }} {{ ipInfo.city }}</div> -->
<div size="small">{{ ipInfo.country || '--' }} {{ ipInfo.regionName }}</div> <div size="small">{{ ipInfo.country || '--' }} {{ ipInfo.regionName }}</div>
</el-descriptions-item> </el-descriptions-item>
<el-descriptions-item> <!-- <el-descriptions-item>
<template #label> <template #label>
<div class="item-title"> <div class="item-title">
延迟 延迟
</div> </div>
</template> </template>
<span style="margin-right: 10px;" class="host-ping">{{ ping }}</span> <span style="margin-right: 10px;" class="host-ping">{{ ping }}</span>
<!-- <span>(http)</span> --> </el-descriptions-item> -->
</el-descriptions-item>
</el-descriptions> </el-descriptions>
<!-- <el-divider content-position="center">实时监控</el-divider> --> <!-- <el-divider content-position="center">实时监控</el-divider> -->