diff --git a/.gitignore b/.gitignore index 4790d71..cd65b26 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,6 @@ server/app/db/* plan.md .env .env.local +.env-encrypt-key +*clear.js +local-script \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index e3fcb17..2c265b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,14 @@ +## [3.0.0](https://github.com/chaos-zhu/easynode/releases) (2024-11-09) + +* 新增跳板机功能,支持选择多台机器跳转 +* 脚本库批量导出导入 +* 本地socket断开自动重连,无需手动重新连接 +* 支持脚本库模糊搜索功能 +* 分组添加实例数量标识 +* 优化登录逻辑 +* 默认登录有效期更改为当天有效 +* 修复一些小bug + ## [2.3.0](https://github.com/chaos-zhu/easynode/releases) (2024-10-24) * 重构本地数据库存储方式(性能提升一个level~) diff --git a/README.md b/README.md index 86307f2..8f34abe 100644 --- a/README.md +++ b/README.md @@ -1,29 +1,43 @@ +
+ # EasyNode - +_✨ 一个多功能Linux服务器WEB终端面板(webSSH&webSFTP) ✨_ + +
+ +

+ 功能 + · + 动图展示 + · + 项目部署 + · + 监控服务安装 + · + 安全与建议 + · + 常见问题 + +

## 功能 -- [x] 功能完善的**SSH终端**&**SFTP** -- [x] 批量**导入导出**实例(Xshell&FinalShell&EasyNode) -- [x] **实例分组** -- [x] **凭据托管** -- [x] **多渠道通知** -- [x] **脚本库** -- [x] **批量指令** -- [x] **终端主题背景自定义** ++ [x] 功能完善的**SSH终端**&**SFTP** ++ [x] 批量导入、导出、编辑服务器配置、脚本等 ++ [x] 脚本库 ++ [x] 实例分组 ++ [x] 凭据托管 ++ [x] 多渠道通知 ++ [x] 批量下发指令 ++ [x] 自定义终端主题 + +## 动图展示 ![实例面板](./doc_images/merge.gif) -## 安装 +## 项目部署 - 默认账户密码 `admin/admin` - web端口:8082 @@ -31,34 +45,17 @@ ### docker镜像 ```shell -docker run -d -p 8082:8082 --name=easynode --restart=always -v /root/easynode/db:/easynode/app/db chaoszhu/easynode +docker run -d -p 8082:8082 --restart=always -v /root/easynode/db:/easynode/app/db chaoszhu/easynode ``` 环境变量: +- `PLUS_KEY`: 激活PLUS功能的授权码 - `DEBUG`: 启动debug日志 0:关闭 1:开启, 默认关闭 - `ALLOWED_IPS`: 可以访问服务的IP白名单, 多个使用逗号分隔, 支持填写部分ip前缀, 例如: `-e ALLOWED_IPS=127.0.0.1,196.168` -### 手动部署 - -依赖Nodejs版本 > 20+ - -```shell -git clone https://github.com/chaos-zhu/easynode -cd easynode -yarn -cd web -yarn build -mv dist/* ../server/app/static -cd ../server -yarn start -# 后台运行需安装pm2 -pm2 start index.js --name easynode-server -``` - ---- ## 监控服务安装 -- 监控服务用于实时向服务端推送**系统、公网IP、CPU、内存、硬盘、网卡**等基础信息,不安装不影响使用面板,但是无法实时同步cpu占用、实时网速、硬盘容量等实用信息。 +- 监控服务用于实时向服务端&web端推送**系统、公网IP、CPU、内存、硬盘、网卡**等基础信息 - 默认端口:**22022** @@ -86,20 +83,6 @@ curl -o- https://ghp.ci/https://raw.githubusercontent.com/chaos-zhu/easynode/mai --- -## 开发 - -1. 拉取代码,环境 `nodejs>=20` -2. cd到项目根目录,`yarn install` 执行安装依赖 -3. `yarn dev`启动项目 -4. web: `http://localhost:18090/` - -## 版本日志 - -- [CHANGELOG](./CHANGELOG.md) - -## QA - -- [QA](./Q%26A.md) ## 安全与建议 @@ -110,12 +93,17 @@ curl -o- https://ghp.ci/https://raw.githubusercontent.com/chaos-zhu/easynode/mai webssh与监控服务都将以`该服务器作为中转`。中国大陆用户建议使用香港、新加坡、日本、韩国等地区的低延迟服务器来安装服务端面板 -## 捐赠 +## 常见问题 -如果您认为此项目帮到了您, 您可以请我喝杯阔乐~ +- [QA](./Q%26A.md) -![wx](./doc_images/wx.jpg) +![3.0.0访问数](https://profile-counter.glitch.me/easynode/3.0.0.count.svg) -## License + diff --git a/package.json b/package.json index 06ad815..c0b9c33 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,8 @@ "license": "ISC", "scripts": { "dev": "concurrently \"yarn workspace web run dev\" \"yarn workspace server run local\"", - "clean": "rimraf web/node_modules server/node_modules client/node_modules node_modules" + "clean": "rimraf web/node_modules server/node_modules client/node_modules node_modules", + "encrypt": "node ./local-script/encrypt-file.js" }, "bugs": { "url": "https://github.com/chaos-zhu/easynode/issues" diff --git a/server/.env.template b/server/.env.template index 2aa3f6b..3b6d98b 100644 --- a/server/.env.template +++ b/server/.env.template @@ -2,4 +2,7 @@ DEBUG=1 # 访问IP限制 -allowedIPs=['127.0.0.1'] \ No newline at end of file +allowedIPs=['127.0.0.1'] + +# 激活PLUS功能的授权码 +PLUS_KEY= diff --git a/server/app/config/index.js b/server/app/config/index.js index 173d291..0c33ec7 100644 --- a/server/app/config/index.js +++ b/server/app/config/index.js @@ -17,6 +17,7 @@ module.exports = { notifyConfigDBPath: path.join(process.cwd(),'app/db/notify-config.db'), onekeyDBPath: path.join(process.cwd(),'app/db/onekey.db'), logDBPath: path.join(process.cwd(),'app/db/log.db'), + plusDBPath: path.join(process.cwd(),'app/db/plus.db'), apiPrefix: '/api/v1', logConfig: { outDir: path.join(process.cwd(),'./app/logs'), diff --git a/server/app/controller/host.js b/server/app/controller/host.js index 193df60..4bfa179 100644 --- a/server/app/controller/host.js +++ b/server/app/controller/host.js @@ -1,17 +1,17 @@ +const path = require('path') +const decryptAndExecuteAsync = require('../utils/decrypt-file') const { RSADecryptAsync, AESEncryptAsync, AESDecryptAsync } = require('../utils/encrypt') const { HostListDB } = require('../utils/db-class') const hostListDB = new HostListDB().getInstance() async function getHostList({ res }) { - // console.log('get-host-list') let data = await hostListDB.findAsync({}) data?.sort((a, b) => Number(b.index || 0) - Number(a.index || 0)) for (const item of data) { try { - let { username, port, authType, _id: id, credential } = item - // console.log('解密凭证title: ', credential) + let { authType, _id: id, credential } = item if (credential) credential = await AESDecryptAsync(credential) - const isConfig = Boolean(username && port && (item[authType])) + const isConfig = Boolean(authType && item[authType]) Object.assign(item, { id, isConfig, password: '', privateKey: '', credential }) } catch (error) { consola.error('getHostList error: ', error.message) @@ -21,94 +21,55 @@ async function getHostList({ res }) { } async function addHost({ res, request }) { - let { - body: { - name, host, index, expired, expiredNotify, group, consoleUrl, remark, - port: newPort, clientPort, username, authType, password, privateKey, credential, command, tempKey - } - } = request - // console.log(request) - if (!host || !name) return res.fail({ msg: 'missing params: name or host' }) - let record = { - name, host, index, expired, expiredNotify, group, consoleUrl, remark, - port: newPort, clientPort, username, authType, password, privateKey, credential, command - } - if (record[authType]) { + let { body } = request + if (!body.name || !body.host) return res.fail({ msg: 'missing params: name or host' }) + let newRecord = { ...body } + const { authType, tempKey } = newRecord + if (newRecord[authType] && tempKey) { const clearTempKey = await RSADecryptAsync(tempKey) - console.log('clearTempKey:', clearTempKey) - const clearSSHKey = await AESDecryptAsync(record[authType], clearTempKey) - console.log(`${ authType }原密文: `, clearSSHKey) - record[authType] = await AESEncryptAsync(clearSSHKey) - // console.log(`${ authType }__commonKey加密存储: `, record[authType]) + const clearSSHKey = await AESDecryptAsync(newRecord[authType], clearTempKey) + newRecord[authType] = await AESEncryptAsync(clearSSHKey) } - await hostListDB.insertAsync(record) + await hostListDB.insertAsync(newRecord) res.success() } async function updateHost({ res, request }) { let { - body: { - hosts, - id, - host: newHost, name: newName, index, oldHost, expired, expiredNotify, group, consoleUrl, remark, - port, clientPort, username, authType, password, privateKey, credential, command, tempKey - } + body } = request - let isBatch = Array.isArray(hosts) - if (isBatch) { - if (!hosts.length) return res.fail({ msg: 'hosts为空' }) - let hostList = await hostListDB.findAsync({}) - for (let oldRecord of hostList) { - let target = hosts.find(item => item.id === oldRecord._id) - if (!target) continue - let { authType } = target - // 如果存在原认证方式则保存下来 - if (!target[authType]) { - target[authType] = oldRecord[authType] - } else { - const clearTempKey = await RSADecryptAsync(target.tempKey) - // console.log('批量解密tempKey:', clearTempKey) - const clearSSHKey = await AESDecryptAsync(target[authType], clearTempKey) - // console.log(`${ authType }原密文: `, clearSSHKey) - target[authType] = await AESEncryptAsync(clearSSHKey) - // console.log(`${ authType }__commonKey加密存储: `, target[authType]) - } - delete target._id - delete target.monitorData - delete target.tempKey - Object.assign(oldRecord, target) - await hostListDB.updateAsync({ _id: oldRecord._id }, oldRecord) - } - return res.success({ msg: '批量修改成功' }) - } - if (!newHost || !newName || !oldHost) return res.fail({ msg: '参数错误' }) - - let updateRecord = { - name: newName, host: newHost, index, expired, expiredNotify, group, consoleUrl, remark, - port, clientPort, username, authType, password, privateKey, credential, command - } - - let oldRecord = await hostListDB.findOneAsync({ _id: id }) - // 如果存在原认证方式则保存下来 - if (!updateRecord[authType] && oldRecord[authType]) { - updateRecord[authType] = oldRecord[authType] - } else { + if (typeof body !== 'object') return res.fail({ msg: '参数错误' }) + const updateFiled = { ...body } + const { id, authType, tempKey } = updateFiled + if (authType && updateFiled[authType]) { const clearTempKey = await RSADecryptAsync(tempKey) - // console.log('clearTempKey:', clearTempKey) - const clearSSHKey = await AESDecryptAsync(updateRecord[authType], clearTempKey) - // console.log(`${ authType }原密文: `, clearSSHKey) - updateRecord[authType] = await AESEncryptAsync(clearSSHKey) - // console.log(`${ authType }__commonKey加密存储: `, updateRecord[authType]) + const clearSSHKey = await AESDecryptAsync(updateFiled[authType], clearTempKey) + updateFiled[authType] = await AESEncryptAsync(clearSSHKey) + delete updateFiled.tempKey + } else { + delete updateFiled.authType + delete updateFiled.password + delete updateFiled.privateKey + delete updateFiled.credential } - await hostListDB.updateAsync({ _id: oldRecord._id }, updateRecord) + console.log('updateFiled: ', updateFiled) + await hostListDB.updateAsync({ _id: id }, { $set: { ...updateFiled } }) res.success({ msg: '修改成功' }) } +async function batchUpdateHost({ res, request }) { + let { updateHosts } = (await decryptAndExecuteAsync(path.join(__dirname, 'plus.js'))) || {} + if (updateHosts) { + await updateHosts({ res, request }) + } else { + return res.fail({ data: false, msg: 'Plus专属功能!' }) + } +} + async function removeHost({ res, request }) { let { body: { ids } } = request if (!Array.isArray(ids)) return res.fail({ msg: '参数错误' }) const numRemoved = await hostListDB.removeAsync({ _id: { $in: ids } }, { multi: true }) - // console.log('numRemoved: ', numRemoved) res.success({ data: `已移除,数量: ${ numRemoved }` }) } @@ -150,5 +111,6 @@ module.exports = { addHost, updateHost, removeHost, - importHost + importHost, + batchUpdateHost } diff --git a/server/app/controller/plus.js b/server/app/controller/plus.js new file mode 100644 index 0000000..d0e0075 --- /dev/null +++ b/server/app/controller/plus.js @@ -0,0 +1 @@ +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 \ No newline at end of file diff --git a/server/app/controller/scripts.js b/server/app/controller/scripts.js index 7993df3..68c770f 100644 --- a/server/app/controller/scripts.js +++ b/server/app/controller/scripts.js @@ -1,6 +1,8 @@ -const localShellJson = require('../config/shell.json') +const path = require('path') +const decryptAndExecuteAsync = require('../utils/decrypt-file') const { randomStr } = require('../utils/tools') const { ScriptsDB } = require('../utils/db-class') +const localShellJson = require('../config/shell.json') const scriptsDB = new ScriptsDB().getInstance() let localShell = JSON.parse(JSON.stringify(localShellJson)).map((item) => { @@ -44,10 +46,28 @@ const removeScript = async ({ res, request }) => { res.success({ data: '移除成功' }) } +const batchRemoveScript = async ({ res, request }) => { + let { body: { ids } } = request + if (!Array.isArray(ids)) return res.fail({ msg: '参数错误' }) + const numRemoved = await scriptsDB.removeAsync({ _id: { $in: ids } }, { multi: true }) + res.success({ data: `批量移除成功,数量: ${ numRemoved }` }) +} + +const importScript = async ({ res, request }) => { + let { impScript } = (await decryptAndExecuteAsync(path.join(__dirname, 'plus.js'))) || {} + if (impScript) { + await impScript({ res, request }) + } else { + return res.fail({ data: false, msg: 'Plus专属功能!' }) + } +} + module.exports = { addScript, getScriptList, getLocalScriptList, updateScriptList, - removeScript + removeScript, + batchRemoveScript, + importScript } diff --git a/server/app/controller/ssh.js b/server/app/controller/ssh.js index 4a794c6..19d648d 100644 --- a/server/app/controller/ssh.js +++ b/server/app/controller/ssh.js @@ -1,10 +1,11 @@ +const path = require('path') const { RSADecryptAsync, AESEncryptAsync, AESDecryptAsync } = require('../utils/encrypt') const { HostListDB, CredentialsDB } = require('../utils/db-class') +const decryptAndExecuteAsync = require('../utils/decrypt-file') const hostListDB = new HostListDB().getInstance() const credentialsDB = new CredentialsDB().getInstance() async function getSSHList({ res }) { - // console.log('get-host-list') let data = await credentialsDB.findAsync({}) data = data?.map(item => { const { name, authType, _id: id, date } = item @@ -83,10 +84,19 @@ const getCommand = async ({ res, request }) => { let hostInfo = await hostListDB.findAsync({}) let record = hostInfo?.find(item => item._id === hostId) consola.info('查询登录后执行的指令:', hostId) - if (!record) return res.fail({ data: false, msg: 'host not found' }) // host不存在 + if (!record) return res.fail({ data: false, msg: 'host not found' }) const { command } = record - if (!command) return res.success({ data: false }) // command不存在 - res.success({ data: command }) // 存在 + if (!command) return res.success({ data: false }) + res.success({ data: command }) +} + +const decryptPrivateKey = async ({ res, request }) => { + let { dePrivateKey } = (await decryptAndExecuteAsync(path.join(__dirname, 'plus.js'))) || {} + if (dePrivateKey) { + await dePrivateKey({ res, request }) + } else { + return res.fail({ data: false, msg: 'Plus专属功能,无法解密私钥!' }) + } } module.exports = { @@ -94,5 +104,6 @@ module.exports = { addSSH, updateSSH, removeSSH, - getCommand + getCommand, + decryptPrivateKey } diff --git a/server/app/controller/user.js b/server/app/controller/user.js index efc8af7..6a673e4 100644 --- a/server/app/controller/user.js +++ b/server/app/controller/user.js @@ -5,9 +5,10 @@ const QRCode = require('qrcode') const { sendNoticeAsync } = require('../utils/notify') const { RSADecryptAsync, AESEncryptAsync, SHA1Encrypt } = require('../utils/encrypt') const { getNetIPInfo } = require('../utils/tools') -const { KeyDB, LogDB } = require('../utils/db-class') +const { KeyDB, LogDB, PlusDB } = require('../utils/db-class') const keyDB = new KeyDB().getInstance() const logDB = new LogDB().getInstance() +const plusDB = new PlusDB().getInstance() const getpublicKey = async ({ res }) => { let { publicKey: data } = await keyDB.findOneAsync({}) @@ -164,6 +165,13 @@ const disableMFA2 = async ({ res }) => { res.success({ msg: 'success' }) } +const getPlusInfo = async ({ res }) => { + let data = await plusDB.findOneAsync({}) + delete data?._id + delete data?.decryptKey + res.success({ data, msg: 'success' }) +} + module.exports = { login, getpublicKey, @@ -172,5 +180,6 @@ module.exports = { getMFA2Status, getMFA2Code, enableMFA2, - disableMFA2 + disableMFA2, + getPlusInfo } diff --git a/server/app/main.js b/server/app/main.js index 976ea78..fc1dc6c 100644 --- a/server/app/main.js +++ b/server/app/main.js @@ -1,13 +1,13 @@ -const consola = require('consola') -global.consola = consola const { httpServer } = require('./server') const initDB = require('./db') const scheduleJob = require('./schedule') +const getLicenseInfo = require('./utils/get-plus') async function main() { await initDB() httpServer() scheduleJob() + getLicenseInfo() } main() diff --git a/server/app/middlewares/log4.js b/server/app/middlewares/log4.js index 282b7b1..aa3c320 100644 --- a/server/app/middlewares/log4.js +++ b/server/app/middlewares/log4.js @@ -3,27 +3,28 @@ const { outDir, recordLog } = require('../config').logConfig log4js.configure({ appenders: { - // 控制台输出 - out: { + console: { type: 'stdout', layout: { - type: 'colored' + type: 'pattern', + pattern: '%[%d{yyyy-MM-dd hh:mm:ss.SSS} [%p] -%] %m' } }, - // 保存日志文件 cheese: { type: 'file', - maxLogSize: 512*1024, // unit: bytes 1KB = 1024bytes - filename: `${ outDir }/receive.log` + maxLogSize: 10 * 1024 * 1024, // unit: bytes 1KB = 1024bytes + filename: `${ outDir }/receive.log`, + backups: 10, + compress: true, + keepFileExt: true } }, categories: { default: { - appenders: [ 'out', 'cheese' ], // 配置 - level: 'info' // 只输出info以上级别的日志 + appenders: ['console', 'cheese'], + level: 'debug' } } - // pm2: true }) const logger = log4js.getLogger() @@ -55,4 +56,7 @@ const useLog = () => { } } -module.exports = useLog() \ No newline at end of file +module.exports = useLog() + +// 可以先测试一下日志是否正常工作 +logger.info('日志系统启动') \ No newline at end of file diff --git a/server/app/router/routes.js b/server/app/router/routes.js index 2602965..042f34d 100644 --- a/server/app/router/routes.js +++ b/server/app/router/routes.js @@ -1,9 +1,9 @@ -const { getSSHList, addSSH, updateSSH, removeSSH, getCommand } = require('../controller/ssh') -const { getHostList, addHost, updateHost, removeHost, importHost } = require('../controller/host') -const { login, getpublicKey, updatePwd, getEasynodeVersion, getMFA2Status, getMFA2Code, enableMFA2, disableMFA2 } = require('../controller/user') +const { getSSHList, addSSH, updateSSH, removeSSH, getCommand, decryptPrivateKey } = require('../controller/ssh') +const { getHostList, addHost, updateHost, batchUpdateHost, removeHost, importHost } = require('../controller/host') +const { login, getpublicKey, updatePwd, getEasynodeVersion, getMFA2Status, getMFA2Code, enableMFA2, disableMFA2, getPlusInfo } = require('../controller/user') const { getNotifyConfig, updateNotifyConfig, getNotifyList, updateNotifyList } = require('../controller/notify') const { getGroupList, addGroupList, updateGroupList, removeGroup } = require('../controller/group') -const { getScriptList, getLocalScriptList, addScript, updateScriptList, removeScript } = require('../controller/scripts') +const { getScriptList, getLocalScriptList, addScript, updateScriptList, removeScript, batchRemoveScript, importScript } = require('../controller/scripts') const { getOnekeyRecord, removeOnekeyRecord } = require('../controller/onekey') const { getLog } = require('../controller/log') @@ -32,6 +32,11 @@ const ssh = [ method: 'get', path: '/command', controller: getCommand + }, + { + method: 'post', + path: '/decrypt-private-key', + controller: decryptPrivateKey } ] const host = [ @@ -50,6 +55,11 @@ const host = [ path: '/host-save', controller: updateHost }, + { + method: 'put', + path: '/batch-update-host', + controller: batchUpdateHost + }, { method: 'post', path: '/host-remove', @@ -101,6 +111,11 @@ const user = [ method: 'post', path: '/mfa2-disable', controller: disableMFA2 + }, + { + method: 'get', + path: '/plus-info', + controller: getPlusInfo } ] const notify = [ @@ -170,10 +185,20 @@ const scripts = [ path: '/script/:id', controller: removeScript }, + { + method: 'post', + path: '/batch-remove-script', + controller: batchRemoveScript + }, { method: 'put', path: '/script/:id', controller: updateScriptList + }, + { + method: 'post', + path: '/import-script', + controller: importScript } ] diff --git a/server/app/schedule/expired-notify.js b/server/app/schedule/expired-notify.js deleted file mode 100644 index 86c5b37..0000000 --- a/server/app/schedule/expired-notify.js +++ /dev/null @@ -1,33 +0,0 @@ -const schedule = require('node-schedule') -const { sendNoticeAsync } = require('../utils/notify') -const { formatTimestamp } = require('../utils/tools') -const { HostListDB } = require('../utils/db-class') -const hostListDB = new HostListDB().getInstance() - -const expiredNotifyJob = async () => { - consola.info('=====开始检测服务器到期时间=====', new Date()) - const hostList = await hostListDB.findAsync({}) - for (const item of hostList) { - if (!item.expiredNotify) continue - const { host, name, expired, consoleUrl } = item - const restDay = Number(((expired - Date.now()) / (1000 * 60 * 60 * 24)).toFixed(1)) - console.log(Date.now(), restDay) - let title = '服务器到期提醒' - let content = `别名: ${ name }\nIP: ${ host }\n到期时间:${ formatTimestamp(expired, 'week') }\n控制台: ${ consoleUrl || '未填写' }` - if (0 <= restDay && restDay <= 1) { - let temp = '有服务器将在一天后到期,请关注\n' - sendNoticeAsync('host_expired', title, temp + content) - } else if (3 <= restDay && restDay < 4) { - let temp = '有服务器将在三天后到期,请关注\n' - sendNoticeAsync('host_expired', title, temp + content) - } else if (7 <= restDay && restDay < 8) { - let temp = '有服务器将在七天后到期,请关注\n' - sendNoticeAsync('host_expired', title, temp + content) - } - } -} - -module.exports = () => { - // 每天中午12点执行一次。 - schedule.scheduleJob('0 0 12 1/1 * ?', expiredNotifyJob) -} diff --git a/server/app/schedule/index.js b/server/app/schedule/index.js index 60c8347..275dd64 100644 --- a/server/app/schedule/index.js +++ b/server/app/schedule/index.js @@ -1,5 +1,32 @@ -const expiredNotify = require('./expired-notify') +const schedule = require('node-schedule') +const { sendNoticeAsync } = require('../utils/notify') +const { formatTimestamp } = require('../utils/tools') +const { HostListDB } = require('../utils/db-class') +const hostListDB = new HostListDB().getInstance() + +const expiredNotifyJob = async () => { + consola.info('=====开始检测服务器到期时间=====', new Date()) + const hostList = await hostListDB.findAsync({}) + for (const item of hostList) { + if (!item.expiredNotify) continue + const { host, name, expired, consoleUrl } = item + const restDay = Number(((expired - Date.now()) / (1000 * 60 * 60 * 24)).toFixed(1)) + console.log(Date.now(), restDay) + let title = '服务器到期提醒' + let content = `别名: ${ name }\nIP: ${ host }\n到期时间:${ formatTimestamp(expired, 'week') }\n控制台: ${ consoleUrl || '未填写' }` + if (0 <= restDay && restDay <= 1) { + let temp = '有服务器将在一天后到期,请关注\n' + sendNoticeAsync('host_expired', title, temp + content) + } else if (3 <= restDay && restDay < 4) { + let temp = '有服务器将在三天后到期,请关注\n' + sendNoticeAsync('host_expired', title, temp + content) + } else if (7 <= restDay && restDay < 8) { + let temp = '有服务器将在七天后到期,请关注\n' + sendNoticeAsync('host_expired', title, temp + content) + } + } +} module.exports = () => { - expiredNotify() + schedule.scheduleJob('0 0 12 1/1 * ?', expiredNotifyJob) } diff --git a/server/app/socket/plus.js b/server/app/socket/plus.js new file mode 100644 index 0000000..685df39 --- /dev/null +++ b/server/app/socket/plus.js @@ -0,0 +1 @@ +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 \ No newline at end of file diff --git a/server/app/socket/terminal.js b/server/app/socket/terminal.js index 13eb9f2..7b29899 100644 --- a/server/app/socket/terminal.js +++ b/server/app/socket/terminal.js @@ -1,108 +1,101 @@ +const path = require('path') const { Server } = require('socket.io') const { Client: SSHClient } = require('ssh2') const { verifyAuthSync } = require('../utils/verify-auth') -const { AESDecryptAsync } = require('../utils/encrypt') const { sendNoticeAsync } = require('../utils/notify') const { isAllowedIp, ping } = require('../utils/tools') +const { AESDecryptAsync } = require('../utils/encrypt') const { HostListDB, CredentialsDB } = require('../utils/db-class') +const decryptAndExecuteAsync = require('../utils/decrypt-file') const hostListDB = new HostListDB().getInstance() const credentialsDB = new CredentialsDB().getInstance() -function createInteractiveShell(socket, sshClient) { +async function getConnectionOptions(hostId) { + const hostInfo = await hostListDB.findOneAsync({ _id: hostId }) + if (!hostInfo) throw new Error(`Host with ID ${ hostId } not found`) + let { authType, host, port, username, name } = hostInfo + let authInfo = { host, port, username } + try { + if (authType === 'credential') { + let credentialId = await AESDecryptAsync(hostInfo[authType]) + const sshRecord = await credentialsDB.findOneAsync({ _id: credentialId }) + authInfo.authType = sshRecord.authType + authInfo[authInfo.authType] = await AESDecryptAsync(sshRecord[authInfo.authType]) + } else { + authInfo[authType] = await AESDecryptAsync(hostInfo[authType]) + } + return { authInfo, name } + } catch (err) { + throw new Error(`解密认证信息失败: ${ err.message }`) + } +} + +function createInteractiveShell(socket, targetSSHClient) { return new Promise((resolve) => { - sshClient.shell({ term: 'xterm-color' }, (err, stream) => { + targetSSHClient.shell({ term: 'xterm-color' }, (err, stream) => { resolve(stream) if (err) return socket.emit('output', err.toString()) - // 终端输出 stream .on('data', (data) => { socket.emit('output', data.toString()) }) .on('close', () => { consola.info('交互终端已关闭') - sshClient.end() + targetSSHClient.end() }) socket.emit('connect_shell_success') // 已连接终端,web端可以执行指令了 }) }) } -// function execShell(sshClient, command = '', callback) { -// if (!command) return -// let result = '' -// sshClient.exec(`source ~/.bashrc && ${ command }`, (err, stream) => { -// if (err) return callback(err.toString()) -// stream -// .on('data', (data) => { -// result += data.toString() -// }) -// .stderr -// .on('data', (data) => { -// result += data.toString() -// }) -// .on('close', () => { -// consola.info('一次性指令执行完成:', command) -// callback(result) -// }) -// .on('error', (error) => { -// console.log('Error:', error.toString()) -// }) -// }) -// } - -async function createTerminal(hostId, socket, sshClient) { - // eslint-disable-next-line no-async-promise-executor +async function createTerminal(hostId, socket, targetSSHClient) { return new Promise(async (resolve) => { - const hostList = await hostListDB.findAsync({}) - const targetHostInfo = hostList.find(item => item._id === hostId) || {} - let { authType, host, port, username, name } = targetHostInfo - if (!host) return socket.emit('create_fail', `查找hostId【${ hostId }】凭证信息失败`) - let authInfo = { host, port, username } - // 统一使用commonKey解密 + const targetHostInfo = await hostListDB.findOneAsync({ _id: hostId }) + if (!targetHostInfo) return socket.emit('create_fail', `查找hostId【${ hostId }】凭证信息失败`) + let { connectByJumpHosts = null } = (await decryptAndExecuteAsync(path.join(__dirname, 'plus.js'))) || {} + let { authType, host, port, username, name, jumpHosts } = targetHostInfo try { - // 解密放到try里面,防止报错【commonKey必须配对, 否则需要重新添加服务器密钥】 - if (authType === 'credential') { - let credentialId = await AESDecryptAsync(targetHostInfo[authType]) - const sshRecord = await credentialsDB.findOneAsync({ _id: credentialId }) - authInfo.authType = sshRecord.authType - authInfo[authInfo.authType] = await AESDecryptAsync(sshRecord[authInfo.authType]) - } else { - authInfo[authType] = await AESDecryptAsync(targetHostInfo[authType]) + let { authInfo: targetConnectionOptions } = await getConnectionOptions(hostId) + let jumpHostResult = connectByJumpHosts && (await connectByJumpHosts(jumpHosts, targetConnectionOptions.host, targetConnectionOptions.port, socket)) + if (jumpHostResult) { + targetConnectionOptions.sock = jumpHostResult.sock } - consola.info('准备连接终端:', host) - // targetHostInfo[targetHostInfo.authType] = await AESDecryptAsync(targetHostInfo[targetHostInfo.authType]) + + socket.emit('terminal_print_info', `准备连接目标终端: ${ name } - ${ host }`) + socket.emit('terminal_print_info', `连接信息: ssh ${ username }@${ host } -p ${ port } -> ${ authType }`) + + consola.info('准备连接目标终端:', host) consola.log('连接信息', { username, port, authType }) - sshClient - .on('ready', async() => { + let closeNoticeFlag = false // 避免重复发送通知 + targetSSHClient + .on('ready', async () => { sendNoticeAsync('host_login', '终端登录', `别名: ${ name } \n IP:${ host } \n 端口:${ port } \n 状态: 登录成功`) + socket.emit('terminal_print_info', `终端连接成功: ${ name } - ${ host }`) consola.success('终端连接成功:', host) socket.emit('connect_terminal_success', `终端连接成功:${ host }`) - let stream = await createInteractiveShell(socket, sshClient) + let stream = await createInteractiveShell(socket, targetSSHClient) 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: ', host) - socket.emit('connect_close') + .on('close', (err) => { + if (closeNoticeFlag) return closeNoticeFlag = false + const closeReason = err ? '发生错误导致连接断开' : '正常断开连接' + consola.info(`终端连接断开(${ closeReason }): ${ host }`) + socket.emit('connect_close', { reason: closeReason }) }) .on('error', (err) => { - consola.log(err) + closeNoticeFlag = true sendNoticeAsync('host_login', '终端登录', `别名: ${ name } \n IP:${ host } \n 端口:${ port } \n 状态: 登录失败`) consola.error('连接终端失败:', host, err.message) - socket.emit('connect_fail', err.message) + socket.emit('connect_terminal_fail', err.message) }) .connect({ - ...authInfo - // debug: (info) => console.log(info) + ...targetConnectionOptions + // debug: (info) => console.log(info) }) } catch (err) { consola.error('创建终端失败: ', host, err.message) - socket.emit('create_fail', err.message) + socket.emit('create_terminal_fail', err.message) } }) } @@ -111,11 +104,15 @@ module.exports = (httpServer) => { const serverIo = new Server(httpServer, { path: '/terminal', cors: { - origin: '*' // 'http://localhost:8080' + origin: '*' } }) + + let connectionCount = 0 + serverIo.on('connection', (socket) => { - // 前者兼容nginx反代, 后者兼容nodejs自身服务 + connectionCount++ + consola.success(`terminal websocket 已连接 - 当前连接数: ${ connectionCount }`) let requestIP = socket.handshake.headers['x-forwarded-for'] || socket.handshake.address if (!isAllowedIp(requestIP)) { socket.emit('ip_forbidden', 'IP地址不在白名单中') @@ -123,7 +120,7 @@ module.exports = (httpServer) => { return } consola.success('terminal websocket 已连接') - let sshClient = null + let targetSSHClient = null socket.on('create', async ({ hostId, token }) => { const { code } = await verifyAuthSync(token, requestIP) if (code !== 1) { @@ -131,47 +128,21 @@ module.exports = (httpServer) => { socket.disconnect() return } - sshClient = new SSHClient() - - // 尝试手动断开调试,再次连接后终端输出内容为4份相同的输出,导致异常 - // setTimeout(() => { - // sshClient.end() - // }, 3000) + targetSSHClient = new SSHClient() let stream = null - function listenerInput(key) { - if (sshClient._sock.writable === false) return consola.info('终端连接已关闭,禁止输入') + if (targetSSHClient._sock.writable === false) return consola.info('终端连接已关闭,禁止输入') stream && stream.write(key) } function resizeShell({ rows, cols }) { - // consola.info('更改tty终端行&列: ', { rows, cols }) stream && stream.setWindow(rows, cols) } socket.on('input', listenerInput) socket.on('resize', resizeShell) - - // 重连 - socket.on('reconnect_terminal', async () => { - consola.info('重连终端: ', hostId) - socket.off('input', listenerInput) // 取消监听,重新注册监听,操作新的stream - socket.off('resize', resizeShell) - sshClient?.end() - sshClient?.destroy() - sshClient = null - stream = null - setTimeout(async () => { - // 初始化新的SSH客户端对象 - sshClient = new SSHClient() - stream = await createTerminal(hostId, socket, sshClient) - socket.emit('reconnect_terminal_success') - socket.on('input', listenerInput) - socket.on('resize', resizeShell) - }, 3000) - }) - stream = await createTerminal(hostId, socket, sshClient) + stream = await createTerminal(hostId, socket, targetSSHClient) }) - socket.on('get_ping',async (ip) => { + socket.on('get_ping', async (ip) => { try { socket.emit('ping_data', await ping(ip, 2500)) } catch (error) { @@ -180,7 +151,10 @@ module.exports = (httpServer) => { }) socket.on('disconnect', (reason) => { - consola.info('终端socket连接断开:', reason) + connectionCount-- + consola.info(`终端socket连接断开: ${ reason } - 当前连接数: ${ connectionCount }`) }) }) } + +module.exports.getConnectionOptions = getConnectionOptions diff --git a/server/app/utils/db-class.js b/server/app/utils/db-class.js index 6c11aba..e3d802b 100644 --- a/server/app/utils/db-class.js +++ b/server/app/utils/db-class.js @@ -8,7 +8,8 @@ const { groupConfDBPath, scriptsDBPath, onekeyDBPath, - logDBPath + logDBPath, + plusDBPath } = require('../config') module.exports.KeyDB = class KeyDB { @@ -117,4 +118,15 @@ module.exports.LogDB = class LogDB { getInstance() { return LogDB.instance } +} + +module.exports.PlusDB = class PlusDB { + constructor() { + if (!PlusDB.instance) { + PlusDB.instance = new Datastore({ filename: plusDBPath, autoload: true }) + } + } + getInstance() { + return PlusDB.instance + } } \ No newline at end of file diff --git a/server/app/utils/decrypt-file.js b/server/app/utils/decrypt-file.js new file mode 100644 index 0000000..cae3094 --- /dev/null +++ b/server/app/utils/decrypt-file.js @@ -0,0 +1,53 @@ +const fs = require('fs-extra') +const path = require('path') +const CryptoJS = require('crypto-js') +const { AESDecryptAsync } = require('./encrypt') +const { PlusDB } = require('./db-class') +const plusDB = new PlusDB().getInstance() + +function decryptAndExecuteAsync(plusPath) { + return new Promise(async (resolve) => { + try { + let { decryptKey } = await plusDB.findOneAsync({}) + if (!decryptKey) { + throw new Error('缺少解密密钥') + } + decryptKey = await AESDecryptAsync(decryptKey) + const encryptedContent = fs.readFileSync(plusPath, 'utf-8') + const bytes = CryptoJS.AES.decrypt(encryptedContent, decryptKey) + const decryptedContent = bytes.toString(CryptoJS.enc.Utf8) + if (!decryptedContent) { + throw new Error('解密失败,请检查密钥是否正确') + } + const customRequire = (modulePath) => { + if (modulePath.startsWith('.')) { + const absolutePath = path.resolve(path.dirname(plusPath), modulePath) + return require(absolutePath) + } + return require(modulePath) + } + const module = { + exports: {}, + require: customRequire, + __filename: plusPath, + __dirname: path.dirname(plusPath) + } + const wrapper = Function('module', 'exports', 'require', '__filename', '__dirname', + decryptedContent + '\n return module.exports;' + ) + const exports = wrapper( + module, + module.exports, + customRequire, + module.__filename, + module.__dirname + ) + resolve(exports) + } catch (error) { + consola.info('解锁plus功能失败: ', error.message) + resolve(null) + } + }) +} + +module.exports = decryptAndExecuteAsync diff --git a/server/app/utils/get-plus.js b/server/app/utils/get-plus.js new file mode 100644 index 0000000..70d6093 --- /dev/null +++ b/server/app/utils/get-plus.js @@ -0,0 +1,93 @@ +const schedule = require('node-schedule') +const { getLocalNetIP } = require('./tools') +const { AESEncryptAsync } = require('./encrypt') +const version = require('../../package.json').version + +async function getLicenseInfo() { + let key = process.env.PLUS_KEY + if (!key || typeof key !== 'string' || key.length < 20) return + let ip = '' + if (global.serverIp && (Date.now() - global.getServerIpLastTime) / 1000 / 60 < 60) { + ip = global.serverIp + consola.log('get server ip by cache: ', ip) + } else { + ip = await getLocalNetIP() + global.serverIp = ip + global.getServerIpLastTime = Date.now() + consola.log('get server ip by net: ', ip) + } + if (!ip) { + consola.error('activate plus failed: get public ip failed') + global.serverIp = '' + return + } + try { + let response + let method = 'POST' + let body = JSON.stringify({ ip, key, version }) + let headers = { 'Content-Type': 'application/json' } + let timeout = 10000 + try { + response = await fetch('https://en1.221022.xyz/api/licenses/activate', { + method, + headers, + body, + timeout + }) + + if (!response.ok && (response.status !== 403)) { + throw new Error('port1 error') + } + + } catch (error) { + consola.log('retry to activate plus by backup server') + response = await fetch('https://en2.221022.xyz/api/licenses/activate', { + method, + headers, + body, + timeout + }) + } + + if (!response.ok) { + consola.log('activate plus failed: ', response.status) + if (response.status === 403) { + const errMsg = await response.json() + throw { errMsg, clear: true } + } + throw Error({ errMsg: `HTTP error! status: ${ response.status }` }) + } + + const { success, data } = await response.json() + if (success) { + let { decryptKey, expiryDate, usedIPCount, maxIPs, usedIPs } = data + decryptKey = await AESEncryptAsync(decryptKey) + consola.success('activate plus success') + const { PlusDB } = require('./db-class') + const plusData = { key, decryptKey, expiryDate, usedIPCount, maxIPs, usedIPs } + const plusDB = new PlusDB().getInstance() + let count = await plusDB.countAsync({}) + if (count === 0) { + await plusDB.insertAsync(plusData) + } else { + await plusDB.removeAsync({}, { multi: true }) + await plusDB.insertAsync(plusData) + } + } + } catch (error) { + consola.error(`activate plus failed: ${ error.message || error.errMsg?.message }`) + if (error.clear) { + const { PlusDB } = require('./db-class') + const plusDB = new PlusDB().getInstance() + await plusDB.removeAsync({}, { multi: true }) + } + } +} + +const randomHour = Math.floor(Math.random() * 24) +const randomMinute = Math.floor(Math.random() * 60) +const randomDay = Math.floor(Math.random() * 7) +const cronExpression = `${ randomMinute } ${ randomHour } * * ${ randomDay }` +schedule.scheduleJob(cronExpression, getLicenseInfo) + +module.exports = getLicenseInfo diff --git a/server/app/utils/tools.js b/server/app/utils/tools.js index e190530..d531bbd 100644 --- a/server/app/utils/tools.js +++ b/server/app/utils/tools.js @@ -89,6 +89,35 @@ const getNetIPInfo = async (searchIp = '') => { } } +const getLocalNetIP = async () => { + try { + let ipUrls = [ + 'http://whois.pconline.com.cn/ipJson.jsp?json=true', + 'https://www.ip.cn/api/index?ip=&type=0', + 'https://freeipapi.com/api/json' + ] + let result = await Promise.allSettled(ipUrls.map(url => axios.get(url))) + let [pconline, ipCN, freeipapi] = result + if (pconline.status === 'fulfilled') { + let ip = pconline.value?.data?.ip + if (ip) return ip + } + if (ipCN.status === 'fulfilled') { + let ip = ipCN.value?.data?.ip + consola.log('ipCN:', ip) + if (ip) return ip + } + if (freeipapi.status === 'fulfilled') { + let ip = pconline.value?.data?.ipAddress + if (ip) return ip + } + return null + } catch (error) { + console.error('getIpInfo Error: ', error?.message || error) + return null + } +} + function isLocalIP(ip) { // Check if IPv4 or IPv6 address const isIPv4 = net.isIPv4(ip) @@ -159,7 +188,7 @@ const isIP = (ip = '') => { return isIPv4.test(ip) || isIPv6.test(ip) } -const randomStr = (len) =>{ +const randomStr = (len) => { len = len || 16 let str = 'ABCDEFGHJKMNPQRSTWXYZabcdefhijkmnprstwxyz2345678', a = str.length, @@ -178,7 +207,7 @@ const getUTCDate = (num = 8) => { } const formatTimestamp = (timestamp = Date.now(), format = 'time') => { - if (typeof(timestamp) !== 'number') return '--' + if (typeof (timestamp) !== 'number') return '--' let date = new Date(timestamp) let padZero = (num) => String(num).padStart(2, '0') let year = date.getFullYear() @@ -187,7 +216,7 @@ const formatTimestamp = (timestamp = Date.now(), format = 'time') => { let hours = padZero(date.getHours()) let minute = padZero(date.getMinutes()) let second = padZero(date.getSeconds()) - let weekday = ['周日', '周一', '周二', '周三', '周四', '周五', '周六' ] + let weekday = ['周日', '周一', '周二', '周三', '周四', '周五', '周六'] let week = weekday[date.getDay()] switch (format) { case 'date': @@ -284,6 +313,7 @@ const ping = (ip, timeout = 5000) => { module.exports = { getNetIPInfo, + getLocalNetIP, throwError, isIP, randomStr, diff --git a/server/index.js b/server/index.js index 110576d..b1e4be9 100644 --- a/server/index.js +++ b/server/index.js @@ -1,2 +1,4 @@ +const consola = require('consola') +global.consola = consola require('dotenv').config() require('./app/main.js') diff --git a/server/package.json b/server/package.json index ac75cb3..02a4726 100644 --- a/server/package.json +++ b/server/package.json @@ -1,58 +1,58 @@ -{ - "name": "server", - "version": "1.0.0", - "description": "easynode-server", - "bin": "./bin/www", - "scripts": { - "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" - }, - "keywords": [], - "author": "", - "license": "ISC", - "nodemonConfig": { - "ignore": [ - "*.json" - ] - }, - "dependencies": { - "@koa/cors": "^5.0.0", - "@seald-io/nedb": "^4.0.4", - "axios": "^1.7.4", - "consola": "^3.2.3", - "cross-env": "^7.0.3", - "crypto-js": "^4.2.0", - "dotenv": "^16.4.5", - "fs-extra": "^11.2.0", - "global": "^4.4.0", - "iconv-lite": "^0.6.3", - "jsonwebtoken": "^9.0.2", - "koa": "^2.15.3", - "koa-body": "^6.0.1", - "koa-compose": "^4.1.0", - "koa-compress": "^5.1.1", - "koa-jwt": "^4.0.4", - "koa-router": "^12.0.1", - "koa-sslify": "^5.0.1", - "koa-static": "^5.0.0", - "koa2-connect-history-api-fallback": "^0.1.3", - "log4js": "^6.9.1", - "node-os-utils": "^1.3.7", - "node-rsa": "^1.1.1", - "node-schedule": "^2.1.1", - "nodemailer": "^6.9.14", - "qrcode": "^1.5.4", - "socket.io": "^4.7.5", - "socket.io-client": "^4.7.5", - "speakeasy": "^2.0.0", - "ssh2": "^1.15.0", - "ssh2-sftp-client": "^10.0.3" - }, - "devDependencies": { - "eslint": "^8.56.0", - "nodemon": "^3.1.4" - } -} +{ + "name": "server", + "version": "3.0.0", + "description": "easynode-server", + "bin": "./bin/www", + "scripts": { + "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" + }, + "keywords": [], + "author": "", + "license": "ISC", + "nodemonConfig": { + "ignore": [ + "*.json" + ] + }, + "dependencies": { + "@koa/cors": "^5.0.0", + "@seald-io/nedb": "^4.0.4", + "axios": "^1.7.4", + "consola": "^3.2.3", + "cross-env": "^7.0.3", + "crypto-js": "^4.2.0", + "dotenv": "^16.4.5", + "fs-extra": "^11.2.0", + "global": "^4.4.0", + "iconv-lite": "^0.6.3", + "jsonwebtoken": "^9.0.2", + "koa": "^2.15.3", + "koa-body": "^6.0.1", + "koa-compose": "^4.1.0", + "koa-compress": "^5.1.1", + "koa-jwt": "^4.0.4", + "koa-router": "^12.0.1", + "koa-sslify": "^5.0.1", + "koa-static": "^5.0.0", + "koa2-connect-history-api-fallback": "^0.1.3", + "log4js": "^6.9.1", + "node-os-utils": "^1.3.7", + "node-rsa": "^1.1.1", + "node-schedule": "^2.1.1", + "nodemailer": "^6.9.14", + "qrcode": "^1.5.4", + "socket.io": "^4.7.5", + "socket.io-client": "^4.7.5", + "speakeasy": "^2.0.0", + "ssh2": "^1.15.0", + "ssh2-sftp-client": "^10.0.3" + }, + "devDependencies": { + "eslint": "^8.56.0", + "nodemon": "^3.1.4" + } +} diff --git a/web/package.json b/web/package.json index 6993074..fa33191 100644 --- a/web/package.json +++ b/web/package.json @@ -1,6 +1,6 @@ { "name": "web", - "version": "2.3.0", + "version": "3.0.0", "description": "easynode-web", "private": true, "scripts": { diff --git a/web/src/api/index.js b/web/src/api/index.js index 6cbaa5d..5f4c8c3 100644 --- a/web/src/api/index.js +++ b/web/src/api/index.js @@ -19,12 +19,15 @@ export default { removeSSH(id) { return axios({ url: `/remove-ssh/${ id }`, method: 'delete' }) }, - // existSSH(host) { - // return axios({ url: '/exist-ssh', method: 'post', data: { host } }) - // }, + getPlusInfo() { + return axios({ url: '/plus-info', method: 'get' }) + }, getCommand(hostId) { return axios({ url: '/command', method: 'get', params: { hostId } }) }, + decryptPrivateKey(data) { + return axios({ url: '/decrypt-private-key', method: 'post', data }) + }, getHostList() { return axios({ url: '/host-list', method: 'get' }) }, @@ -34,6 +37,9 @@ export default { updateHost(data) { return axios({ url: '/host-save', method: 'put', data }) }, + batchUpdateHost(data) { + return axios({ url: '/batch-update-host', method: 'put', data }) + }, removeHost(data) { return axios({ url: '/host-remove', method: 'post', data }) }, @@ -88,8 +94,11 @@ export default { deleteGroup(id) { return axios({ url: `/group/${ id }`, method: 'delete' }) }, - getScriptList() { - return axios({ url: '/script', method: 'get' }) + getScriptList(params = {}) { + return axios({ url: '/script', method: 'get', params }) + }, + importScript(data) { + return axios({ url: '/import-script', method: 'post', data }) }, getLocalScriptList() { return axios({ url: '/local-script', method: 'get' }) @@ -103,6 +112,9 @@ export default { deleteScript(id) { return axios({ url: `/script/${ id }`, method: 'delete' }) }, + batchRemoveScript(data) { + return axios({ url: '/batch-remove-script', method: 'post', data }) + }, getOnekeyRecord() { return axios({ url: '/onekey', method: 'get' }) }, diff --git a/web/src/app.vue b/web/src/app.vue index 515b016..664f6e0 100644 --- a/web/src/app.vue +++ b/web/src/app.vue @@ -5,8 +5,8 @@ + + + diff --git a/web/src/components/menuList.vue b/web/src/components/menuList.vue index 132953d..9f9269e 100644 --- a/web/src/components/menuList.vue +++ b/web/src/components/menuList.vue @@ -18,7 +18,7 @@ \ No newline at end of file diff --git a/web/src/views/scripts/index.vue b/web/src/views/scripts/index.vue index 3e6cb15..af60728 100644 --- a/web/src/views/scripts/index.vue +++ b/web/src/views/scripts/index.vue @@ -1,9 +1,52 @@ + +
+ +
+ + + \ No newline at end of file diff --git a/web/src/views/server/components/host-form.vue b/web/src/views/server/components/host-form.vue index 293a7ff..5375600 100644 --- a/web/src/views/server/components/host-form.vue +++ b/web/src/views/server/components/host-form.vue @@ -30,232 +30,260 @@ label-width="100px" :show-message="false" > - - - - - - + + + + + + + + +
+ + + -
- - - - - - -
- - - - - - - 密钥 - 密码 - 凭据 - - + + - - 本地私钥... - - - +
{{ item.value }}
+ +
+
+ + 密钥 + 密码 + 凭据 + + + + 本地私钥... + + + + + + + + + + + +
+ {{ item.name }} + + {{ item.authType === 'privateKey' ? '密钥' : '密码' }} + +
+
+
+
+ + + - - - - - - - -
+
{{ item.name }} - - {{ item.authType === 'privateKey' ? '密钥' : '密码' }} -
- - - - + + + + + - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + @@ -263,6 +291,7 @@