支持服务器批量指令下发

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
return new Promise(async (resolve) => {
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 clientUninstallScript = 'wget https://mirror.ghproxy.com/https://raw.githubusercontent.com/chaos-zhu/easynode/main/client/easynode-client-uninstall.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 && sh easynode-client-uninstall.sh'
let clientVersion = process.env.CLIENT_VERSION
consola.info('客户端版本:', clientVersion)
let installId = `clientInstall${ clientVersion }`
@ -162,12 +162,18 @@ function initScriptsDB() {
let isClientUninstall = scriptList?.find(script => script._id = uninstallId)
let writeFlag = false
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
} else {
console.info('客户端安装脚本已存在')
}
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
} else {
console.info('客户端卸载脚本已存在')
}
if (writeFlag) await writeScriptList(scriptList)
resolve()

View File

@ -1,6 +1,6 @@
const { Server } = require('socket.io')
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 = {
connecting: '连接中',
@ -27,10 +27,17 @@ function disconnectAllExecClient() {
}
function execShell(socket, sshClient, curRes, resolve) {
const throttledDataHandler = throttle((data) => {
curRes.status = execStatusEnum.executing
curRes.result += data?.toString() || ''
const throttledDataHandler = shellThrottle(() => {
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) // 防止内存爆破
sshClient.exec(curRes.command, function(err, stream) {
if (err) {
@ -41,8 +48,9 @@ function execShell(socket, sshClient, curRes, resolve) {
return
}
stream
.on('close', () => {
throttledDataHandler.flush()
.on('close', async () => {
// ssh连接关闭后再执行一次输出防止最后一次节流函数发生在延迟时间内导致终端的输出数据丢失
await throttledDataHandler.last() // 等待最后一次节流函数执行完成,再执行一次数据输出
// console.log('onekey终端执行完成, 关闭连接: ', curRes.host)
if (curRes.status === execStatusEnum.executing) {
curRes.status = execStatusEnum.execSuccess
@ -53,16 +61,16 @@ function execShell(socket, sshClient, curRes, resolve) {
})
.on('data', (data) => {
// console.log(curRes.host, '执行中: \n' + data)
// curRes.status = execStatusEnum.executing
// curRes.result += data.toString()
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()
curRes.status = execStatusEnum.executing
curRes.result += data.toString()
// socket.emit('output', execResult)
throttledDataHandler(data)
})
@ -147,6 +155,7 @@ module.exports = (httpServer) => {
consola.error('onekey终端连接失败:', err.level)
curRes.status = execStatusEnum.connectFail
curRes.result += err.message
socket.emit('output', execResult)
resolve(curRes)
})
.connect({
@ -157,6 +166,7 @@ module.exports = (httpServer) => {
consola.error('创建终端错误:', err.message)
curRes.status = execStatusEnum.connectFail
curRes.result += err.message
socket.emit('output', execResult)
resolve(curRes)
}
})

View File

@ -21,7 +21,7 @@ const {
} = require('./storage')
const { RSADecryptSync, AESEncryptSync, AESDecryptSync, SHA1Encrypt } = require('./encrypt')
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')
module.exports = {
@ -31,7 +31,7 @@ module.exports = {
randomStr,
getUTCDate,
formatTimestamp,
throttle,
shellThrottle,
verifyAuthSync,
isProd,
RSADecryptSync,

View File

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

View File

@ -4,8 +4,8 @@
"description": "easynode-server",
"bin": "./bin/www",
"scripts": {
"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",
"local": "cross-env EXEC_ENV=local nodemon index.js",
"prod": "cross-env EXEC_ENV=production nodemon index.js",
"start": "node ./index.js",
"lint": "eslint . --ext .js,.vue",
"lint:fix": "eslint . --ext .js,.jsx,.cjs,.mjs --fix"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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