✨ 支持服务器批量指令下发
This commit is contained in:
parent
22c4e2cd46
commit
7513825d28
@ -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()
|
||||
|
@ -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)
|
||||
}
|
||||
})
|
||||
|
@ -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,
|
||||
|
@ -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
|
||||
}
|
@ -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"
|
||||
|
@ -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 }
|
||||
|
@ -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
|
||||
|
@ -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"
|
||||
|
@ -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
|
||||
}
|
||||
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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> -->
|
||||
|
Loading…
x
Reference in New Issue
Block a user