Compare commits

...

154 Commits
v2.1.7 ... main

Author SHA1 Message Date
chaos-zhu
4808f6e218 🆕 更新readme 2025-02-23 12:11:54 +08:00
chaos-zhu
9bd1cca518 🆕 切换到自建git代理 2025-02-18 22:08:41 +08:00
chaos-zhu
64d5db8c56 🐛 修复git代理host 2025-02-05 22:14:25 +08:00
chaos-zhu
37e1b891d3 🐛 移除任务 2024-12-30 21:56:52 +08:00
chaos-zhu
50ed2a8569 📝 更新描述 2024-12-24 22:49:53 +08:00
chaos-zhu
84b5f1beb6 🐛 修复通知测试按钮&自动重连机制 2024-12-24 22:40:14 +08:00
chaos-zhu
5f0e6e9ecc 📝 更新文档 2024-12-22 23:11:07 +08:00
chaos-zhu
0cbe43ecdd 优化移动端UI 2024-12-22 22:48:16 +08:00
chaos-zhu
0bef9b53af 前端支持激活plus 2024-12-22 22:42:00 +08:00
chaos-zhu
cbc6fa02ac 🐛 修复mfa2登录首字符为0时无法输入的bug&前端支持激活plus 2024-12-22 22:20:53 +08:00
chaos-zhu
9df142ccde 新增tg通知 2024-12-22 17:39:12 +08:00
chaos-zhu
6252f481d5 支持keyboard-interactive认证 2024-12-22 15:31:48 +08:00
chaos-zhu
aaf79fe60a
Merge pull request #122 from chaos-zhu/dependabot/npm_and_yarn/cross-spawn-7.0.6
⬆️ Bump cross-spawn from 7.0.3 to 7.0.6
2024-11-19 23:01:44 +08:00
chaos-zhu
d149e947bc 🐛 修复添加实例错误禁用的bug 2024-11-19 22:52:55 +08:00
dependabot[bot]
59b9938809
⬆️ Bump cross-spawn from 7.0.3 to 7.0.6
Bumps [cross-spawn](https://github.com/moxystudio/node-cross-spawn) from 7.0.3 to 7.0.6.
- [Changelog](https://github.com/moxystudio/node-cross-spawn/blob/master/CHANGELOG.md)
- [Commits](https://github.com/moxystudio/node-cross-spawn/compare/v7.0.3...v7.0.6)

---
updated-dependencies:
- dependency-name: cross-spawn
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-11-19 14:33:35 +00:00
chaos-zhu
cb866c6d26 🐛 修复添加实例错误禁用的bug 2024-11-19 22:31:30 +08:00
chaos-zhu
079c62b838 更新版本通知 2024-11-18 22:22:13 +08:00
chaos-zhu
c04989b951 优化脚本库新增脚本时序号自动累加 2024-11-09 23:43:02 +08:00
chaos-zhu
7c15d311c1 📝 更新文档 2024-11-09 23:29:39 +08:00
chaos-zhu
29fd0a5bbf 📝 更新文档 2024-11-09 23:24:24 +08:00
chaos-zhu
2c41928f65 plus&功能重构 2024-11-09 23:14:51 +08:00
chaos-zhu
1fdf8c6a09 📝 描述更新 2024-10-24 00:26:06 +08:00
chaos-zhu
678a1e4d04 支持MFA2二次验证 2024-10-24 00:00:44 +08:00
chaos-zhu
f0b492da26 支持MFA2二次验证 2024-10-23 22:48:24 +08:00
chaos-zhu
70bdaa5b69 ♻️ 重构本地数据库-credentials模块 2024-10-22 23:57:32 +08:00
chaos-zhu
cdd741b7fd ♻️ 重构本地数据库-keyConfig模块 2024-10-22 23:22:48 +08:00
chaos-zhu
90ee38ff44 ♻️ 重构本地数据库-onekey模块 2024-10-22 23:00:12 +08:00
chaos-zhu
9b71b28e46 ♻️ 重构本地数据库-scripts模块 2024-10-22 22:50:00 +08:00
chaos-zhu
98d44e8ab4 ♻️ 重构本地数据库-notify模块 2024-10-22 22:41:49 +08:00
chaos-zhu
dafb2cc5c9 ♻️ 重构本地数据库-log模块 2024-10-22 22:02:05 +08:00
chaos-zhu
5437486eba ♻️ 重构本地数据库-group模块 2024-10-22 21:48:29 +08:00
chaos-zhu
7aefa410dc 调整移动端虚拟按键位置 2024-10-22 21:32:52 +08:00
chaos-zhu
5724ede172 ♻️ 重构本地数据库-host模块 2024-10-22 00:48:26 +08:00
chaos-zhu
a72ab84cee 增强终端背景设定 2024-10-21 22:08:55 +08:00
chaos-zhu
6273a9498e 🐛 修复移动端软键盘弹起UI问题 2024-10-21 21:15:33 +08:00
chaos-zhu
c8898e6acb 🐛 修复删除实例异常 2024-10-21 18:55:41 +08:00
chaos-zhu
fe5e75878a 登录日志本地化储存 2024-10-20 22:35:50 +08:00
chaos-zhu
e9a567c3fe 优化移动端兼容UI 2024-10-20 21:56:41 +08:00
chaos-zhu
846c19ceb3 :fix: 修复移动端alt按键映射 2024-10-20 21:34:46 +08:00
chaos-zhu
fc42e1b29a 新增移动端虚拟按键映射 2024-10-20 19:59:34 +08:00
chaos-zhu
6b5f882808 兼容移动端UI 2024-10-20 16:22:56 +08:00
chaos-zhu
d8f0938a11 添加ping命令 2024-10-20 10:59:08 +08:00
chaos-zhu
1a09a1276c 📝 版本号更新 2024-10-17 23:55:15 +08:00
chaos-zhu
53cc1628c2 终端支持快捷设置 2024-10-17 23:53:04 +08:00
chaos-zhu
70e867410f ♻️ 重构终端设置项 2024-10-17 23:06:16 +08:00
chaos-zhu
203750c133 终端展示服务端ping客户端延迟 2024-10-15 09:02:24 +08:00
chaos-zhu
94097a1c6d 终端展示服务端ping客户端延迟 2024-10-14 22:52:49 +08:00
chaos-zhu
d184a8bdaa 📝 更新安装描述 2024-10-14 00:37:52 +08:00
chaos-zhu
20917da5a7 📝 2.2.6版本更新 2024-10-14 00:29:40 +08:00
chaos-zhu
808a785d5b 📝 2.2.6版本更新 2024-10-14 00:23:17 +08:00
chaos-zhu
0460af5c48 支持自定义客户端端口 2024-10-14 00:21:19 +08:00
chaos-zhu
da36ed9d1b 支持自定义客户端端口 2024-10-13 23:38:59 +08:00
chaos-zhu
e784092c1a 👷 更新客户端构建action 2024-10-13 23:17:02 +08:00
chaos-zhu
d54b682f7a 客户端脚本支持自定义端口 2024-10-13 23:07:54 +08:00
chaos-zhu
09e2c39132 Merge branch 'main' of github.com:chaos-zhu/easynode 2024-10-13 23:05:01 +08:00
chaos-zhu
3e8a9ac74a 客户端脚本支持自定义端口 2024-10-13 23:04:37 +08:00
chaos-zhu
0bc0284559
Merge pull request #108 from chaos-zhu/dependabot/npm_and_yarn/vite-5.3.6
⬆️ Bump vite from 5.3.4 to 5.3.6
2024-10-13 11:11:15 +08:00
dependabot[bot]
6b11e28b9b
⬆️ Bump vite from 5.3.4 to 5.3.6
Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 5.3.4 to 5.3.6.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/v5.3.6/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v5.3.6/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-10-13 02:25:39 +00:00
chaos-zhu
4c1d0fc291
Merge pull request #95 from chaos-zhu/dependabot/npm_and_yarn/micromatch-4.0.8
⬆️ Bump micromatch from 4.0.7 to 4.0.8
2024-10-13 10:24:33 +08:00
chaos-zhu
7028205d9d
Merge pull request #106 from chaos-zhu/dependabot/npm_and_yarn/rollup-4.24.0
⬆️ Bump rollup from 4.18.1 to 4.24.0
2024-10-13 10:24:22 +08:00
dependabot[bot]
27957c3fbb
⬆️ Bump rollup from 4.18.1 to 4.24.0
Bumps [rollup](https://github.com/rollup/rollup) from 4.18.1 to 4.24.0.
- [Release notes](https://github.com/rollup/rollup/releases)
- [Changelog](https://github.com/rollup/rollup/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rollup/rollup/compare/v4.18.1...v4.24.0)

---
updated-dependencies:
- dependency-name: rollup
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-10-11 15:58:26 +00:00
chaos-zhu
c61585fd4d 📝 版本更新 2024-10-11 23:39:52 +08:00
chaos-zhu
aaddf08dd8 🐛 修复第三方git代理地址 2024-10-11 23:35:42 +08:00
chaos-zhu
3de5537448 🐛 修复同主机名使用id连接终端 2024-10-11 23:28:20 +08:00
chaos-zhu
4c7a214c55 支持关闭所有终端连接 2024-10-11 23:12:12 +08:00
chaos-zhu
51b3c58673 支持同主机名称任意端口 2024-10-11 22:51:02 +08:00
chaos-zhu
7e45186d22 Merge branch 'main' of github.com:chaos-zhu/easynode 2024-10-09 23:37:59 +08:00
chaos-zhu
b1ded4991f 📝 更新README 2024-10-09 23:37:47 +08:00
chaos-zhu
e7a2cdc3e7
Merge pull request #100 from chaos-zhu/dependabot/npm_and_yarn/path-to-regexp-6.3.0
⬆️ Bump path-to-regexp from 6.2.2 to 6.3.0
2024-09-15 21:22:22 +08:00
dependabot[bot]
c2a717619b
⬆️ Bump path-to-regexp from 6.2.2 to 6.3.0
Bumps [path-to-regexp](https://github.com/pillarjs/path-to-regexp) from 6.2.2 to 6.3.0.
- [Release notes](https://github.com/pillarjs/path-to-regexp/releases)
- [Changelog](https://github.com/pillarjs/path-to-regexp/blob/master/History.md)
- [Commits](https://github.com/pillarjs/path-to-regexp/compare/v6.2.2...v6.3.0)

---
updated-dependencies:
- dependency-name: path-to-regexp
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-09-15 13:20:45 +00:00
dependabot[bot]
e4d26c46e2
⬆️ Bump micromatch from 4.0.7 to 4.0.8
Bumps [micromatch](https://github.com/micromatch/micromatch) from 4.0.7 to 4.0.8.
- [Release notes](https://github.com/micromatch/micromatch/releases)
- [Changelog](https://github.com/micromatch/micromatch/blob/master/CHANGELOG.md)
- [Commits](https://github.com/micromatch/micromatch/compare/4.0.7...4.0.8)

---
updated-dependencies:
- dependency-name: micromatch
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-08-31 07:43:09 +00:00
chaos-zhu
0f191398db SFTP支持输入路径跳转 2024-08-31 15:39:19 +08:00
chaos-zhu
57c252dc99 🆕 web版本号更新 2024-08-31 15:15:43 +08:00
chaos-zhu
c9f75e6b30 📝 描述更新 2024-08-23 16:18:14 +08:00
chaos-zhu
56cc7dc3d5 🆕 图床托管终端背景图片 2024-08-20 13:59:06 +08:00
chaos-zhu
eb9ecb7ad5 🆕 添加上传完成后的tips 2024-08-20 13:35:46 +08:00
chaos-zhu
510e660b17 🆕 限制上传文件夹额外的操作 2024-08-20 12:20:42 +08:00
chaos-zhu
284c7e9398 🆕 web版本号更新 2024-08-20 11:15:53 +08:00
chaos-zhu
b3a6f19ddd 📝 版本描述更新 2024-08-20 11:13:02 +08:00
chaos-zhu
124f3b32ec 📝 版本描述更新 2024-08-20 10:49:29 +08:00
chaos-zhu
9f04c8adbb 支持IP白名单访问限制 2024-08-20 10:44:16 +08:00
chaos-zhu
997761f2fc 📝 docker启动参数更新 2024-08-19 12:46:12 +08:00
chaos-zhu
9ce2a3bf5f 📝 版本更新描述 2024-08-19 12:05:25 +08:00
chaos-zhu
9f40aeddf5 🚨 Eslint规则更新 2024-08-19 10:39:40 +08:00
chaos-zhu
e333fa5aa3 📝 版本更新描述 2024-08-18 19:36:56 +08:00
chaos-zhu
65bb8bb6c7 📝 版本更新描述 2024-08-18 18:11:48 +08:00
chaos-zhu
e0673293ef 优化终端回显 2024-08-18 18:10:59 +08:00
chaos-zhu
a6b2ed5d8f 支持菜单栏折叠与展开 2024-08-18 17:39:44 +08:00
chaos-zhu
140be0ca53 📝 版本更新描述 2024-08-18 16:31:50 +08:00
chaos-zhu
fae1df601d 🔒 修复Code scanning提到的依赖风险 2024-08-18 16:31:10 +08:00
chaos-zhu
8284e942a5 💄 版本更新弹出样式优化 2024-08-18 16:25:22 +08:00
chaos-zhu
3266edd418 📝 部署文档更新 2024-08-18 16:09:26 +08:00
chaos-zhu
6b61ad6764 💄 版本更新弹出样式优化 2024-08-18 15:45:25 +08:00
chaos-zhu
45b1393039 💄 实例详情展开样式优化 2024-08-18 15:13:49 +08:00
chaos-zhu
14cb60a403 📝 版本更新描述 2024-08-18 15:05:36 +08:00
chaos-zhu
e9ee48ac43 🐛 修复导入实例暗黑模式样式 2024-08-18 15:04:31 +08:00
chaos-zhu
beffd97396 ♻️ 调整utils引用 2024-08-18 15:02:31 +08:00
chaos-zhu
4e9bfe46d3 🐛 修复交换内存占比 2024-08-18 14:26:03 +08:00
chaos-zhu
32089f4196 🐛 修复SFTP暗黑模式背景 2024-08-18 14:14:52 +08:00
chaos-zhu
ad444fc124 📝 更新系统gif描述 2024-08-18 14:11:12 +08:00
chaos-zhu
1512b71165 📝 版本更新描述 2024-08-18 00:39:43 +08:00
chaos-zhu
5ec31ccef7 支持主题切换动画 2024-08-18 00:35:29 +08:00
chaos-zhu
5dddc31bfa 支持暗黑主题切换 2024-08-18 00:18:49 +08:00
chaos-zhu
23e0140544 支持暗黑主题切换 2024-08-17 23:43:59 +08:00
chaos-zhu
ad0984bf64 🐛 修复批量脚本下发执行结果通知重复的bug 2024-08-17 18:03:05 +08:00
chaos-zhu
c063a3e588 📦 调整客户端安装脚本 2024-08-17 17:36:45 +08:00
chaos-zhu
d212f68855 📦 调整客户端安装脚本 2024-08-17 17:31:29 +08:00
chaos-zhu
8e906476f8 📝 版本更新描述 2024-08-17 17:27:07 +08:00
chaos-zhu
4dd5c72794 📦 调整客户端安装脚本 2024-08-17 17:22:38 +08:00
chaos-zhu
f21da41d6d 支持老版本通知服务升级 2024-08-17 17:20:10 +08:00
chaos-zhu
e92e2beb4d 新增批量指令提醒&终端登录提醒 2024-08-17 16:33:27 +08:00
chaos-zhu
221f9e06b9 新增服务器到期提醒 2024-08-17 16:19:32 +08:00
chaos-zhu
3d410602d6 完善通知模块 2024-08-17 16:10:17 +08:00
chaos-zhu
279cd90f63 🆕 重构全局通知方案 2024-08-17 01:11:06 +08:00
chaos-zhu
82a89f827a 🆕 重构全局通知方案 2024-08-17 01:10:23 +08:00
chaos-zhu
525cbef68f 🐛 修复终端连接失败异常抛出 2024-08-17 00:18:12 +08:00
chaos-zhu
cfb9a24a5f 🐛 修复上传同一个文件无法选择的bug 2024-08-17 00:01:02 +08:00
chaos-zhu
1bdb620617 ♻️ 重构通知模块 2024-08-16 23:57:25 +08:00
chaos-zhu
ee63a53d6e 🆕 过滤客户端版本号 2024-08-16 14:00:50 +08:00
chaos-zhu
2d6f5d091f 📝 版本更新描述 2024-08-16 13:53:46 +08:00
chaos-zhu
930ac5b465 🆕 过滤客户端检测更新 2024-08-16 13:52:53 +08:00
chaos-zhu
d9bd3c252c 🔧 clien shell 安装优化 2024-08-16 13:42:56 +08:00
chaos-zhu
68ea2db18e 👷 监控客户端自动化构建 2024-08-16 13:20:39 +08:00
chaos-zhu
3fe458db69 👷 监控客户端自动化构建 2024-08-16 13:15:57 +08:00
chaos-zhu
6bba59f89f 👷 监控客户端自动化构建 2024-08-16 13:08:21 +08:00
chaos-zhu
54b164938c 👷 监控客户端自动化构建 2024-08-16 12:15:28 +08:00
chaos-zhu
2d6c878f80 👷 监控客户端自动化构建 2024-08-16 12:03:36 +08:00
chaos-zhu
2af5e06b80 👷 监控客户端自动化构建 2024-08-16 12:01:21 +08:00
chaos-zhu
317cd71927 👷 监控客户端自动化构建 2024-08-16 11:57:25 +08:00
chaos-zhu
2ad1137c99 👷 监控客户端自动化构建 2024-08-16 11:56:33 +08:00
chaos-zhu
049f991cd8 👷 监控客户端自动化构建 2024-08-16 11:54:20 +08:00
chaos-zhu
c9180d5148 👷 监控客户端自动化构建 2024-08-16 11:51:57 +08:00
chaos-zhu
8485fe8bd6 👷 监控客户端自动化构建 2024-08-16 11:48:03 +08:00
chaos-zhu
2d4f872b82 👷 监控客户端自动化构建 2024-08-16 11:43:01 +08:00
chaos-zhu
308defaa53 👷 监控客户端自动化构建 2024-08-16 11:40:23 +08:00
chaos-zhu
3499bcdd4b 🔧 clien shell调整 2024-08-16 11:39:01 +08:00
chaos-zhu
0197e9bb41 👷 监控客户端自动化构建 2024-08-16 11:37:34 +08:00
chaos-zhu
673dad66bf 🔧 调整安装shell 2024-08-16 11:24:53 +08:00
chaos-zhu
ae6c72115f 👷 监控客户端自动化构建 2024-08-16 11:21:30 +08:00
chaos-zhu
c3a689cc96 🆕 新增默认指令 2024-08-15 10:45:40 +08:00
chaos-zhu
e0b12a98e8 优化终端socket断开后逻辑 2024-08-15 09:37:41 +08:00
chaos-zhu
fb36c5ddc4 📝 更新描述 2024-08-15 09:28:47 +08:00
chaos-zhu
1c006bcf47 📝 安全提醒 2024-08-15 09:26:02 +08:00
chaos-zhu
aca303ca1c 📝 版本更新描述 2024-08-15 09:24:34 +08:00
chaos-zhu
1e783c0d90 新增终端连接状态展示 2024-08-15 09:24:06 +08:00
chaos-zhu
e0eb1446db 优化终端连接逻辑 2024-08-15 08:16:38 +08:00
chaos-zhu
21a97bf375 重构终端连接逻辑 2024-08-15 07:24:02 +08:00
chaos-zhu
5aaad74c57 右键粘贴安全处理 2024-08-15 04:27:44 +08:00
chaos-zhu
44dde760af 终端支持选中复制&右键粘贴 2024-08-15 04:16:33 +08:00
chaos-zhu
fd847c1925 🐛 修复数据及时更新插入 2024-08-15 01:59:06 +08:00
chaos-zhu
2992563b68 📝 版本更新描述 2024-08-14 16:29:59 +08:00
chaos-zhu
dbca45a9ce 📝 添加初始账户登录警告 2024-08-14 16:28:29 +08:00
chaos-zhu
a6c7ce7f99 📝 版本更新 2024-08-14 15:52:29 +08:00
chaos-zhu
3fed458e3b 终端面板新增交换内存展示 2024-08-14 11:46:51 +08:00
137 changed files with 6820 additions and 3479 deletions

79
.github/workflows/client-builder.yml vendored Normal file
View File

@ -0,0 +1,79 @@
name: Build Client to Release
on:
push:
branches:
- main
paths:
- 'client/**'
workflow_dispatch:
jobs:
build:
runs-on: ubuntu-22.04
steps:
- name: Checkout Repository
uses: actions/checkout@v3
- name: Set up Node.js
uses: actions/setup-node@v3
with:
node-version: '20'
- name: Install dependencies
run: npm install
working-directory: client
- name: Build for Linux x64
run: npm run pkglinux:x64
working-directory: client
- name: Install QEMU # 设置qemu 支持arm的虚拟环境
run: sudo apt-get update && sudo apt-get install -y qemu qemu-user-static binfmt-support
- name: Setup QEMU ARM64
run: |
sudo update-binfmts --enable qemu-aarch64
sudo cp /usr/bin/qemu-aarch64-static /usr/local/bin
uname -a
- name: Build for Linux arm64
run: npm run pkglinux:arm64
working-directory: client
- name: Set tag name
id: tag_name
run: echo "TAG_NAME=client-$(date +'%Y-%m-%d')" >> $GITHUB_ENV
- name: Create Release
id: create_release
uses: actions/create-release@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
tag_name: ${{ env.TAG_NAME }}
release_name: ${{ env.TAG_NAME }}
draft: false
prerelease: false
- name: Upload Release Asset
id: upload-release-asset
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }} # This pulls from the CREATE RELEASE step above, referencing it's ID to get its outputs object, which include a `upload_url`. See this blog post for more info: https://jasonet.co/posts/new-features-of-github-actions/#passing-data-to-future-steps
asset_path: client/dist/easynode-client-x64
asset_name: easynode-client-x64
asset_content_type: application/octet-stream
- name: Upload Linux ARM64 Binary to Release
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: client/dist/easynode-client-arm64
asset_name: easynode-client-arm64
asset_content_type: application/octet-stream

View File

@ -1,9 +1,9 @@
name: Docker Build and Push name: Build Server to DockerHub
on: on:
release: release:
types: [published] types: [published]
workflow_dispatch: # 手动构建 workflow_dispatch:
inputs: inputs:
tag_name: tag_name:
description: 'Tag Name (leave empty for default latest)' description: 'Tag Name (leave empty for default latest)'

4
.gitignore vendored
View File

@ -11,3 +11,7 @@ server/app/db/*
plan.md plan.md
.env .env
.env.local .env.local
.env-encrypt-key
*clear.js
local-script
版本发布.md

5
.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,5 @@
{
"cSpell.ignoreWords": [
"Onekey"
]
}

View File

@ -1,3 +1,149 @@
## [3.0.3](https://github.com/chaos-zhu/easynode/releases) (2024-12-22)
* 支持keyboard-interactive服务器验证(serv00验证通过)
* 支持TG Bot通知方式
* 添加web端Plus授权功能
* 修复一些UI问题
* 修复MFA2登录验证码为0开头无法输入的bug
## [3.0.2](https://github.com/chaos-zhu/easynode/releases) (2024-11-20)
* 修复添加实例错误禁用的bug
## [3.0.1](https://github.com/chaos-zhu/easynode/releases) (2024-11-18)
* 修复同IP实例SFTP连接到其他的实例的bug
* 修复一些UI问题
## [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~)
* 支持MFA2二次登录验证
* 优化了一些页面在移动端的展示
* 修复偶现刷新页面需重新登录的bug
## [2.2.8](https://github.com/chaos-zhu/easynode/releases) (2024-10-20)
### Features
* 兼容移动端UI
* 新增移动端虚拟功能按键映射
* 调整终端功能菜单
* 登录日志本地化储存
* 修复终端选中文本无法复制的bug
* 修复无法展示服务端ping客户端延迟ms的bug
* 修复暗黑模式下的一些样式问题
## [2.2.7](https://github.com/chaos-zhu/easynode/releases) (2024-10-17)
### Features
* 终端连接页新增展示服务端ping客户端延迟ms
* 修复自定义客户端端口默认字符串的bug
* 终端支持快捷设置开关: 快捷复制、快捷粘贴、选中脚本自动执行
## [2.2.6](https://github.com/chaos-zhu/easynode/releases) (2024-10-14)
### Features
* 支持自定义客户端端口,方便穿透内网机器
* 修复监控数据意外注入bug
## [2.2.5](https://github.com/chaos-zhu/easynode/releases) (2024-10-11)
### Features
* 不再对同IP:PORT的实例进行校验
* 支持同IP任意端口的服务器录入
* 支持关闭所有终端连接
* 修复第三方git代理地址
## [2.2.4](https://github.com/chaos-zhu/easynode/releases) (2024-08-31)
### Features
* SFTP支持输入路径跳转
## [2.2.3](https://github.com/chaos-zhu/easynode/releases) (2024-08-20)
### Features
* 添加环境变量 ✔
* 支持IP访问白名单设置 ✔
* 修复一些小bug ✔
* 优化Eslint规则 ✔
## [2.2.2](https://github.com/chaos-zhu/easynode/releases) (2024-08-19)
### Features
* 支持菜单栏的折叠与展开 ✔
* 优化终端回显 ✔
* 优化暗黑模式下滚动条样式 ✔
## [2.2.1](https://github.com/chaos-zhu/easynode/releases) (2024-08-18)
### Features
* 支持暗黑主题切换 ✔
* 批量脚本下发执行结果通知重复的bug ✔
* 修复交换内存占比的bug ✔
* 优化服务端代码引用 ✔
* 修复Code scanning提到的依赖风险 ✔
## [2.2.0](https://github.com/chaos-zhu/easynode/releases) (2024-08-17)
### Features
* 重构通知模块 ✔
* 支持大多数邮箱SMTP配置通知 ✔
* 支持Server酱通知 ✔
* 新增批量指令执行结果提醒 ✔
* 新增终端登录与登录状态提醒 ✔
* 新增服务器到期提醒 ✔
* 修复上传同一个文件无法选择的bug ✔
* 修复终端连接失败抛出异常的bug ✔
* 调整客户端安装脚本 ✔
## [2.1.9](https://github.com/chaos-zhu/easynode/releases) (2024-08-16)
### Features
* 过滤客户端检测更新 ✔
## [2.1.8](https://github.com/chaos-zhu/easynode/releases) (2024-08-15)
### Features
* 终端连接逻辑重写,断线自动重连 ✔
* 终端连接状态展示 ✔
* 终端支持选中复制&右键粘贴 ✔
* 终端设置支持字体大小 ✔
* 终端默认字体样式更改为`Cascadia Code`
## [2.1.7](https://github.com/chaos-zhu/easynode/releases) (2024-08-14)
### Features
* 客户端监控服务支持swap内存交换回传 ✔
* 面板支持展示swap内存交换状态展示 ✔
* 添加初始账户登录警告 ✔
## [2.1.6](https://github.com/chaos-zhu/easynode/releases) (2024-08-13) ## [2.1.6](https://github.com/chaos-zhu/easynode/releases) (2024-08-13)
### Features ### Features

View File

@ -4,10 +4,7 @@ COPY ./web .
COPY yarn.lock . COPY yarn.lock .
RUN yarn RUN yarn
RUN yarn build RUN yarn build
# RUN find ../server/app/static -type f ! -name '.gitkeep' -exec rm -f {} +
# RUN mv dist/* ../server/app/static
# 在这里加上builder_server
FROM node:20.16-alpine3.20 AS builder_server FROM node:20.16-alpine3.20 AS builder_server
WORKDIR /easynode/server WORKDIR /easynode/server
COPY ./server . COPY ./server .
@ -16,6 +13,7 @@ COPY --from=builder_web /easynode/web/dist ./app/static
RUN yarn RUN yarn
FROM node:20.16-alpine3.20 FROM node:20.16-alpine3.20
RUN apk add --no-cache iputils
WORKDIR /easynode WORKDIR /easynode
COPY --from=builder_server /easynode/server . COPY --from=builder_server /easynode/server .
ENV HOST=0.0.0.0 ENV HOST=0.0.0.0

155
README.md
View File

@ -1,91 +1,94 @@
<div align="center">
# EasyNode # EasyNode
> [!WARNING] _✨ 一个多功能Linux服务器WEB终端面板(webSSH&webSFTP) ✨_
> 初次部署EasyNode登录系统后务必记得修改默认账户密码 `admin/admin`!强烈建议使用 **iptables** 限制IP访问谨慎暴露面板服务到公网
<!-- > [!NOTE] </div>
> webssh与监控服务都将以`该服务器作为中转`。中国大陆连接建议使用香港、新加坡、日本、韩国等地区的低延迟服务器来安装服务端 -->
- [功能](#功能) <p align="center">
- [安装](#安装) <a href="https://github.com/chaos-zhu/easynode/releases/latest">
- [服务端安装](#服务端安装) <img src="https://img.shields.io/github/v/release/chaos-zhu/easynode?color=brightgreen" alt="release">
- [监控服务安装](#监控服务安装) </a>
- [版本日志](#版本日志) <a href="https://github.com/chaos-zhu/easynode/actions">
- [开发](#开发) <img src="https://img.shields.io/github/actions/workflow/status/chaos-zhu/easynode/docker-builder.yml?branch=main" alt="deployment status">
- [QA](#QA) </a>
- [捐赠](#捐赠) <a href="https://hub.docker.com/repository/docker/chaoszhu/easynode">
- [License](#license) <img src="https://img.shields.io/docker/pulls/chaoszhu/easynode?color=brightgreen" alt="docker pull">
</a>
<a href="https://github.com/chaos-zhu/easynode/releases/latest">
<img src="https://img.shields.io/github/downloads/chaos-zhu/easynode/total?color=brightgreen&include_prereleases" alt="release">
</a>
<a href="https://raw.githubusercontent.com/chaos-zhu/easynode/main/LICENSE">
<img src="https://img.shields.io/github/license/chaos-zhu/easynode?color=brightgreen" alt="license">
</a>
</p>
<p align="center">
<a href="#功能">功能</a>
·
<a href="#面板展示">面板展示</a>
·
<a href="#项目部署">项目部署</a>
·
<a href="#监控服务安装">监控服务安装</a>
·
<a href="#安全与建议">安全与建议</a>
·
<a href="#常见问题">常见问题</a>
<!-- ·
<a href="#Plus功能">Plus版功能</a> -->
</p>
## 功能 ## 功能
- [x] webssh终端&SFTP + [x] 功能完善的**SSH终端**&**SFTP**
- [x] 批量导入导出实例(Xshell&FinalShell&EasyNode) + [x] 批量导入、导出、编辑服务器配置、脚本等
- [x] 实例分组 + [x] 脚本库
- [x] 凭据托管 + [x] 实例分组
- [x] 邮件通知 + [x] 凭据托管
- [x] 服务器状态推送 + [x] 多渠道通知
- [x] 脚本库 + [x] 批量下发指令
- [x] 批量指令 + [x] 自定义终端主题
- [x] 终端主题背景图
![实例面板](./doc_images/merge.gif) ## 面板展示
## 安装 ![面板展示](./doc_images/merge.gif)
### 服务端安装 ## 项目部署
- 占用端口8082 推荐使用docker镜像安装 - 默认账户密码 `admin/admin`
- web端口8082
#### Docker部署 ### docker镜像
```shell ```shell
docker run -d --net=host --name=easynode-server -v $PWD/easynode/db:/easynode/app/db chaoszhu/easynode docker run -d -p 8082:8082 --restart=always -v /root/easynode/db:/easynode/app/db chaoszhu/easynode
# 容器支持使用-p 8082:8082映射端口, 但是无法记录登录IP
``` ```
访问http://yourip:8082 环境变量:
- `DEBUG`: 启动debug日志 0关闭 1开启, 默认关闭
- `ALLOWED_IPS`: 可以访问服务的IP白名单, 多个使用逗号分隔, 支持填写部分ip前缀, 例如: `-e ALLOWED_IPS=127.0.0.1,196.168`
#### 手动部署 ## 监控服务安装
依赖Nodejs版本 > 20+ - 监控服务用于实时向服务端&web端推送**系统、公网IP、CPU、内存、硬盘、网卡**等基础信息
```shell - 默认端口:**22022**
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
```
访问http://yourip:8082
- 查看日志:`pm2 log easynode-server`
- 启动服务:`pm2 start easynode-server`
- 停止服务:`pm2 stop easynode-server`
- 删除服务:`pm2 delete easynode-server`
---
### 监控服务安装
- 监控服务用于实时向服务端推送**系统、公网IP、CPU、内存、硬盘、网卡**等基础信息不安装不影响使用面板但是无法实时同步cpu占用、实时网速、硬盘容量等有用信息。
- 占用端口:**22022**
> 安装 > 安装
```shell ```shell
curl -o- https://mirror.ghproxy.com/https://raw.githubusercontent.com/chaos-zhu/easynode/main/client/easynode-client-install.sh | bash # 使用默认端口22022安装
curl -o- https://git.221022.xyz/https://raw.githubusercontent.com/chaos-zhu/easynode/main/client/easynode-client-install.sh | bash
# 使用自定义端口安装, 例如54321
curl -o- https://git.221022.xyz/https://raw.githubusercontent.com/chaos-zhu/easynode/main/client/easynode-client-install.sh | bash -s -- 54321
``` ```
> 卸载 > 卸载
```shell ```shell
curl -o- https://mirror.ghproxy.com/https://raw.githubusercontent.com/chaos-zhu/easynode/main/client/easynode-client-uninstall.sh | bash curl -o- https://git.221022.xyz/https://raw.githubusercontent.com/chaos-zhu/easynode/main/client/easynode-client-uninstall.sh | bash
``` ```
> 查看监控服务状态:`systemctl status easynode-client` > 查看监控服务状态:`systemctl status easynode-client`
@ -96,27 +99,25 @@ curl -o- https://mirror.ghproxy.com/https://raw.githubusercontent.com/chaos-zhu/
--- ---
## 版本日志
- [CHANGELOG](./CHANGELOG.md) ## 安全与建议
## 开发 首先声明任何系统无法保障没有bug的存在EasyNode也一样。
1. 拉取代码,环境 `nodejs``>=20` 面板提供MFA2功能并且可配置访问此服务的IP白名单, 如需加强可以使用**iptables**进一步限制IP访问。
2. cd到项目根目录`yarn install` 执行安装依赖 如果需要更高级别的安全性,建议面板服务不要暴露到公网。
3. `yarn dev`启动项目
4. web: `http://localhost:18090/`
## QA webssh与监控服务都将以`该服务器作为中转`。中国大陆用户建议使用香港、新加坡、日本、韩国等地区的低延迟服务器来安装服务端面板
## 常见问题
- [QA](./Q%26A.md) - [QA](./Q%26A.md)
## 捐赠 <!-- ## Plus版功能
如果您认为此项目帮到了您, 您可以请我喝杯阔乐~ - 跳板机功能,拯救被墙实例与龟速终端输入
- 本地socket断开自动重连,无需手动重新连接
![wx](./doc_images/wx.jpg) - 批量修改实例配置(优化版)
- 脚本库批量导出导入
## License - 凭据管理支持解密带密码保护的密钥
- 提出的功能需求享有更高的开发优先级 -->
[MIT](LICENSE). Copyright (c).

View File

@ -1,3 +1,3 @@
module.exports = { module.exports = {
httpPort: 22022 defaultPort: 22022
} }

View File

@ -1,14 +1,15 @@
const http = require('http') const http = require('http')
const Koa = require('koa') const Koa = require('koa')
const { httpPort } = require('./config') const { defaultPort } = require('./config')
const wsOsInfo = require('./socket/monitor') const wsOsInfo = require('./socket/monitor')
const httpServer = () => { const httpServer = () => {
const app = new Koa() const app = new Koa()
const server = http.createServer(app.callback()) const server = http.createServer(app.callback())
serverHandler(app, server) serverHandler(app, server)
server.listen(httpPort, () => { const port = process.env.clientPort || defaultPort
console.log(`Server(http) is running on port:${ httpPort }`) server.listen(port, () => {
console.log(`Server(http) is running on port:${ port }`)
}) })
} }

View File

@ -5,12 +5,18 @@ if [ "$(id -u)" != "0" ] ; then
exit 1 exit 1
fi fi
clientPort=${clientPort:-22022}
SERVER_NAME=easynode-client SERVER_NAME=easynode-client
FILE_PATH=/root/local/easynode-client FILE_PATH=/root/local/easynode-client
SERVICE_PATH=/etc/systemd/system SERVICE_PATH=/etc/systemd/system
SERVER_VERSION=v1.0 CLIENT_VERSION=client-2024-10-13 # 目前监控客户端版本发布需手动更改为最新版本号
SERVER_PROXY="https://git.221022.xyz/"
echo "***********************开始安装 easynode-client_${SERVER_VERSION}***********************" if [ ! -z "$1" ]; then
clientPort=$1
fi
echo "***********************开始安装EasyNode监控客户端端,当前版本号: ${CLIENT_VERSION}, 端口: ${clientPort}***********************"
systemctl status ${SERVER_NAME} > /dev/null 2>&1 systemctl status ${SERVER_NAME} > /dev/null 2>&1
if [ $? != 4 ] if [ $? != 4 ]
@ -42,16 +48,16 @@ echo "***********************创建文件PATH***********************"
mkdir -p ${FILE_PATH} mkdir -p ${FILE_PATH}
echo "***********************下载开始***********************" echo "***********************下载开始***********************"
DOWNLOAD_SERVICE_URL="https://mirror.ghproxy.com/https://github.com/chaos-zhu/easynode/releases/download/v2.1.7/easynode-client.service"
ARCH=$(uname -m) ARCH=$(uname -m)
echo "***********************系统架构: $ARCH***********************"
if [ "$ARCH" = "x86_64" ] ; then if [ "$ARCH" = "x86_64" ] ; then
DOWNLOAD_FILE_URL="https://mirror.ghproxy.com/https://github.com/chaos-zhu/easynode/releases/download/v2.1.7/easynode-client-x86" DOWNLOAD_FILE_URL="${SERVER_PROXY}https://github.com/chaos-zhu/easynode/releases/download/${CLIENT_VERSION}/easynode-client-x64"
elif [ "$ARCH" = "aarch64" ] ; then elif [ "$ARCH" = "aarch64" ] ; then
DOWNLOAD_FILE_URL="https://mirror.ghproxy.com/https://github.com/chaos-zhu/easynode/releases/download/v2.1.7/easynode-client-arm64" DOWNLOAD_FILE_URL="${SERVER_PROXY}https://github.com/chaos-zhu/easynode/releases/download/${CLIENT_VERSION}/easynode-client-arm64"
else else
echo "未知的架构:$ARCH" echo "不支持的架构:$ARCH. 只支持x86_64和aarch64其他架构请自行构建"
exit 1 exit 1
fi fi
@ -63,6 +69,8 @@ then
exit 1 exit 1
fi fi
DOWNLOAD_SERVICE_URL="${SERVER_PROXY}https://raw.githubusercontent.com/chaos-zhu/easynode/main/client/easynode-client.service"
wget -O ${FILE_PATH}/${SERVER_NAME}.service --no-check-certificate --no-cache ${DOWNLOAD_SERVICE_URL} wget -O ${FILE_PATH}/${SERVER_NAME}.service --no-check-certificate --no-cache ${DOWNLOAD_SERVICE_URL}
if [ $? != 0 ] if [ $? != 0 ]
@ -77,6 +85,8 @@ echo "***********************下载成功***********************"
chmod +x ${FILE_PATH}/${SERVER_NAME} chmod +x ${FILE_PATH}/${SERVER_NAME}
chmod 777 ${FILE_PATH}/${SERVER_NAME}.service chmod 777 ${FILE_PATH}/${SERVER_NAME}.service
sed -i "s/clientPort=22022/clientPort=${clientPort}/g" ${FILE_PATH}/${SERVER_NAME}.service
# echo "***********************移动service&reload***********************" # echo "***********************移动service&reload***********************"
mv ${FILE_PATH}/${SERVER_NAME}.service ${SERVICE_PATH} mv ${FILE_PATH}/${SERVER_NAME}.service ${SERVICE_PATH}
@ -86,7 +96,6 @@ systemctl daemon-reload
echo "***********************启动服务***********************" echo "***********************启动服务***********************"
systemctl start ${SERVER_NAME} systemctl start ${SERVER_NAME}
# echo "***********************设置开机启动***********************" # echo "***********************设置开机启动***********************"
systemctl enable ${SERVER_NAME} systemctl enable ${SERVER_NAME}

View File

@ -1,7 +1,8 @@
[Unit] [Unit]
Description=easynode client server port_22022 Description=easynode client server
[Service] [Service]
Environment="clientPort=22022"
ExecStart=/root/local/easynode-client/easynode-client ExecStart=/root/local/easynode-client/easynode-client
WorkingDirectory=/root/local/easynode-client WorkingDirectory=/root/local/easynode-client
Restart=always Restart=always

View File

@ -9,8 +9,8 @@
"scripts": { "scripts": {
"client": "nodemon ./app/main.js", "client": "nodemon ./app/main.js",
"pkgwin": "pkg . -t node16-win-x64", "pkgwin": "pkg . -t node16-win-x64",
"pkglinux:x86": "pkg . -t node16-linux-x64 -o dist/easynode-client-x86", "pkglinux:x64": "pkg . -t node16-linux-x64 -o dist/easynode-client-x64",
"pkglinux:arm": "pkg . -t node16-linux-arm64 -o dist/easynode-client-arm64" "pkglinux:arm64": "pkg . -t node16-linux-arm64 -o dist/easynode-client-arm64"
}, },
"keywords": [], "keywords": [],
"author": "", "author": "",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 250 KiB

After

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 248 KiB

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 123 KiB

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 161 KiB

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 181 KiB

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 208 KiB

After

Width:  |  Height:  |  Size: 117 KiB

BIN
doc_images/7.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

After

Width:  |  Height:  |  Size: 900 KiB

View File

@ -22,7 +22,8 @@
"license": "ISC", "license": "ISC",
"scripts": { "scripts": {
"dev": "concurrently \"yarn workspace web run dev\" \"yarn workspace server run local\"", "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": { "bugs": {
"url": "https://github.com/chaos-zhu/easynode/issues" "url": "https://github.com/chaos-zhu/easynode/issues"

View File

@ -1,2 +1,8 @@
# 启动debug日志 0关闭 1开启 # 启动debug日志 0关闭 1开启
DEBUG=1 DEBUG=1
# 访问IP限制
allowedIPs=['127.0.0.1']
# 激活PLUS功能的授权码
PLUS_KEY=

View File

@ -28,10 +28,15 @@ module.exports = {
quotes: ['error', 'single'], // 引号single单引 double双引 quotes: ['error', 'single'], // 引号single单引 double双引
semi: ['error', 'never'], // 结尾分号never禁止 always必须 semi: ['error', 'never'], // 结尾分号never禁止 always必须
'comma-dangle': ['error', 'never'], // 对象拖尾逗号 'comma-dangle': ['error', 'never'], // 对象拖尾逗号
'space-before-blocks': ['error', 'always'],
'space-in-parens': ['error', 'never'],
'keyword-spacing': ['error', { 'before': true, 'after': true }],
'no-redeclare': ['error', { builtinGlobals: true }], // 禁止重复对象声明 'no-redeclare': ['error', { builtinGlobals: true }], // 禁止重复对象声明
'no-multi-assign': 0, 'no-multi-assign': 0,
'no-restricted-globals': 0, 'no-restricted-globals': 0,
'no-case-declarations': 0,
'space-before-function-paren': 0, // 函数定义时括号前面空格 'space-before-function-paren': 0, // 函数定义时括号前面空格
'no-async-promise-executor': 0, // 允许在回调中使用async函数
'one-var': 0, // 允许连续声明 'one-var': 0, // 允许连续声明
// 'no-undef': 0, // 允许未定义的变量【会使env配置无效】 // 'no-undef': 0, // 允许未定义的变量【会使env配置无效】
'linebreak-style': 0, // 检测CRLF/LF检测【默认LF】 'linebreak-style': 0, // 检测CRLF/LF检测【默认LF】

View File

@ -4,18 +4,20 @@ consola.info('debug日志', process.env.DEBUG === '1' ? '开启' : '关闭')
module.exports = { module.exports = {
httpPort: 8082, httpPort: 8082,
clientPort: 22022, // 暂不支持更改 defaultClientPort: 22022,
uploadDir: path.join(process.cwd(),'app/db'), uploadDir: path.join(process.cwd(),'app/db'),
staticDir: path.join(process.cwd(),'app/static'), staticDir: path.join(process.cwd(),'app/static'),
sftpCacheDir: path.join(process.cwd(),'app/socket/sftp-cache'), sftpCacheDir: path.join(process.cwd(),'app/socket/sftp-cache'),
credentialsDBPath: path.join(process.cwd(),'app/db/credentials.db'), credentialsDBPath: path.join(process.cwd(),'app/db/credentials.db'),
keyDBPath: path.join(process.cwd(),'app/db/key.db'), keyDBPath: path.join(process.cwd(),'app/db/key.db'),
hostListDBPath: path.join(process.cwd(),'app/db/host.db'), hostListDBPath: path.join(process.cwd(),'app/db/host.db'),
notifyConfDBPath: path.join(process.cwd(),'app/db/notify.db'),
groupConfDBPath: path.join(process.cwd(),'app/db/group.db'), groupConfDBPath: path.join(process.cwd(),'app/db/group.db'),
emailNotifyDBPath: path.join(process.cwd(),'app/db/email.db'),
scriptsDBPath: path.join(process.cwd(),'app/db/scripts.db'), scriptsDBPath: path.join(process.cwd(),'app/db/scripts.db'),
notifyDBPath: path.join(process.cwd(),'app/db/notify.db'),
notifyConfigDBPath: path.join(process.cwd(),'app/db/notify-config.db'),
onekeyDBPath: path.join(process.cwd(),'app/db/onekey.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', apiPrefix: '/api/v1',
logConfig: { logConfig: {
outDir: path.join(process.cwd(),'./app/logs'), outDir: path.join(process.cwd(),'./app/logs'),

View File

@ -1,17 +1,22 @@
[ [
{ {
"name": "easynode监控服务安装", "name": "easynode监控服务安装",
"command": "curl -o- https://mirror.ghproxy.com/https://raw.githubusercontent.com/chaos-zhu/easynode/main/client/easynode-client-install.sh | bash", "command": "curl -o- https://git.221022.xyz/https://raw.githubusercontent.com/chaos-zhu/easynode/main/client/easynode-client-install.sh | bash",
"description": "easynode-监控服务-安装脚本" "description": "easynode-监控服务-安装脚本"
}, },
{ {
"name": "easynode监控服务卸载", "name": "easynode监控服务卸载",
"command": "curl -o- https://mirror.ghproxy.com/https://raw.githubusercontent.com/chaos-zhu/easynode/main/client/easynode-client-uninstall.sh | bash", "command": "curl -o- https://git.221022.xyz/https://raw.githubusercontent.com/chaos-zhu/easynode/main/client/easynode-client-uninstall.sh | bash",
"description": "easynode-监控服务-卸载脚本" "description": "easynode-监控服务-卸载脚本"
}, },
{ {
"name": "查询本机公网IP", "name": "查询本机公网IP",
"command": "curl ifconfig.me", "command": "curl ifconfig.me",
"description": "查询本机公网IP" "description": "查询本机公网IP"
},
{
"name": "生成ssh密钥对",
"command": "ssh-keygen -t rsa -b 2048",
"description": "生成ssh密钥对"
} }
] ]

View File

@ -1,10 +1,11 @@
const { readGroupList, writeGroupList, readHostList, writeHostList, randomStr } = require('../utils') const { HostListDB, GroupDB } = require('../utils/db-class')
const hostListDB = new HostListDB().getInstance()
const groupDB = new GroupDB().getInstance()
async function getGroupList({ res }) { async function getGroupList({ res }) {
let data = await readGroupList() let data = await groupDB.findAsync({})
data = data.map(item => { data = data.map(item => ({ ...item, id: item._id }))
return { ...item, id: item._id }
})
data?.sort((a, b) => Number(b.index || 0) - Number(a.index || 0)) data?.sort((a, b) => Number(b.index || 0) - Number(a.index || 0))
res.success({ data }) res.success({ data })
} }
@ -12,10 +13,8 @@ async function getGroupList({ res }) {
const addGroupList = async ({ res, request }) => { const addGroupList = async ({ res, request }) => {
let { body: { name, index } } = request let { body: { name, index } } = request
if (!name) return res.fail({ data: false, msg: '参数错误' }) if (!name) return res.fail({ data: false, msg: '参数错误' })
let groupList = await readGroupList()
let group = { name, index } let group = { name, index }
groupList.push(group) await groupDB.insertAsync(group)
await writeGroupList(groupList)
res.success({ data: '添加成功' }) res.success({ data: '添加成功' })
} }
@ -23,34 +22,26 @@ const updateGroupList = async ({ res, request }) => {
let { params: { id } } = request let { params: { id } } = request
let { body: { name, index } } = request let { body: { name, index } } = request
if (!id || !name) return res.fail({ data: false, msg: '参数错误' }) if (!id || !name) return res.fail({ data: false, msg: '参数错误' })
let groupList = await readGroupList() let target = await groupDB.findOneAsync({ _id: id })
let idx = groupList.findIndex(item => item._id === id) if (!target) return res.fail({ data: false, msg: `分组ID${ id }不存在` })
if (idx === -1) return res.fail({ data: false, msg: `分组ID${ id }不存在` }) await groupDB.updateAsync({ _id: id }, { name, index: Number(index) || 0 })
const { _id } = groupList[idx]
let group = { _id, name, index: Number(index) || 0 }
groupList.splice(idx, 1, group)
await writeGroupList(groupList)
res.success({ data: '修改成功' }) res.success({ data: '修改成功' })
} }
const removeGroup = async ({ res, request }) => { const removeGroup = async ({ res, request }) => {
let { params: { id } } = request let { params: { id } } = request
if (id === 'default') return res.fail({ data: false, msg: '保留分组, 禁止删除' }) if (id === 'default') return res.fail({ data: false, msg: '保留分组, 禁止删除' })
let groupList = await readGroupList()
let idx = groupList.findIndex(item => item._id === id)
if (idx === -1) return res.fail({ msg: '分组不存在' })
// 移除分组将所有该分组下host分配到default中去 // 移除分组将所有该分组下host分配到default中去
let hostList = await readHostList() let hostList = await hostListDB.findAsync({})
hostList = hostList?.map((item) => { if (Array.isArray(hostList) && hostList.length > 0) {
if (item.group === groupList[idx]._id) item.group = 'default' for (let item of hostList) {
return item if (item.group === id) {
}) item.group = 'default'
await writeHostList(hostList) await hostListDB.updateAsync({ _id: item._id }, item)
}
groupList.splice(idx, 1) }
await writeGroupList(groupList) }
await groupDB.removeAsync({ _id: id })
res.success({ data: '移除成功' }) res.success({ data: '移除成功' })
} }

View File

@ -1,15 +1,17 @@
const { readHostList, writeHostList, RSADecryptSync, AESEncryptSync, AESDecryptSync } = require('../utils') 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 }) { async function getHostList({ res }) {
// console.log('get-host-list') let data = await hostListDB.findAsync({})
let data = await readHostList()
data?.sort((a, b) => Number(b.index || 0) - Number(a.index || 0)) data?.sort((a, b) => Number(b.index || 0) - Number(a.index || 0))
for (const item of data) { for (const item of data) {
try { try {
let { username, port, authType, _id: id, credential } = item let { authType, _id: id, credential } = item
// console.log('解密凭证title: ', credential) if (credential) credential = await AESDecryptAsync(credential)
if (credential) credential = await AESDecryptSync(credential) const isConfig = Boolean(authType && item[authType])
const isConfig = Boolean(username && port && (item[authType]))
Object.assign(item, { id, isConfig, password: '', privateKey: '', credential }) Object.assign(item, { id, isConfig, password: '', privateKey: '', credential })
} catch (error) { } catch (error) {
consola.error('getHostList error: ', error.message) consola.error('getHostList error: ', error.message)
@ -18,125 +20,66 @@ async function getHostList({ res }) {
res.success({ data }) res.success({ data })
} }
async function addHost({ async function addHost({ res, request }) {
res, request let { body } = request
}) { if (!body.name || !body.host) return res.fail({ msg: 'missing params: name or host' })
let { let newRecord = { ...body }
body: { const { authType, tempKey } = newRecord
name, host: newHost, index, expired, expiredNotify, group, consoleUrl, remark, if (newRecord[authType] && tempKey) {
port, username, authType, password, privateKey, credential, command, tempKey const clearTempKey = await RSADecryptAsync(tempKey)
const clearSSHKey = await AESDecryptAsync(newRecord[authType], clearTempKey)
newRecord[authType] = await AESEncryptAsync(clearSSHKey)
} }
} = request await hostListDB.insertAsync(newRecord)
// console.log(request)
if (!newHost || !name) return res.fail({ msg: 'missing params: name or host' })
let hostList = await readHostList()
if (hostList?.some(({ host }) => host === newHost)) return res.fail({ msg: `主机${ newHost }已存在` })
let record = {
name, host: newHost, index, expired, expiredNotify, group, consoleUrl, remark,
port, username, authType, password, privateKey, credential, command
}
if (record[authType]) {
const clearTempKey = await RSADecryptSync(tempKey)
console.log('clearTempKey:', clearTempKey)
const clearSSHKey = await AESDecryptSync(record[authType], clearTempKey)
console.log(`${ authType }原密文: `, clearSSHKey)
record[authType] = await AESEncryptSync(clearSSHKey)
console.log(`${ authType }__commonKey加密存储: `, record[authType])
}
hostList.push(record)
await writeHostList(hostList)
res.success() res.success()
} }
async function updateHost({ res, request }) { async function updateHost({ res, request }) {
let { let {
body: { body
hosts,
host: newHost, name: newName, index, oldHost, expired, expiredNotify, group, consoleUrl, remark,
port, username, authType, password, privateKey, credential, command, tempKey
}
} = request } = request
let isBatch = Array.isArray(hosts) if (typeof body !== 'object') return res.fail({ msg: '参数错误' })
if (isBatch) { const updateFiled = { ...body }
if (!hosts.length) return res.fail({ msg: 'hosts为空' }) const { id, authType, tempKey } = updateFiled
let hostList = await readHostList() if (authType && updateFiled[authType]) {
// console.log('批量修改实例') const clearTempKey = await RSADecryptAsync(tempKey)
let newHostList = [] const clearSSHKey = await AESDecryptAsync(updateFiled[authType], clearTempKey)
for (let oldRecord of hostList) { updateFiled[authType] = await AESEncryptAsync(clearSSHKey)
let record = hosts.find(item => item.host === oldRecord.host) delete updateFiled.tempKey
if (!record) {
newHostList.push(oldRecord)
continue
}
let { authType } = record
// 如果存在原认证方式则保存下来
if (!record[authType] && oldRecord[authType]) {
record[authType] = oldRecord[authType]
} else { } else {
const clearTempKey = await RSADecryptSync(record.tempKey) delete updateFiled.authType
// console.log('批量解密tempKey:', clearTempKey) delete updateFiled.password
const clearSSHKey = await AESDecryptSync(record[authType], clearTempKey) delete updateFiled.privateKey
// console.log(`${ authType }原密文: `, clearSSHKey) delete updateFiled.credential
record[authType] = await AESEncryptSync(clearSSHKey)
// console.log(`${ authType }__commonKey加密存储: `, record[authType])
} }
newHostList.push(Object.assign(oldRecord, record)) console.log('updateFiled: ', updateFiled)
} await hostListDB.updateAsync({ _id: id }, { $set: { ...updateFiled } })
await writeHostList(newHostList) res.success({ msg: '修改成功' })
return res.success({ msg: '批量修改成功' })
}
if (!newHost || !newName || !oldHost) return res.fail({ msg: '参数错误' })
let hostList = await readHostList()
let record = {
name: newName, host: newHost, index, expired, expiredNotify, group, consoleUrl, remark,
port, username, authType, password, privateKey, credential, command
}
if (!hostList.some(({ host }) => host === oldHost)) return res.fail({ msg: `原实例[${ oldHost }]不存在,请尝试添加实例` })
let idx = hostList.findIndex(({ host }) => host === oldHost)
const oldRecord = hostList[idx]
// 如果存在原认证方式则保存下来
if (!record[authType] && oldRecord[authType]) {
record[authType] = oldRecord[authType]
} else {
const clearTempKey = await RSADecryptSync(tempKey)
// console.log('clearTempKey:', clearTempKey)
const clearSSHKey = await AESDecryptSync(record[authType], clearTempKey)
// console.log(`${ authType }原密文: `, clearSSHKey)
record[authType] = await AESEncryptSync(clearSSHKey)
// console.log(`${ authType }__commonKey加密存储: `, record[authType])
}
hostList.splice(idx, 1, record)
writeHostList(hostList)
res.success()
} }
async function removeHost({ async function batchUpdateHost({ res, request }) {
res, request let { updateHosts } = (await decryptAndExecuteAsync(path.join(__dirname, 'plus.js'))) || {}
}) { if (updateHosts) {
let { body: { host } } = request await updateHosts({ res, request })
let hostList = await readHostList()
if (Array.isArray(host)) {
hostList = hostList.filter(item => !host.includes(item.host))
// if (hostList.length === 0) return res.fail({ msg: '没有可删除的实例' })
} else { } else {
let hostIdx = hostList.findIndex(item => item.host === host) return res.fail({ data: false, msg: 'Plus专属功能!' })
if (hostIdx === -1) return res.fail({ msg: `${ host }不存在` })
hostList.splice(hostIdx, 1)
} }
writeHostList(hostList)
res.success({ data: '已移除' })
} }
async function importHost({ async function removeHost({ res, request }) {
res, request let { body: { ids } } = request
}) { if (!Array.isArray(ids)) return res.fail({ msg: '参数错误' })
const numRemoved = await hostListDB.removeAsync({ _id: { $in: ids } }, { multi: true })
res.success({ data: `已移除,数量: ${ numRemoved }` })
}
async function importHost({ res, request }) {
let { body: { importHost, isEasyNodeJson = false } } = request let { body: { importHost, isEasyNodeJson = false } } = request
if (!Array.isArray(importHost)) return res.fail({ msg: '参数错误' }) if (!Array.isArray(importHost)) return res.fail({ msg: '参数错误' })
let hostList = await readHostList() let hostList = await hostListDB.findAsync({})
// 过滤已存在的host // 考虑到批量导入可能会重复太多,先过滤已存在的host:port
let hostListSet = new Set(hostList.map(item => item.host)) let hostListSet = new Set(hostList.map(({ host, port }) => `${ host }:${ port }`))
let newHostList = importHost.filter(item => !hostListSet.has(item.host)) let newHostList = importHost.filter(({ host, port }) => !hostListSet.has(`${ host }:${ port }`))
let newHostListLen = newHostList.length let newHostListLen = newHostList.length
if (newHostListLen === 0) return res.fail({ msg: '导入的实例已存在' }) if (newHostListLen === 0) return res.fail({ msg: '导入的实例已存在' })
@ -158,10 +101,8 @@ async function importHost({
item.index = newHostListLen - index item.index = newHostListLen - index
return Object.assign(item, { ...extraFiels }) return Object.assign(item, { ...extraFiels })
}) })
} }
hostList.push(...newHostList) await hostListDB.insertAsync(newHostList)
writeHostList(hostList)
res.success({ data: { len: newHostList.length } }) res.success({ data: { len: newHostList.length } })
} }
@ -170,5 +111,6 @@ module.exports = {
addHost, addHost,
updateHost, updateHost,
removeHost, removeHost,
importHost importHost,
batchUpdateHost
} }

View File

@ -0,0 +1,17 @@
const { LogDB } = require('../utils/db-class')
const logDB = new LogDB().getInstance()
let whiteList = process.env.ALLOWED_IPS ? process.env.ALLOWED_IPS.split(',') : []
async function getLog({ res }) {
let list = await logDB.findAsync({})
list = list.map(item => {
return { ...item, id: item._id }
})
list?.sort((a, b) => Number(b.date) - Number(a.date))
res.success({ data: { list, whiteList } })
}
module.exports = {
getLog
}

View File

@ -1,89 +1,56 @@
const { const path = require('path')
readSupportEmailList, const decryptAndExecuteAsync = require('../utils/decrypt-file')
readUserEmailList, const { sendServerChan, sendEmail } = require('../utils/notify')
writeUserEmailList, const { NotifyConfigDB, NotifyDB } = require('../utils/db-class')
emailTransporter, const notifyDB = new NotifyDB().getInstance()
readNotifyList, const notifyConfigDB = new NotifyConfigDB().getInstance()
writeNotifyList } = require('../utils')
const commonTemp = require('../template/commonTemp')
async function getSupportEmailList({ res }) { async function getNotifyConfig({ res }) {
const data = await readSupportEmailList() const data = await notifyConfigDB.findOneAsync({})
res.success({ data }) return res.success({ data })
} }
async function getUserEmailList({ res }) { async function updateNotifyConfig({ res, request }) {
const userEmailList = (await readUserEmailList()).map(({ target, auth: { user } }) => ({ target, user })) let { body: { noticeConfig } } = request
const supportEmailList = await readSupportEmailList() let { type } = noticeConfig
const data = userEmailList.map(({ target: userTarget, user: email }) => { try {
let name = supportEmailList.find(({ target: supportTarget }) => supportTarget === userTarget).name // console.log('noticeConfig: ', noticeConfig[type])
return { name, email } switch (type) {
}) case 'sct':
res.success({ data }) await sendServerChan(noticeConfig[type]['sendKey'], 'EasyNode通知测试', '这是一条测试通知')
break
case 'email':
await sendEmail(noticeConfig[type], 'EasyNode通知测试', '这是一条测试通知')
break
case 'tg':
let { sendTg } = await decryptAndExecuteAsync(path.join(__dirname, '../utils/plus.js')) || {}
console.log('sendTg: ', sendTg)
if (!sendTg) return res.fail({ msg: 'Plus专属功能点请激活Plus' })
await sendTg(noticeConfig[type], 'EasyNode通知测试', '这是一条测试通知')
break
} }
await notifyConfigDB.update({}, { $set: noticeConfig }, { upsert: true })
async function pushEmail({ res, request }) { return res.success({ msg: '测试通过 | 保存成功' })
let { body: { toEmail, isTest } } = request } catch (error) {
if (!isTest) return res.fail({ msg: '此接口暂时只做测试邮件使用, 需传递参数isTest: true' }) return res.fail({ msg: error.message })
consola.info('发送测试邮件:', toEmail)
let { code, msg } = await emailTransporter({ toEmail, title: '测试邮件', html: commonTemp('邮件通知测试邮件') })
msg = msg && msg.message || msg
if (code === 0) return res.success({ msg })
return res.fail({ msg })
} }
async function updateUserEmailList({ res, request }) {
let { body: { target, auth } } = request
const supportList = await readSupportEmailList()
let flag = supportList.some((item) => item.target === target)
if (!flag) return res.fail({ msg: `不支持的邮箱类型:${ target }` })
if (!auth.user || !auth.pass) return res.fail({ msg: 'missing params: auth.' })
let newUserEmail = { target, auth }
let userEmailList = await readUserEmailList()
let idx = userEmailList.findIndex(({ auth: { user } }) => auth.user === user)
if (idx !== -1) userEmailList.splice(idx, 1, newUserEmail)
else userEmailList.unshift(newUserEmail)
const { code, msg } = await writeUserEmailList(userEmailList)
if (code === 0) return res.success()
return res.fail({ msg })
}
async function removeUserEmail({ res, request }) {
let { params: { email } } = request
const userEmailList = await readUserEmailList()
let idx = userEmailList.findIndex(({ auth: { user } }) => user === email)
if (idx === -1) return res.fail({ msg: `删除失败, 不存在该邮箱:${ email }` })
userEmailList.splice(idx, 1)
const { code, msg } = await writeUserEmailList(userEmailList)
if (code === 0) return res.success()
return res.fail({ msg })
} }
async function getNotifyList({ res }) { async function getNotifyList({ res }) {
const data = await readNotifyList() const data = await notifyDB.findAsync({})
res.success({ data }) res.success({ data })
} }
async function updateNotifyList({ res, request }) { async function updateNotifyList({ res, request }) {
let { body: { type, sw } } = request let { body: { type, sw } } = request
if (!([true, false].includes(sw))) return res.fail({ msg: `Error type for sw${ sw }, must be Boolean` }) if (!([true, false].includes(sw))) return res.fail({ msg: `Error type for sw${ sw }, must be Boolean` })
const notifyList = await readNotifyList() await notifyDB.updateAsync({ type }, { $set: { sw } })
let target = notifyList.find((item) => item.type === type)
if (!target) return res.fail({ msg: `更新失败, 不存在该通知类型:${ type }` })
target.sw = sw
// console.log(notifyList)
await writeNotifyList(notifyList)
res.success() res.success()
} }
module.exports = { module.exports = {
pushEmail, getNotifyConfig,
getSupportEmailList, updateNotifyConfig,
getUserEmailList,
updateUserEmailList,
removeUserEmail,
getNotifyList, getNotifyList,
updateNotifyList updateNotifyList
} }

View File

@ -1,7 +1,8 @@
const { readOneKeyRecord, deleteOneKeyRecord } = require('../utils') const { OnekeyDB } = require('../utils/db-class')
const onekeyDB = new OnekeyDB().getInstance()
async function getOnekeyRecord({ res }) { async function getOnekeyRecord({ res }) {
let data = await readOneKeyRecord() let data = await onekeyDB.findAsync({})
data = data.map(item => { data = data.map(item => {
return { ...item, id: item._id } return { ...item, id: item._id }
}) })
@ -11,14 +12,11 @@ async function getOnekeyRecord({ res }) {
const removeOnekeyRecord = async ({ res, request }) => { const removeOnekeyRecord = async ({ res, request }) => {
let { body: { ids } } = request let { body: { ids } } = request
let onekeyRecord = await readOneKeyRecord()
if (ids === 'ALL') { if (ids === 'ALL') {
ids = onekeyRecord.map(item => item._id) await onekeyDB.removeAsync({}, { multi: true })
await deleteOneKeyRecord(ids)
res.success({ data: '移除全部成功' }) res.success({ data: '移除全部成功' })
} else { } else {
if (!onekeyRecord.some(item => ids.includes(item._id))) return res.fail({ msg: '批量指令记录ID不存在' }) await onekeyDB.removeAsync({ _id: { $in: ids } })
await deleteOneKeyRecord(ids)
res.success({ data: '移除成功' }) res.success({ data: '移除成功' })
} }
} }

View File

@ -0,0 +1 @@
U2FsdGVkX18Hh5ifqReKzxVcNwA8NC2cGnvuPCHW9V4+sVMxFFE7NxliY3R9Pyu2jZvnRb80+VpkEinfaZX0H1xx+I5PU2/mqIUU+1yxKrmWQtwJm6EwNwyDFrj3Epbl1zkfTUXLhk1a5lff+s1Qic02SbnLMtThV9Pg2m6w7HeJJiYOdaRFGlHvgGL4m7O9Ps135wdsdLU9y5aRiXF+1fi35Y6ZlDwPJGEMfZyIQKF87QksAW6LOP/Y1+mgIfLS6WwJnf8kW4l0KQktfvmsWtn00neZRQJc9I6WVMEN2jq4vbeE0KqtoOV0B/+Y/nLFnJjSYs5VE4qQ3gTFzuHe/dPoWXcBX5J5RhAxeY1qVQUtgKxUVwnBeGyjCmM7scX001AoxMcZFnpl+rx1ccOHYF2wB8GsuhsRlAAgWiyPXVJFSMYW3mFm61wvy1dWFad+kNYNFJo+SW8YUSkUCs3sXHXHn8eFsy75ChgHqMp1hvvyug8eFVPwp3IgtLK1D1Et096h8EhhvCvR7VecWwFi4AeMvuZWSmn+gkgGinx9zKUjkA5Bi65tyXmCa4ozyoi+TtuWKqJZyRQ8K2Kw0fc1AUCN8Cp/89Omb9thA10lvVtEJ+k1anao1llY9tPJsYlb0lNGYUlff29cDQnKIbV8P9mHXAyjRJatypWfLPfvqBT81iEDdB5dMASgm3gZqQPrSE50hBsCjzeNaCQF32TPfEFeOWRS1M1tOFpjanJZwfUreMLR77lANkSjiPYOgUvSzgAu0JVIehjXW2vYhC3+Sg7ETbdeV74pAx+Tc8qNWPyZtbNvdg+5wegr5ICgvXObf/btDUL9Jl7x+x7SY7dDrDj6AJRQROcUCdtNisG8HBKnvWS8nqNaUmR7d2E8pQ6qEFKX1ISvkxUp5RTD+9Vos0BfL4+mUB9iovxhDTfSXCIdJa69obTvvLD9xOJvNDrd72zLTQZSI74i/cFeNlersYiQAgL26oyqkv1eFL7Xd3bzq24EbZjP3hrBEqktW5qFeUAe8cPuA3bwDQwGI5BGkQ2hsS7G8xvx0dwllUOE3XVjxEuH8hkGO/GfFdqPRHfkizoNu1yNQEQeY6s9cMp5ovY20YIPRl8bhakjcUtUjMqee5kDdmScELKzoam8TwNiTBrBiuCwA2DcaC6dWDgOjhRs1Y4LEiQ8KZptuO/zTbJc5qcoKA6CUiVTN7vD4u5DHN60mGU9hoS46hfCe++U6L5FR4lafjRdUR0qkCEtf2SKnXyWqLUTgS2kNLQr4ZZbLMi7Mm+5+Q1JIIjzqqfjOlzeO9T4F9lknUkFXD5bc0Q5g+it89KG8xDbISUznv/UTXSxh485VKecT9Cjgd7I438N6xeL1CcJZiluLOvZ0Z3FDxkrW4Tmwbi852Z7tghFAamMW7GPL8LJRt5q2fhe0/U5oKBuGglRvga2tju3wBfzQdpavyNVyRjN2pywO4fk3qhez0suF9wVOc6GU9PUU2jCRm/gEF/qrj32tUjpDbxS9D1nCs441La7bYV8eCtb+2pEjgvjtIp+BM0lz+aHnseKT/iUGHlubKhrTMJQ7jEAPKtcl2OpS4fXiVIiy0qK4rI8S/vkdcRd07H77FfPDqEHTxTMQhHMGqi+d+YpFgrXSin4vcn8KXS87MEILjn5kmUDOsXAWZCqlD3oQ5ADVt91R+Ty5DcIZgaiQkB1aq6feIfSx4rsioNCOgFqmx4mcCds4Ar6gzsRdXN4Kcw8plrrePttZLyNOleoIX5Diy3GAiq6ENCkbYtsaic5EqCQ6AV5qBzEDu0DKZkdqxUWd0wf5+gJwEFQAMj+lD/UhlHuD8ArSI56jYQUbrcfdLnXutfrNA2Ogte9RltQxiUb6N90uNW1rT/2vlUgmQgbvZriKqpm+K3CZ9+6zsCDSUgr/cJmkSvu5gIpvC81IAQX6K8sUqtc9l3vn5vEvqqIp2yb5N25xs0NB0/yglyAHgXLXbG/sE73TrRMj4W+3HGlF35YSQnsLcyzvqEIoAhjngDf/6HXCkNpUQjyc8+uzIsKTh73WV9rh1/7xoY0lxHGabI+c8j5+WlWD1K0Xec83Sodqf+XStr90w1ceK73/DZGdgJIbdKfgO4Xn9ZY8AlzbeJq0W2/WWi/nPE9UZtVK6EEuOcmG2L5/gv2hTMjko6KG+ygrn0+bSvClXL51Brq7IvfO9mMlAGV8zK8vp82RM0KH38xPaJGTHbdawB1gaatkXywzXw0YTmzfaswt46WcWlLZ8vgr01zMp7pfp6A4GAT952rSprlfE014osCZj2oe+j2FQ0QOIYPSj3IatoqlDGfMOxPAbId8sx3anls9Zbk4feeVEvy0+VEmeZVIyDSzjZWuQYQ7VQLEcyaARRtOnfDYt2STIXy61ScWepdj1tmuhw/Kc0Aov61tEZ1apHHxrugzmN96A/2FST2KkbCtsYvbBqE9bZ3F4dLAfVazWidSQv4wPKgkZHFY94jlXxkN0dkA0yildyiQC5k3Iiw3zSwZO9a91K8uSQbbL54C4Y7aCW1HG//OabzNSg9Qty5a1hoiovpCiziAc3xoxuT+75ICozxKLG8+UN3vEZ2QXMv3b/qlXhRr7t8LtlFiA9nmUMfCAieovrZSB4OzrKHe37mg17USWsF1by73YTriFRTiE7JO5E6GMFz3bloppT64svf0SHgFELOuc4xclZfJTYAhLLxkiwDzmKWWheEz5TOOL/8p+5n7+/AuffGykVu6NlmSXH1uIg9JYNUy6UFnd2vOhx+8DxSVFd+1VdW+u2zpPAgiFAiNZJGx+6BVS05kO2mQ++0BHlmbXTw2tdt/BF1N07J5kIY0yRqrMtlwAb6cNbb+yWHkYX/C+3MDLBd

View File

@ -1,12 +1,16 @@
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 localShellJson = require('../config/shell.json')
const { readScriptList, writeScriptList, randomStr } = require('../utils') const scriptsDB = new ScriptsDB().getInstance()
let localShell = JSON.parse(JSON.stringify(localShellJson)).map((item) => { let localShell = JSON.parse(JSON.stringify(localShellJson)).map((item) => {
return { ...item, id: randomStr(10), index: '--', description: item.description + '|内置脚本' } return { ...item, id: randomStr(10), index: '--', description: item.description + '|内置脚本' }
}) })
async function getScriptList({ res }) { async function getScriptList({ res }) {
let data = await readScriptList() let data = await scriptsDB.findAsync({})
data = data.map(item => { data = data.map(item => {
return { ...item, id: item._id } return { ...item, id: item._id }
}) })
@ -23,10 +27,8 @@ const addScript = async ({ res, request }) => {
let { body: { name, description, command, index } } = request let { body: { name, description, command, index } } = request
if (!name || !command) return res.fail({ data: false, msg: '参数错误' }) if (!name || !command) return res.fail({ data: false, msg: '参数错误' })
index = Number(index) || 0 index = Number(index) || 0
let scriptsList = await readScriptList()
let record = { name, description, command, index } let record = { name, description, command, index }
scriptsList.push(record) await scriptsDB.insertAsync(record)
await writeScriptList(scriptsList)
res.success({ data: '添加成功' }) res.success({ data: '添加成功' })
} }
@ -34,30 +36,38 @@ const updateScriptList = async ({ res, request }) => {
let { params: { id } } = request let { params: { id } } = request
let { body: { name, description, command, index } } = request let { body: { name, description, command, index } } = request
if (!name || !command) return res.fail({ data: false, msg: '参数错误' }) if (!name || !command) return res.fail({ data: false, msg: '参数错误' })
let scriptsList = await readScriptList() await scriptsDB.updateAsync({ _id: id }, { name, description, command, index })
let idx = scriptsList.findIndex(item => item._id === id)
if (idx === -1) return res.fail({ data: false, msg: `脚本ID${ id }不存在` })
const { _id } = scriptsList[idx]
let record = Object.assign({ _id }, { name, description, command, index })
scriptsList.splice(idx, 1, record)
await writeScriptList(scriptsList)
res.success({ data: '修改成功' }) res.success({ data: '修改成功' })
} }
const removeScript = async ({ res, request }) => { const removeScript = async ({ res, request }) => {
let { params: { id } } = request let { params: { id } } = request
let scriptsList = await readScriptList() await scriptsDB.removeAsync({ _id: id })
let idx = scriptsList.findIndex(item => item._id === id)
if (idx === -1) return res.fail({ msg: '脚本ID不存在' })
scriptsList.splice(idx, 1)
await writeScriptList(scriptsList)
res.success({ data: '移除成功' }) 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 = { module.exports = {
addScript, addScript,
getScriptList, getScriptList,
getLocalScriptList, getLocalScriptList,
updateScriptList, updateScriptList,
removeScript removeScript,
batchRemoveScript,
importScript
} }

View File

@ -1,8 +1,12 @@
const { readSSHRecord, writeSSHRecord, readHostList, writeHostList, RSADecryptSync, AESEncryptSync, AESDecryptSync } = require('../utils') 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 }) { async function getSSHList({ res }) {
// console.log('get-host-list') let data = await credentialsDB.findAsync({})
let data = await readSSHRecord()
data = data?.map(item => { data = data?.map(item => {
const { name, authType, _id: id, date } = item const { name, authType, _id: id, date } = item
return { id, name, authType, privateKey: '', password: '', date } return { id, name, authType, privateKey: '', password: '', date }
@ -15,18 +19,16 @@ const addSSH = async ({ res, request }) => {
let { body: { name, authType, password, privateKey, tempKey } } = request let { body: { name, authType, password, privateKey, tempKey } } = request
let record = { name, authType, password, privateKey } let record = { name, authType, password, privateKey }
if (!name || !record[authType]) return res.fail({ data: false, msg: '参数错误' }) if (!name || !record[authType]) return res.fail({ data: false, msg: '参数错误' })
let sshRecord = await readSSHRecord() let count = await credentialsDB.countAsync({ name })
if (sshRecord.some(item => item.name === name)) return res.fail({ data: false, msg: '已存在同名凭证' }) if (count > 0) return res.fail({ data: false, msg: '已存在同名凭证' })
const clearTempKey = await RSADecryptSync(tempKey) const clearTempKey = await RSADecryptAsync(tempKey)
console.log('clearTempKey:', clearTempKey) console.log('clearTempKey:', clearTempKey)
const clearSSHKey = await AESDecryptSync(record[authType], clearTempKey) const clearSSHKey = await AESDecryptAsync(record[authType], clearTempKey)
// console.log(`${ authType }原密文: `, clearSSHKey) // console.log(`${ authType }原密文: `, clearSSHKey)
record[authType] = await AESEncryptSync(clearSSHKey) record[authType] = await AESEncryptAsync(clearSSHKey)
console.log(`${ authType }__commonKey加密存储: `, record[authType]) // console.log(`${ authType }__commonKey加密存储: `, record[authType])
await credentialsDB.insertAsync({ ...record, date: Date.now() })
sshRecord.push({ ...record, date: Date.now() })
await writeSSHRecord(sshRecord)
consola.info('添加凭证:', name) consola.info('添加凭证:', name)
res.success({ data: '保存成功' }) res.success({ data: '保存成功' })
} }
@ -35,58 +37,66 @@ const updateSSH = async ({ res, request }) => {
let { body: { id, name, authType, password, privateKey, date, tempKey } } = request let { body: { id, name, authType, password, privateKey, date, tempKey } } = request
let record = { name, authType, password, privateKey, date } let record = { name, authType, password, privateKey, date }
if (!id || !name) return res.fail({ data: false, msg: '请输入凭据名称' }) if (!id || !name) return res.fail({ data: false, msg: '请输入凭据名称' })
let sshRecord = await readSSHRecord() let oldRecord = await credentialsDB.findOneAsync({ _id: id })
let idx = sshRecord.findIndex(item => item._id === id) if (!oldRecord) return res.fail({ data: false, msg: '凭证不存在' })
if (sshRecord.some(item => item.name === name && item.date !== date)) return res.fail({ data: false, msg: '已存在同名凭证' })
if(idx === -1) res.fail({ data: false, msg: '请输入凭据名称' })
const oldRecord = sshRecord[idx]
// 判断原记录是否存在当前更新记录的认证方式 // 判断原记录是否存在当前更新记录的认证方式
if (!oldRecord[authType] && !record[authType]) return res.fail({ data: false, msg: `请输入${ authType === 'password' ? '密码' : '密钥' }` }) if (!oldRecord[authType] && !record[authType]) return res.fail({ data: false, msg: `请输入${ authType === 'password' ? '密码' : '密钥' }` })
if (!record[authType] && oldRecord[authType]) { if (!record[authType] && oldRecord[authType]) {
record[authType] = oldRecord[authType] record[authType] = oldRecord[authType]
} else { } else {
const clearTempKey = await RSADecryptSync(tempKey) const clearTempKey = await RSADecryptAsync(tempKey)
console.log('clearTempKey:', clearTempKey) console.log('clearTempKey:', clearTempKey)
const clearSSHKey = await AESDecryptSync(record[authType], clearTempKey) const clearSSHKey = await AESDecryptAsync(record[authType], clearTempKey)
// console.log(`${ authType }原密文: `, clearSSHKey) // console.log(`${ authType }原密文: `, clearSSHKey)
record[authType] = await AESEncryptSync(clearSSHKey) record[authType] = await AESEncryptAsync(clearSSHKey)
console.log(`${ authType }__commonKey加密存储: `, record[authType]) // console.log(`${ authType }__commonKey加密存储: `, record[authType])
} }
record._id = sshRecord[idx]._id await credentialsDB.updateAsync({ _id: id }, record)
sshRecord.splice(idx, 1, record)
await writeSSHRecord(sshRecord)
consola.info('修改凭证:', name) consola.info('修改凭证:', name)
res.success({ data: '保存成功' }) res.success({ data: '保存成功' })
} }
const removeSSH = async ({ res, request }) => { const removeSSH = async ({ res, request }) => {
let { params: { id } } = request let { params: { id } } = request
let sshRecord = await readSSHRecord() let count = await credentialsDB.countAsync({ _id: id })
let idx = sshRecord.findIndex(item => item._id === id) if (count === 0) return res.fail({ msg: '凭证不存在' })
if(idx === -1) return res.fail({ msg: '凭证不存在' })
sshRecord.splice(idx, 1)
// 将删除的凭证id从host中删除 // 将删除的凭证id从host中删除
let hostList = await readHostList() let hostList = await hostListDB.findAsync({})
hostList = hostList.map(item => { if (Array.isArray(hostList) && hostList.length > 0) {
if (item.credential === id) item.credential = '' for (let host of hostList) {
return item let { credential } = host
}) credential = await AESDecryptAsync(credential)
await writeHostList(hostList) if (credential === id) {
host.credential = ''
await hostListDB.updateAsync({ _id: host._id }, host)
}
}
}
await hostListDB.compactDatafileAsync()
consola.info('移除凭证:', id) consola.info('移除凭证:', id)
await writeSSHRecord(sshRecord) await credentialsDB.removeAsync({ _id: id })
res.success({ data: '移除成功' }) res.success({ data: '移除成功' })
} }
const getCommand = async ({ res, request }) => { const getCommand = async ({ res, request }) => {
let { host } = request.query let { hostId } = request.query
if(!host) return res.fail({ data: false, msg: '参数错误' }) if (!hostId) return res.fail({ data: false, msg: '参数错误' })
let hostInfo = await readHostList() let hostInfo = await hostListDB.findAsync({})
let record = hostInfo?.find(item => item.host === host) let record = hostInfo?.find(item => item._id === hostId)
consola.info('查询登录后执行的指令:', host) 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 const { command } = record
if(!command) return res.success({ data: false }) // command不存在 if (!command) return res.success({ data: false })
res.success({ data: command }) // 存在 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 = { module.exports = {
@ -94,5 +104,6 @@ module.exports = {
addSSH, addSSH,
updateSSH, updateSSH,
removeSSH, removeSSH,
getCommand getCommand,
decryptPrivateKey
} }

View File

@ -1,9 +1,21 @@
const jwt = require('jsonwebtoken') const jwt = require('jsonwebtoken')
const axios = require('axios') const axios = require('axios')
const { getNetIPInfo, readKey, writeKey, RSADecryptSync, AESEncryptSync, SHA1Encrypt, sendEmailToConfList, getNotifySwByType } = require('../utils') const speakeasy = require('speakeasy')
const QRCode = require('qrcode')
const version = require('../../package.json').version
const getLicenseInfo = require('../utils/get-plus')
const { plusServer1, plusServer2 } = require('../utils/plus-server')
const { sendNoticeAsync } = require('../utils/notify')
const { RSADecryptAsync, AESEncryptAsync, SHA1Encrypt } = require('../utils/encrypt')
const { getNetIPInfo } = require('../utils/tools')
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 }) => { const getpublicKey = async ({ res }) => {
let { publicKey: data } = await readKey() let { publicKey: data } = await keyDB.findOneAsync({})
if (!data) return res.fail({ msg: 'publicKey not found, Try to restart the server', status: 500 }) if (!data) return res.fail({ msg: 'publicKey not found, Try to restart the server', status: 500 })
res.success({ data }) res.success({ data })
} }
@ -17,16 +29,15 @@ let loginCountDown = forbidTimer
let forbidLogin = false let forbidLogin = false
const login = async ({ res, request }) => { const login = async ({ res, request }) => {
let { body: { loginName, ciphertext, jwtExpires }, ip: clientIp } = request let { body: { loginName, ciphertext, jwtExpires, mfa2Token }, ip: clientIp } = request
if (!loginName && !ciphertext) return res.fail({ msg: '请求非法!' }) if (!loginName && !ciphertext) return res.fail({ msg: '请求非法!' })
if (forbidLogin) return res.fail({ msg: `禁止登录! 倒计时[${ loginCountDown }s]后尝试登录或重启面板服务` }) if (forbidLogin) return res.fail({ msg: `禁止登录! 倒计时[${ loginCountDown }s]后尝试登录或重启面板服务` })
loginErrCount++ loginErrCount++
loginErrTotal++ loginErrTotal++
if (loginErrCount >= allowErrCount) { if (loginErrCount >= allowErrCount) {
const { ip, country, city } = await getNetIPInfo(clientIp) const { ip, country, city } = await getNetIPInfo(clientIp)
// 发送通知&禁止登录 // 异步发送通知&禁止登录
let sw = getNotifySwByType('err_login') sendNoticeAsync('err_login', '登录错误提醒', `错误登录次数: ${ loginErrTotal }\n地点:${ country + city }\nIP: ${ ip }`)
if (sw) sendEmailToConfList('登录错误提醒', `重新登录次数: ${ loginErrTotal }<br/>地点:${ country + city }<br/>IP: ${ ip }`)
forbidLogin = true forbidLogin = true
loginErrCount = 0 loginErrCount = 0
@ -49,10 +60,13 @@ const login = async ({ res, request }) => {
// 登录流程 // 登录流程
try { try {
// console.log('ciphertext', ciphertext) let loginPwd = await RSADecryptAsync(ciphertext)
let loginPwd = await RSADecryptSync(ciphertext) let { user, pwd, enableMFA2, secret } = await keyDB.findOneAsync({})
// console.log('Decrypt解密password:', loginPwd) if (enableMFA2) {
let { user, pwd } = await readKey() const isValid = speakeasy.totp.verify({ secret, encoding: 'base32', token: mfa2Token, window: 1 })
console.log('MFA2 verfify:', isValid)
if (!isValid) return res.fail({ msg: '验证失败' })
}
if (loginName === user && loginPwd === 'admin' && pwd === 'admin') { if (loginName === user && loginPwd === 'admin' && pwd === 'admin') {
const token = await beforeLoginHandler(clientIp, jwtExpires) const token = await beforeLoginHandler(clientIp, jwtExpires)
return res.success({ data: { token, jwtExpires }, msg: '登录成功,请及时修改默认用户名和密码' }) return res.success({ data: { token, jwtExpires }, msg: '登录成功,请及时修改默认用户名和密码' })
@ -62,8 +76,8 @@ const login = async ({ res, request }) => {
const token = await beforeLoginHandler(clientIp, jwtExpires) const token = await beforeLoginHandler(clientIp, jwtExpires)
return res.success({ data: { token, jwtExpires }, msg: '登录成功' }) return res.success({ data: { token, jwtExpires }, msg: '登录成功' })
} catch (error) { } catch (error) {
console.log('解密失败:', error) console.log('登录失败:', error.message)
res.fail({ msg: '解密失败, 请查看服务端日志' }) res.fail({ msg: '登录失败, 请查看服务端日志' })
} }
} }
@ -72,52 +86,42 @@ const beforeLoginHandler = async (clientIp, jwtExpires) => {
// consola.success('登录成功, 准备生成token', new Date()) // consola.success('登录成功, 准备生成token', new Date())
// 生产token // 生产token
let { commonKey } = await readKey() let { commonKey } = await keyDB.findOneAsync({})
let token = jwt.sign({ date: Date.now() }, commonKey, { expiresIn: jwtExpires }) // 生成token let token = jwt.sign({ date: Date.now() }, commonKey, { expiresIn: jwtExpires }) // 生成token
token = await AESEncryptSync(token) // 对称加密token后再传输给前端 token = await AESEncryptAsync(token) // 对称加密token后再传输给前端
// 记录客户端登录IP(用于判断是否异地且只保留最近10) // 记录客户端登录IP(用于判断是否异地且只保留最近10<EFBFBD><EFBFBD>)
const clientIPInfo = await getNetIPInfo(clientIp) const clientIPInfo = await getNetIPInfo(clientIp)
const { ip, country, city } = clientIPInfo || {} const { ip, country, city } = clientIPInfo || {}
consola.info('登录成功:', new Date(), { ip, country, city }) consola.info('登录成功:', new Date(), { ip, country, city })
// 邮件登录通知 // 登录通知
let sw = getNotifySwByType('login') sendNoticeAsync('login', '登录提醒', `地点:${ country + city }\nIP: ${ ip }`)
if (sw) sendEmailToConfList('登录提醒', `地点:${ country + city }<br/>IP: ${ ip }`)
global.loginRecord.unshift(clientIPInfo) await logDB.insertAsync({ ip, country, city, date: Date.now(), type: 'login' })
if (global.loginRecord.length > 10) global.loginRecord = global.loginRecord.slice(0, 10)
return token return token
} }
const updatePwd = async ({ res, request }) => { const updatePwd = async ({ res, request }) => {
let { body: { oldLoginName, oldPwd, newLoginName, newPwd } } = request let { body: { oldLoginName, oldPwd, newLoginName, newPwd } } = request
let rsaOldPwd = await RSADecryptSync(oldPwd) let rsaOldPwd = await RSADecryptAsync(oldPwd)
oldPwd = rsaOldPwd === 'admin' ? 'admin' : SHA1Encrypt(rsaOldPwd) oldPwd = rsaOldPwd === 'admin' ? 'admin' : SHA1Encrypt(rsaOldPwd)
let keyObj = await readKey() let keyObj = await keyDB.findOneAsync({})
let { user, pwd } = keyObj let { user, pwd } = keyObj
if (oldLoginName !== user || oldPwd !== pwd) return res.fail({ data: false, msg: '原用户名或密码校验失败' }) if (oldLoginName !== user || oldPwd !== pwd) return res.fail({ data: false, msg: '原用户名或密码校验失败' })
// 旧密钥校验通过,加密保存新密码 // 旧密钥校验通过,加密保存新密码
newPwd = await RSADecryptSync(newPwd) === 'admin' ? 'admin' : SHA1Encrypt(await RSADecryptSync(newPwd)) newPwd = await RSADecryptAsync(newPwd) === 'admin' ? 'admin' : SHA1Encrypt(await RSADecryptAsync(newPwd))
keyObj.user = newLoginName keyObj.user = newLoginName
keyObj.pwd = newPwd keyObj.pwd = newPwd
await writeKey(keyObj) await keyDB.updateAsync({}, keyObj)
sendNoticeAsync('updatePwd', '用户密码修改提醒', `原用户名:${ user }\n更新用户名: ${ newLoginName }`)
let sw = getNotifySwByType('updatePwd')
if (sw) sendEmailToConfList(`登录信息修改提醒, 新用户名: ${ newLoginName }`)
res.success({ data: true, msg: 'success' }) res.success({ data: true, msg: 'success' })
} }
const getLoginRecord = async ({ res }) => {
res.success({ data: global.loginRecord, msg: 'success' })
}
const getEasynodeVersion = async ({ res }) => { const getEasynodeVersion = async ({ res }) => {
try { try {
// const { data } = await axios.get('https://api.github.com/repos/chaos-zhu/easynode/releases/latest') // const { data } = await axios.get('https://api.github.com/repos/chaos-zhu/easynode/releases/latest')
const { data } = await axios.get('https://get-easynode-latest-version.chaoszhu.workers.dev/version') const { data } = await axios.get('https://get-easynode-latest-version.chaoszhu.workers.dev/version')
console.log(data)
res.success({ data, msg: 'success' }) res.success({ data, msg: 'success' })
} catch (error) { } catch (error) {
consola.error('Failed to fetch Easynode latest version:', error) consola.error('Failed to fetch Easynode latest version:', error)
@ -125,10 +129,98 @@ const getEasynodeVersion = async ({ res }) => {
} }
} }
let tempSecret = null
const getMFA2Status = async ({ res }) => {
const { enableMFA2 = false } = await keyDB.findOneAsync({})
res.success({ data: enableMFA2, msg: 'success' })
}
const getMFA2Code = async ({ res }) => {
const { user } = await keyDB.findOneAsync({})
let { otpauth_url, base32 } = speakeasy.generateSecret({ name: `EasyNode-${ user }`, length: 20 })
tempSecret = base32
const qrImage = await QRCode.toDataURL(otpauth_url)
const data = { qrImage, secret: tempSecret }
res.success({ data, msg: 'success' })
}
const enableMFA2 = async ({ res, request }) => {
const { body: { token } } = request
if (!token) return res.fail({ data: false, msg: '参数错误' })
try {
// const isValid = authenticator.verify({ token, secret: tempSecret })
const isValid = speakeasy.totp.verify({ secret: tempSecret, encoding: 'base32', token, window: 1 })
if (!isValid) return res.fail({ msg: '验证失败' })
const keyConfig = await keyDB.findOneAsync({})
keyConfig.enableMFA2 = true
keyConfig.secret = tempSecret
tempSecret = null
await keyDB.updateAsync({}, keyConfig)
res.success({ msg: '验证成功' })
} catch (error) {
res.fail({ msg: `验证失败: ${ error.message }` })
}
}
const disableMFA2 = async ({ res }) => {
const keyConfig = await keyDB.findOneAsync({})
keyConfig.enableMFA2 = false
keyConfig.secret = null
await keyDB.updateAsync({}, keyConfig)
res.success({ msg: 'success' })
}
const getPlusInfo = async ({ res }) => {
let data = await plusDB.findOneAsync({})
delete data?._id
delete data?.decryptKey
res.success({ data, msg: 'success' })
}
const getPlusDiscount = async ({ res } = {}) => {
if (process.env.EXEC_ENV === 'local') return res.success({ discount: false })
const servers = [plusServer1, plusServer2]
for (const server of servers) {
try {
const url = `${ server }/api/announcement/public?version=${ version }`
const response = await fetch(url)
if (!response.ok) {
throw new Error(`HTTP error! status: ${ response.status }`)
}
const data = await response.json()
return res.success({ data, msg: 'success' })
} catch (error) {
if (server === servers[servers.length - 1]) {
consola.error('All servers failed:', error.message)
return res.success({ discount: false })
}
continue
}
}
}
const getPlusConf = async ({ res }) => {
const { key } = await plusDB.findOneAsync({}) || {}
res.success({ data: key || '', msg: 'success' })
}
const updatePlusKey = async ({ res, request }) => {
const { body: { key } } = request
const { success, msg } = await getLicenseInfo(key)
if (!success) return res.fail({ msg })
res.success({ msg: 'success' })
}
module.exports = { module.exports = {
login, login,
getpublicKey, getpublicKey,
updatePwd, updatePwd,
getLoginRecord, getEasynodeVersion,
getEasynodeVersion getMFA2Status,
getMFA2Code,
enableMFA2,
disableMFA2,
getPlusInfo,
getPlusDiscount,
getPlusConf,
updatePlusKey
} }

View File

@ -1,188 +1,104 @@
const { writeKey, writeNotifyList, writeGroupList } = require('./utils/storage') const NodeRSA = require('node-rsa')
const { KeyDB, NotifyDB, GroupDB, EmailNotifyDB } = require('./utils/db-class') const { randomStr } = require('./utils/tools')
const { readScriptList, writeScriptList } = require('./utils') const { AESEncryptAsync } = require('./utils/encrypt')
const { KeyDB, GroupDB, NotifyDB, NotifyConfigDB } = require('./utils/db-class')
function initKeyDB() { async function initKeyDB() {
return new Promise((resolve, reject) => {
const keyDB = new KeyDB().getInstance() const keyDB = new KeyDB().getInstance()
keyDB.count({}, async (err, count) => { let count = await keyDB.countAsync({})
if (err) { if (count !== 0) return consola.info('公私钥已存在[重新生成会导致已保存的ssh密钥信息失效]')
consola.log('初始化keyDB错误:', err) let newConfig = {
reject(err)
} else {
if (count === 0) {
consola.log('初始化keyDB✔')
const defaultData = {
user: 'admin', user: 'admin',
pwd: 'admin', pwd: 'admin',
commonKey: '', commonKey: randomStr(16),
publicKey: '', publicKey: '',
privateKey: '' privateKey: ''
} }
await writeKey(defaultData) await keyDB.insertAsync(newConfig)
} let key = new NodeRSA({ b: 1024 })
} key.setOptions({ encryptionScheme: 'pkcs1', environment: 'browser' })
resolve() let privateKey = key.exportKey('pkcs1-private-pem')
}) let publicKey = key.exportKey('pkcs8-public-pem')
}) newConfig.privateKey = await AESEncryptAsync(privateKey, newConfig.commonKey) // 加密私钥
newConfig.publicKey = publicKey // 公开公钥
await keyDB.updateAsync({}, { $set: newConfig }, { upsert: true })
consola.info('Task: 已生成新的非对称加密公私钥')
} }
function initNotifyDB() { async function initGroupDB() {
return new Promise((resolve, reject) => {
const notifyDB = new NotifyDB().getInstance()
notifyDB.count({}, async (err, count) => {
if (err) {
consola.log('初始化notifyDB错误:', err)
reject(err)
} else {
if (count === 0) {
consola.log('初始化notifyDB✔')
const defaultData = [{
'type': 'login',
'desc': '登录面板提醒',
'sw': true
},
{
'type': 'err_login',
'desc': '登录错误提醒(连续5次)',
'sw': true
},
{
'type': 'updatePwd',
'desc': '修改密码提醒',
'sw': true
}
// {
// 'type': 'host_offline',
// 'desc': '客户端离线提醒(每小时最多发送一次提醒)',
// 'sw': true
// }
]
await writeNotifyList(defaultData)
}
}
resolve()
})
})
}
function initGroupDB() {
return new Promise((resolve, reject) => {
const groupDB = new GroupDB().getInstance() const groupDB = new GroupDB().getInstance()
groupDB.count({}, async (err, count) => { let count = await groupDB.countAsync({})
if (err) {
consola.log('初始化groupDB错误:', err)
reject(err)
} else {
if (count === 0) { if (count === 0) {
consola.log('初始化groupDB✔') consola.log('初始化groupDB✔')
const defaultData = [{ '_id': 'default', 'name': '默认分组', 'index': 0 }] const defaultData = [{ '_id': 'default', 'name': '默认分组', 'index': 0 }]
await writeGroupList(defaultData) return groupDB.insertAsync(defaultData)
} }
} return Promise.resolve()
resolve()
})
})
} }
function initEmailNotifyDB() { async function initNotifyDB() {
return new Promise((resolve, reject) => { const notifyDB = new NotifyDB().getInstance()
const emailNotifyDB = new EmailNotifyDB().getInstance() let count = await notifyDB.countAsync({})
emailNotifyDB.count({}, async (err, count) => { if (count !== 0) return
if (err) { consola.log('初始化notifyDB✔')
consola.log('初始化emailNotifyDB错误:', err) let defaultData = [{
reject(err) 'type': 'login',
} else { 'desc': '登录面板提醒',
if (count === 0) { 'sw': false
consola.log('初始化emailNotifyDB✔') }, {
'type': 'err_login',
'desc': '登录错误提醒(连续5次)',
'sw': false
}, {
'type': 'updatePwd',
'desc': '修改密码提醒',
'sw': false
}, {
'type': 'host_login',
'desc': '服务器登录提醒',
'sw': false
}, {
'type': 'onekey_complete',
'desc': '批量指令执行完成提醒',
'sw': false
}, {
'type': 'host_expired',
'desc': '服务器到期提醒',
'sw': false
}]
return notifyDB.insertAsync(defaultData)
}
async function initNotifyConfigDB() {
const notifyConfigDB = new NotifyConfigDB().getInstance()
let notifyConfig = await notifyConfigDB.findOneAsync({})
consola.log('初始化NotifyConfigDB✔')
const defaultData = { const defaultData = {
'support': [ type: 'sct',
{ sct: {
'name': 'QQ邮箱', sendKey: ''
'target': 'qq',
'host': 'smtp.qq.com',
'port': 465,
'secure': true,
'tls': {
'rejectUnauthorized': false
}
}, },
{ email: {
'name': '网易126', service: 'QQ',
'target': 'wangyi126', user: '',
'host': 'smtp.126.com', pass: ''
'port': 465,
'secure': true,
'tls': {
'rejectUnauthorized': false
}
}, },
{ tg: {
'name': '网易163', token: '',
'target': 'wangyi163', chatId: ''
'host': 'smtp.163.com',
'port': 465,
'secure': true,
'tls': {
'rejectUnauthorized': false
} }
} }
], if (notifyConfig) {
'user': [ await notifyConfigDB.removeAsync({ _id: notifyConfig._id })
] delete notifyConfig._id
return notifyConfigDB.insertAsync(Object.assign({}, defaultData, notifyConfig))
} }
emailNotifyDB.update({}, { $set: defaultData }, { upsert: true }, (err, numReplaced) => { return notifyConfigDB.insertAsync(defaultData)
if (err) {
reject(err)
} else {
emailNotifyDB.compactDatafile()
resolve(numReplaced)
}
})
} else {
resolve()
}
}
})
})
}
function initScriptsDB() {
// eslint-disable-next-line no-async-promise-executor
return new Promise(async (resolve) => {
let scriptList = await readScriptList()
let clientInstallScript = 'curl -o- https://mirror.ghproxy.com/https://raw.githubusercontent.com/chaos-zhu/easynode/main/client/easynode-client-install.sh | bash'
let clientUninstallScript = 'curl -o- https://mirror.ghproxy.com/https://raw.githubusercontent.com/chaos-zhu/easynode/main/client/easynode-client-uninstall.sh | bash'
let installId = 'clientInstall'
let uninstallId = 'clientUninstall'
let isClientInstall = scriptList?.find(script => script._id = installId)
let isClientUninstall = scriptList?.find(script => script._id = uninstallId)
let writeFlag = false
if (!isClientInstall) {
console.info('初始化客户端安装脚本')
scriptList.push({ _id: installId, name: 'easynode-客户端-安装脚本', description: '系统内置|重启生成', command: clientInstallScript, index: 1 })
writeFlag = true
} else {
console.info('客户端安装脚本已存在')
}
if (!isClientUninstall) {
console.info('初始化客户端卸载脚本')
scriptList.push({ _id: uninstallId, name: 'easynode-客户端-卸载脚本', description: '系统内置|重启生成', command: clientUninstallScript, index: 0 })
writeFlag = true
} else {
console.info('客户端卸载脚本已存在')
}
if (writeFlag) await writeScriptList(scriptList)
resolve()
})
} }
module.exports = async () => { module.exports = async () => {
await initKeyDB() await initKeyDB()
await initNotifyDB() await initNotifyDB()
await initGroupDB() await initGroupDB()
await initEmailNotifyDB() await initNotifyConfigDB()
// await initScriptsDB()
} }

View File

@ -1,50 +0,0 @@
db目录初始化后自动生成
**host.db**
> 存储服务器基本信息
**key.db**
> 用于加密的密钥相关
**credentials.db**
> ssh密钥记录(加密存储)
**email.db**
> 邮件配置
- port: 587 --> secure: false
```db
// Gmail调试不通过, 暂缓
{
"name": "Google邮箱",
"target": "google",
"host": "smtp.gmail.com",
"port": 465,
"secure": true,
"tls": {
"rejectUnauthorized": false
}
}
```
**notify.db**
> 通知配置
**group.db**
> 服务器分组配置
**scripts.db**
> 脚本库
**onekey.db**
> 批量指令记录

View File

@ -1,49 +0,0 @@
const NodeRSA = require('node-rsa')
const { getNetIPInfo, readHostList, writeHostList, readKey, writeKey, randomStr, isProd, AESEncryptSync } = require('./utils')
const isDev = !isProd()
// 存储本机IP, 供host列表接口调用
// eslint-disable-next-line no-unused-vars
async function initLocalIp() {
if(isDev) return consola.info('非生产环境不初始化保存本地IP')
const localNetIPInfo = await getNetIPInfo()
let vpsList = await readHostList()
let { ip: localNetIP } = localNetIPInfo
if(vpsList.some(({ host }) => host === localNetIP)) return consola.info('本机IP已储存: ', localNetIP)
vpsList.unshift({ name: 'server-side-host', host: localNetIP, group: 'default' })
writeHostList(vpsList)
consola.info('Task: 生产环境首次启动储存本机IP: ', localNetIP)
}
// 初始化公私钥, 供登录、保存ssh密钥/密码等加解密
async function initRsa() {
let keyObj = await readKey()
if(keyObj.privateKey && keyObj.publicKey) return consola.info('公私钥已存在[重新生成会导致已保存的ssh密钥信息失效]')
let key = new NodeRSA({ b: 1024 })
key.setOptions({ encryptionScheme: 'pkcs1', environment: 'browser' })
let privateKey = key.exportKey('pkcs1-private-pem')
let publicKey = key.exportKey('pkcs8-public-pem')
keyObj.privateKey = await AESEncryptSync(privateKey) // 加密私钥
keyObj.publicKey = publicKey // 公开公钥
await writeKey(keyObj)
consola.info('Task: 已生成新的非对称加密公私钥')
}
// 随机的commonKey secret
async function randomJWTSecret() {
let keyObj = await readKey()
if(keyObj?.commonKey) return consola.info('commonKey密钥已存在')
keyObj.commonKey = randomStr(16)
await writeKey(keyObj)
consola.info('Task: 已生成新的随机commonKey密钥')
}
module.exports = async () => {
await randomJWTSecret() // 全局密钥
await initRsa() // 全局公钥密钥
// initLocalIp() // :TODO: 默认添加服务端vps
// 用于记录客户端登录IP的列表
global.loginRecord = []
}

View File

@ -1,15 +1,13 @@
const consola = require('consola')
global.consola = consola
const { httpServer } = require('./server') const { httpServer } = require('./server')
const initDB = require('./db') const initDB = require('./db')
const initEncryptConf = require('./init')
const scheduleJob = require('./schedule') const scheduleJob = require('./schedule')
const getLicenseInfo = require('./utils/get-plus')
async function main() { async function main() {
await initDB() await initDB()
await initEncryptConf()
httpServer() httpServer()
scheduleJob() scheduleJob()
getLicenseInfo()
} }
main() main()

View File

@ -1,5 +1,5 @@
const { verifyAuthSync } = require('../utils')
const { apiPrefix } = require('../config') const { apiPrefix } = require('../config')
const { verifyAuthSync } = require('../utils/verify-auth')
let whitePath = [ let whitePath = [
'/login', '/login',

View File

@ -1,3 +1,4 @@
const ipFilter = require('./ipFilter') // IP过滤
const responseHandler = require('./response') // 统一返回格式, 错误捕获 const responseHandler = require('./response') // 统一返回格式, 错误捕获
const useAuth = require('./auth') // 鉴权 const useAuth = require('./auth') // 鉴权
// const useCors = require('./cors') // 处理跨域[暂时禁止] // const useCors = require('./cors') // 处理跨域[暂时禁止]
@ -8,8 +9,8 @@ const useStatic = require('./static') // 静态目录
const compress = require('./compress') // br/gzip压缩 const compress = require('./compress') // br/gzip压缩
const history = require('./history') // vue-router的history模式 const history = require('./history') // vue-router的history模式
// 注意注册顺序
module.exports = [ module.exports = [
ipFilter,
compress, compress,
history, history,
useStatic, // staic先注册不然会被jwt拦截 useStatic, // staic先注册不然会被jwt拦截

View File

@ -0,0 +1,16 @@
// 白名单IP
const fs = require('fs')
const path = require('path')
const { isAllowedIp } = require('../utils/tools')
const htmlPath = path.join(__dirname, '../template/ipForbidden.html')
const ipForbiddenHtml = fs.readFileSync(htmlPath, 'utf8')
const ipFilter = async (ctx, next) => {
// console.log('requestIP:', ctx.request.ip)
if (isAllowedIp(ctx.request.ip)) return await next()
ctx.status = 403
ctx.body = ipForbiddenHtml
}
module.exports = ipFilter

View File

@ -3,27 +3,28 @@ const { outDir, recordLog } = require('../config').logConfig
log4js.configure({ log4js.configure({
appenders: { appenders: {
// 控制台输出 console: {
out: {
type: 'stdout', type: 'stdout',
layout: { layout: {
type: 'colored' type: 'pattern',
pattern: '%[%d{yyyy-MM-dd hh:mm:ss.SSS} [%p] -%] %m'
} }
}, },
// 保存日志文件
cheese: { cheese: {
type: 'file', type: 'file',
maxLogSize: 512*1024, // unit: bytes 1KB = 1024bytes maxLogSize: 10 * 1024 * 1024, // unit: bytes 1KB = 1024bytes
filename: `${ outDir }/receive.log` filename: `${ outDir }/receive.log`,
backups: 10,
compress: true,
keepFileExt: true
} }
}, },
categories: { categories: {
default: { default: {
appenders: [ 'out', 'cheese' ], // 配置 appenders: ['console', 'cheese'],
level: 'info' // 只输出info以上级别的日志 level: 'debug'
} }
} }
// pm2: true
}) })
const logger = log4js.getLogger() const logger = log4js.getLogger()
@ -56,3 +57,6 @@ const useLog = () => {
} }
module.exports = useLog() module.exports = useLog()
// 可以先测试一下日志是否正常工作
logger.info('日志系统启动')

View File

@ -1,10 +1,11 @@
const { getSSHList, addSSH, updateSSH, removeSSH, getCommand } = require('../controller/ssh') const { getSSHList, addSSH, updateSSH, removeSSH, getCommand, decryptPrivateKey } = require('../controller/ssh')
const { getHostList, addHost, updateHost, removeHost, importHost } = require('../controller/host') const { getHostList, addHost, updateHost, batchUpdateHost, removeHost, importHost } = require('../controller/host')
const { login, getpublicKey, updatePwd, getLoginRecord, getEasynodeVersion } = require('../controller/user') const { login, getpublicKey, updatePwd, getEasynodeVersion, getMFA2Status, getMFA2Code, enableMFA2, disableMFA2, getPlusInfo, getPlusDiscount, getPlusConf, updatePlusKey } = require('../controller/user')
const { getSupportEmailList, getUserEmailList, updateUserEmailList, removeUserEmail, pushEmail, getNotifyList, updateNotifyList } = require('../controller/notify') const { getNotifyConfig, updateNotifyConfig, getNotifyList, updateNotifyList } = require('../controller/notify')
const { getGroupList, addGroupList, updateGroupList, removeGroup } = require('../controller/group') 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 { getOnekeyRecord, removeOnekeyRecord } = require('../controller/onekey')
const { getLog } = require('../controller/log')
const ssh = [ const ssh = [
{ {
@ -31,6 +32,11 @@ const ssh = [
method: 'get', method: 'get',
path: '/command', path: '/command',
controller: getCommand controller: getCommand
},
{
method: 'post',
path: '/decrypt-private-key',
controller: decryptPrivateKey
} }
] ]
const host = [ const host = [
@ -49,6 +55,11 @@ const host = [
path: '/host-save', path: '/host-save',
controller: updateHost controller: updateHost
}, },
{
method: 'put',
path: '/batch-update-host',
controller: batchUpdateHost
},
{ {
method: 'post', method: 'post',
path: '/host-remove', path: '/host-remove',
@ -78,40 +89,60 @@ const user = [
}, },
{ {
method: 'get', method: 'get',
path: '/get-login-record', path: '/version',
controller: getLoginRecord controller: getEasynodeVersion
}, },
{ {
method: 'get', method: 'get',
path: '/version', path: '/mfa2-status',
controller: getEasynodeVersion controller: getMFA2Status
},
{
method: 'post',
path: '/mfa2-code',
controller: getMFA2Code
},
{
method: 'post',
path: '/mfa2-enable',
controller: enableMFA2
},
{
method: 'post',
path: '/mfa2-disable',
controller: disableMFA2
},
{
method: 'get',
path: '/plus-info',
controller: getPlusInfo
},
{
method: 'get',
path: '/plus-discount',
controller: getPlusDiscount
},
{
method: 'get',
path: '/plus-conf',
controller: getPlusConf
},
{
method: 'post',
path: '/plus-conf',
controller: updatePlusKey
} }
] ]
const notify = [ const notify = [
{ {
method: 'get', method: 'get',
path: '/support-email', path: '/notify-config',
controller: getSupportEmailList controller: getNotifyConfig
}, },
{ {
method: 'get', method: 'put',
path: '/user-email', path: '/notify-config',
controller: getUserEmailList controller: updateNotifyConfig
},
{
method: 'post',
path: '/user-email',
controller: updateUserEmailList
},
{
method: 'post',
path: '/push-email',
controller: pushEmail
},
{
method: 'delete',
path: '/user-email/:email',
controller: removeUserEmail
}, },
{ {
method: 'get', method: 'get',
@ -169,10 +200,20 @@ const scripts = [
path: '/script/:id', path: '/script/:id',
controller: removeScript controller: removeScript
}, },
{
method: 'post',
path: '/batch-remove-script',
controller: batchRemoveScript
},
{ {
method: 'put', method: 'put',
path: '/script/:id', path: '/script/:id',
controller: updateScriptList controller: updateScriptList
},
{
method: 'post',
path: '/import-script',
controller: importScript
} }
] ]
@ -188,4 +229,12 @@ const onekey = [
controller: removeOnekeyRecord controller: removeOnekeyRecord
} }
] ]
module.exports = [].concat(ssh, host, user, notify, group, scripts, onekey)
const log = [
{
method: 'get',
path: '/log',
controller: getLog
}
]
module.exports = [].concat(ssh, host, user, notify, group, scripts, onekey, log)

View File

@ -1,30 +0,0 @@
const schedule = require('node-schedule')
const { readHostList, sendEmailToConfList, formatTimestamp } = require('../utils')
const expiredNotifyJob = async () => {
consola.info('=====开始检测服务器到期时间=====', new Date())
const hostList = await readHostList()
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 }<br/>IP: ${ host }<br/>到期时间:${ formatTimestamp(expired, 'week') }<br/>控制台: ${ consoleUrl || '未填写' }`
if(0 <= restDay && restDay <= 1) {
let temp = '有服务器将在一天后到期,请关注<br/>'
sendEmailToConfList(title, temp + content)
}else if(3 <= restDay && restDay < 4) {
let temp = '有服务器将在三天后到期,请关注<br/>'
sendEmailToConfList(title, temp + content)
}else if(7 <= restDay && restDay < 8) {
let temp = '有服务器将在七天后到期,请关注<br/>'
sendEmailToConfList(title, temp + content)
}
}
}
module.exports = () => {
// 每天中午12点执行一次。
schedule.scheduleJob('0 0 12 1/1 * ?', expiredNotifyJob)
}

View File

@ -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 = () => { module.exports = () => {
expiredNotify() schedule.scheduleJob('0 0 12 1/1 * ?', expiredNotifyJob)
} }

View File

@ -5,10 +5,9 @@ const { httpPort } = require('./config')
const middlewares = require('./middlewares') const middlewares = require('./middlewares')
const wsTerminal = require('./socket/terminal') const wsTerminal = require('./socket/terminal')
const wsSftp = require('./socket/sftp') const wsSftp = require('./socket/sftp')
// const wsHostStatus = require('./socket/host-status')
const wsClientInfo = require('./socket/clients') const wsClientInfo = require('./socket/clients')
const wsOnekey = require('./socket/onekey') const wsOnekey = require('./socket/onekey')
const { throwError } = require('./utils') const { throwError } = require('./utils/tools')
const httpServer = () => { const httpServer = () => {
const app = new Koa() const app = new Koa()
@ -25,7 +24,6 @@ function serverHandler(app, server) {
app.proxy = true // 用于nginx反代时获取真实客户端ip app.proxy = true // 用于nginx反代时获取真实客户端ip
wsTerminal(server) // 终端 wsTerminal(server) // 终端
wsSftp(server) // sftp wsSftp(server) // sftp
// wsHostStatus(server) // 终端侧边栏host信息(单个host)
wsOnekey(server) // 一键指令 wsOnekey(server) // 一键指令
wsClientInfo(server) // 客户端信息 wsClientInfo(server) // 客户端信息
app.context.throwError = throwError // 常用方法挂载全局ctx上 app.context.throwError = throwError // 常用方法挂载全局ctx上

View File

@ -1,22 +1,26 @@
const { Server: ServerIO } = require('socket.io') const { Server: ServerIO } = require('socket.io')
const { io: ClientIO } = require('socket.io-client') const { io: ClientIO } = require('socket.io-client')
const { readHostList } = require('../utils') const { defaultClientPort } = require('../config')
const { clientPort } = require('../config') const { verifyAuthSync } = require('../utils/verify-auth')
const { verifyAuthSync } = require('../utils') const { isAllowedIp } = require('../utils/tools')
const { HostListDB } = require('../utils/db-class')
const hostListDB = new HostListDB().getInstance()
let clientSockets = [] let clientSockets = []
let clientsData = {} let clientsData = {}
async function getClientsInfo(clientSockets) { async function getClientsInfo(clientSockets) {
let hostList = await readHostList() let hostList = await hostListDB.findAsync({})
clientSockets.forEach((clientItem) => { clientSockets.forEach((clientItem) => {
// 被删除的客户端断开连接 // 被删除的客户端断开连接
if (!hostList.some(item => item.host === clientItem.host)) clientItem.close && clientItem.close() if (!hostList.some(item => item.host === clientItem.host)) clientItem.close && clientItem.close()
}) })
hostList hostList
.map(({ host, name }) => { .map(({ host, name, clientPort }) => {
if (clientSockets.some(item => item.host === host)) return { name, isIo: true } // 已经建立io连接(无论是否连接成功)的host不再重复建立连接,因为存在多次(reconnectionAttempts)的重试机制 // 已经建立io连接(无论是否连接成功)的host不再重复建立连接,因为存在多次(reconnectionAttempts)的重试机制
let clientSocket = ClientIO(`http://${ host }:${ clientPort }`, { if (clientSockets.some(item => `${ item.host }:${ item.clientPort || defaultClientPort }` === `${ host }:${ clientPort || defaultClientPort }`)) return { name, isIo: true }
// console.log(name, 'clientPort:', clientPort)
let clientSocket = ClientIO(`http://${ host }:${ clientPort || defaultClientPort }`, {
path: '/client/os-info', path: '/client/os-info',
forceNew: true, forceNew: true,
timeout: 5000, timeout: 5000,
@ -24,34 +28,35 @@ async function getClientsInfo(clientSockets) {
reconnectionAttempts: 1000 reconnectionAttempts: 1000
}) })
// 将与客户端连接的socket实例保存起来web端断开时关闭这些连接 // 将与客户端连接的socket实例保存起来web端断开时关闭这些连接
clientSockets.push({ host, name, clientSocket }) clientSockets.push({ host, name, clientPort, clientSocket })
return { return {
host, host,
name, name,
clientPort,
clientSocket clientSocket
} }
}) })
.forEach((item) => { .forEach((item) => {
if (item.isIo) return // console.log('已经建立io连接的host不再重复建立连接', item.name) if (item.isIo) return // console.log('已经建立io连接的host不再重复建立连接', item.name)
const { host, name, clientSocket } = item const { host, name, clientPort, clientSocket } = item
// clientsData[host] = { connect: false } // eslint-disable-next-line no-unused-vars
clientSocket clientSocket
.on('connect', () => { .on('connect', () => {
consola.success('client connect success:', host, name) consola.success('client connect success:', host, name)
clientSocket.on('client_data', (osData) => { clientSocket.on('client_data', (osData) => {
clientsData[host] = { connect: true, ...osData } clientsData[`${ host }:${ clientPort || defaultClientPort }`] = { connect: true, ...osData }
}) })
clientSocket.on('client_error', (error) => { clientSocket.on('client_error', (error) => {
clientsData[host] = { connect: true, error: `client_error: ${ error }` } clientsData[`${ host }:${ clientPort || defaultClientPort }`] = { connect: true, error: `client_error: ${ error }` }
}) })
}) })
.on('connect_error', (error) => { // 连接失败 .on('connect_error', (error) => { // 连接失败
// consola.error('client connect fail:', host, name, error.message) // consola.error('client connect fail:', host, name, error.message)
clientsData[host] = { connect: false, error: `client_connect_error: ${ error }` } clientsData[`${ host }:${ clientPort || defaultClientPort }`] = { connect: false, error: `client_connect_error: ${ error }` }
}) })
.on('disconnect', (error) => { // 一方主动断开连接 .on('disconnect', (error) => { // 一方主动断开连接
// consola.info('client connect disconnect:', host, name) // consola.info('client connect disconnect:', host, name)
clientsData[host] = { connect: false, error: `client_disconnect: ${ error }` } clientsData[`${ host }:${ clientPort || defaultClientPort }`] = { connect: false, error: `client_disconnect: ${ error }` }
}) })
}) })
} }
@ -66,9 +71,14 @@ module.exports = (httpServer) => {
serverIo.on('connection', (socket) => { serverIo.on('connection', (socket) => {
// 前者兼容nginx反代, 后者兼容nodejs自身服务 // 前者兼容nginx反代, 后者兼容nodejs自身服务
let clientIp = socket.handshake.headers['x-forwarded-for'] || socket.handshake.address let requestIP = socket.handshake.headers['x-forwarded-for'] || socket.handshake.address
if (!isAllowedIp(requestIP)) {
socket.emit('ip_forbidden', 'IP地址不在白名单中')
socket.disconnect()
return
}
socket.on('init_clients_data', async ({ token }) => { socket.on('init_clients_data', async ({ token }) => {
const { code, msg } = await verifyAuthSync(token, clientIp) const { code, msg } = await verifyAuthSync(token, requestIP)
if (code !== 1) { if (code !== 1) {
socket.emit('token_verify_fail', msg || '鉴权失败') socket.emit('token_verify_fail', msg || '鉴权失败')
socket.disconnect() socket.disconnect()

View File

@ -1,74 +0,0 @@
const { Server: ServerIO } = require('socket.io')
const { io: ClientIO } = require('socket.io-client')
const { clientPort } = require('../config')
const { verifyAuthSync } = require('../utils')
let hostSockets = {}
function getHostInfo(serverSocket, host) {
let hostSocket = ClientIO(`http://${ host }:${ clientPort }`, {
path: '/client/os-info',
forceNew: false,
timeout: 5000,
reconnectionDelay: 3000,
reconnectionAttempts: 3
})
// 将与客户端连接的socket实例保存起来web端断开时关闭与客户端的连接
hostSockets[serverSocket.id] = hostSocket
hostSocket
.on('connect', () => {
consola.success('host-status-socket连接成功:', host)
hostSocket.on('client_data', (data) => {
serverSocket.emit('host_data', data)
})
hostSocket.on('client_error', () => {
serverSocket.emit('host_data', null)
})
})
.on('connect_error', (error) => {
consola.error('host-status-socket连接[失败]:', host, error.message)
serverSocket.emit('host_data', null)
})
.on('disconnect', () => {
consola.info('host-status-socket连接[断开]:', host)
serverSocket.emit('host_data', null)
})
}
module.exports = (httpServer) => {
const serverIo = new ServerIO(httpServer, {
path: '/host-status',
cors: {
origin: '*' // 需配置跨域
}
})
serverIo.on('connection', (serverSocket) => {
// 前者兼容nginx反代, 后者兼容nodejs自身服务
let clientIp = serverSocket.handshake.headers['x-forwarded-for'] || serverSocket.handshake.address
serverSocket.on('init_host_data', async ({ token, host }) => {
// 校验登录态
const { code, msg } = await verifyAuthSync(token, clientIp)
if(code !== 1) {
serverSocket.emit('token_verify_fail', msg || '鉴权失败')
serverSocket.disconnect()
return
}
// 获取客户端数据
getHostInfo(serverSocket, host)
consola.info('host-status-socket连接socketId: ', serverSocket.id, 'host-status-socket已连接数: ', Object.keys(hostSockets).length)
// 关闭连接
serverSocket.on('disconnect', () => {
// 当web端与服务端断开连接时, 服务端与每个客户端的socket也应该断开连接
let socket = hostSockets[serverSocket.id]
socket.close && socket.close()
delete hostSockets[serverSocket.id]
consola.info('host-status-socket剩余连接数: ', Object.keys(hostSockets).length)
})
})
})
}

View File

@ -1,6 +1,14 @@
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, shellThrottle } = require('../utils') const { sendNoticeAsync } = require('../utils/notify')
const { verifyAuthSync } = require('../utils/verify-auth')
const { shellThrottle } = require('../utils/tools')
const { AESDecryptAsync } = require('../utils/encrypt')
const { isAllowedIp } = require('../utils/tools')
const { HostListDB, CredentialsDB, OnekeyDB } = require('../utils/db-class')
const hostListDB = new HostListDB().getInstance()
const credentialsDB = new CredentialsDB().getInstance()
const onekeyDB = new OnekeyDB().getInstance()
const execStatusEnum = { const execStatusEnum = {
connecting: '连接中', connecting: '连接中',
@ -49,7 +57,7 @@ function execShell(socket, sshClient, curRes, resolve) {
} }
stream stream
.on('close', async () => { .on('close', async () => {
// ssh连接关闭后,再执行一次输出,防止最后一次节流函数发生在延迟时间内导致终端的输出数据丢失 // shell关闭后,再执行一次输出,防止最后一次节流函数发生在延迟时间内导致终端的输出数据丢失
await throttledDataHandler.last() // 等待最后一次节流函数执行完成,再执行一次数据输出 await throttledDataHandler.last() // 等待最后一次节流函数执行完成,再执行一次数据输出
// console.log('onekey终端执行完成, 关闭连接: ', curRes.host) // console.log('onekey终端执行完成, 关闭连接: ', curRes.host)
if (curRes.status === execStatusEnum.executing) { if (curRes.status === execStatusEnum.executing) {
@ -86,7 +94,12 @@ module.exports = (httpServer) => {
}) })
serverIo.on('connection', (socket) => { serverIo.on('connection', (socket) => {
// 前者兼容nginx反代, 后者兼容nodejs自身服务 // 前者兼容nginx反代, 后者兼容nodejs自身服务
let clientIp = socket.handshake.headers['x-forwarded-for'] || socket.handshake.address let requestIP = socket.handshake.headers['x-forwarded-for'] || socket.handshake.address
if (!isAllowedIp(requestIP)) {
socket.emit('ip_forbidden', 'IP地址不在白名单中')
socket.disconnect()
return
}
consola.success('onekey-terminal websocket 已连接') consola.success('onekey-terminal websocket 已连接')
if (isExecuting) { if (isExecuting) {
socket.emit('create_fail', '正在执行中, 请稍后再试') socket.emit('create_fail', '正在执行中, 请稍后再试')
@ -94,16 +107,15 @@ module.exports = (httpServer) => {
return return
} }
isExecuting = true isExecuting = true
socket.on('create', async ({ hosts, token, command, timeout }) => { socket.on('create', async ({ hostIds, token, command, timeout }) => {
const { code } = await verifyAuthSync(token, clientIp) const { code } = await verifyAuthSync(token, requestIP)
if (code !== 1) { if (code !== 1) {
socket.emit('token_verify_fail') socket.emit('token_verify_fail')
socket.disconnect() socket.disconnect()
return return
} }
setTimeout(() => { setTimeout(() => {
// 超时未执行完成,断开连接 // 超时未执行完成,强制断开连接
disconnectAllExecClient()
const { connecting, executing } = execStatusEnum const { connecting, executing } = execStatusEnum
execResult.forEach(item => { execResult.forEach(item => {
// 连接中和执行中的状态设定为超时 // 连接中和执行中的状态设定为超时
@ -111,34 +123,38 @@ module.exports = (httpServer) => {
item.status = execStatusEnum.execTimeout item.status = execStatusEnum.execTimeout
} }
}) })
socket.emit('timeout', { reason: `执行超时,已强制终止执行 - 超时时间${ timeout }`, result: execResult }) let reason = `执行超时,已强制终止执行 - 超时时间${ timeout }`
sendNoticeAsync('onekey_complete', '批量指令执行超时', reason)
socket.emit('timeout', { reason, result: execResult })
socket.disconnect() socket.disconnect()
disconnectAllExecClient()
}, timeout * 1000) }, timeout * 1000)
console.log('hosts:', hosts) console.log('hostIds:', hostIds)
// console.log('token:', token) // console.log('token:', token)
console.log('command:', command) console.log('command:', command)
const hostList = await readHostList() const hostList = await hostListDB.findAsync({})
const targetHostsInfo = hostList.filter(item => hosts.some(ip => item.host === ip)) || {} const targetHostsInfo = hostList.filter(item => hostIds.some(id => item._id === id)) || {}
// console.log('targetHostsInfo:', targetHostsInfo) // console.log('targetHostsInfo:', targetHostsInfo)
if (!targetHostsInfo.length) return socket.emit('create_fail', `未找到【${ hosts }】服务器信息`) if (!targetHostsInfo.length) return socket.emit('create_fail', `未找到【${ hostIds }】服务器信息`)
// 查找 hostInfo -> 并发执行 // 查找 hostInfo -> 并发执行
socket.emit('ready') socket.emit('ready')
let execPromise = targetHostsInfo.map((hostInfo, index) => { let execPromise = targetHostsInfo.map((hostInfo, index) => {
// 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, reject) => {
setTimeout(() => reject('执行超时'), timeout * 1000)
let { authType, host, port, username } = hostInfo let { authType, host, port, username } = hostInfo
let authInfo = { host, port, username } let authInfo = { host, port, username }
let curRes = { command, host, name: hostInfo.name, result: '', status: execStatusEnum.connecting, date: Date.now() - (targetHostsInfo.length - index) } // , execStatusEnum let curRes = { command, host, port, name: hostInfo.name, result: '', status: execStatusEnum.connecting, date: Date.now() - (targetHostsInfo.length - index) } // , execStatusEnum
execResult.push(curRes) execResult.push(curRes)
try { try {
if (authType === 'credential') { if (authType === 'credential') {
let credentialId = await AESDecryptSync(hostInfo['credential']) let credentialId = await AESDecryptAsync(hostInfo['credential'])
const sshRecordList = await readSSHRecord() const sshRecordList = await credentialsDB.findAsync({})
const sshRecord = sshRecordList.find(item => item._id === credentialId) const sshRecord = sshRecordList.find(item => item._id === credentialId)
authInfo.authType = sshRecord.authType authInfo.authType = sshRecord.authType
authInfo[authInfo.authType] = await AESDecryptSync(sshRecord[authInfo.authType]) authInfo[authInfo.authType] = await AESDecryptAsync(sshRecord[authInfo.authType])
} else { } else {
authInfo[authType] = await AESDecryptSync(hostInfo[authType]) authInfo[authType] = await AESDecryptAsync(hostInfo[authType])
} }
consola.info('准备连接终端执行一次性指令:', host) consola.info('准备连接终端执行一次性指令:', host)
consola.log('连接信息', { username, port, authType }) consola.log('连接信息', { username, port, authType })
@ -171,10 +187,15 @@ module.exports = (httpServer) => {
} }
}) })
}) })
try {
await Promise.all(execPromise) await Promise.all(execPromise)
consola.success('onekey执行完成') consola.success('onekey执行完成')
socket.emit('exec_complete') socket.emit('exec_complete')
sendNoticeAsync('onekey_complete', '批量指令执行完成', '请登录面板查看执行结果')
socket.disconnect() socket.disconnect()
} catch (error) {
consola.error('onekey执行失败', error)
}
}) })
socket.on('disconnect', async (reason) => { socket.on('disconnect', async (reason) => {
@ -187,7 +208,7 @@ module.exports = (httpServer) => {
item.status = execStatusEnum.socketInterrupt item.status = execStatusEnum.socketInterrupt
} }
}) })
await writeOneKeyRecord(execResult) await onekeyDB.insertAsync(execResult)
isExecuting = false isExecuting = false
execResult = [] execResult = []
execClient = [] execClient = []

View File

@ -0,0 +1 @@
U2FsdGVkX1+P63MsIfF2N9/XM16sWy0/pZMWy+0Ptf+FhySv/AiawI4Pcf/HQU7Auxde+GszGb7+t+i1Ckngo6VK9PkwALR87GbqCJtGeMazZTkEGkmNuePdpej0O3oAuwITI1FOKPW4Xe4RIFAkJfghqCgUD0Ps0Y6sPwIxOX7fTi4TopNksRMQ5X+UvezrGnPsF5EC2CAPmKwtRFWVqx5csAFhifvMxwEA+WCA7l9KLLvcybGtY4RZf0uSLb6qGxrJBN/zbQ1MMxVw9JbUML09uQ3VKLvQMJZmjctIpZDr4YEMMMdDD/qDk64feV8Tc5VPENsyl2i9kxZ4Z3s5pUu6oDm+/GE2ag0OMITgg7Wc6QpeqlWJwgAeGuqxz2nnuQfGbhHv4g800Hwc7C6ylYgHHeSY+gx39PyDQy1tE7vtW/83ZQUSgWRXxMYTMHUgYKh6P3XG+HxJz6vRjpZqwjPIc3jd253EQnHVG4YZ7VxjBFwpcmidnkvKMa+dvQSZypOL3XLKlMSGdMWFbtVuw4MMYnTBadWkS3eekdVFtvpG5NRqga0TBxPeoISLsn717u9BYcROHvPzvX/MKG7S9CGClZb4mYbOPxKmENPT6AvQbCrzOlK6X/kHTLOxivc2O5uzL4CRXBKFDeaAUJqs/PLZuCfvdmMBKPiMQpCLtFBYCXoxDarggu2D8EZYcoxFPJ/YE7sy/bFa52hQ0V1wYXkhwez1Q4Q3PH1dqdzOESOI30KEOk9TwkEhdV69ymZ6rb6cXWy1KOph2Vo8dQhgNItgvNCCyEuojR54eXnh8x7R1FHmITrobfRZZOYmpFFRZntirTpkEDvt8sPR7G+STcfKc+OgNVoCSV2Ca79Ex0BVACRxWTrafC+VX80mQGldt3wwTk2P+7mcTl0NqH6F7hfzaDjHLy/pbd/78lJLToMz7K1/PaPfOAlVsD5MTh2hefAmqTDZRKfBHyQQjwgh2hNqpmzP2tcQqO5qeigk3fzvZjyXAX2Zqu85hc6QI0Quc4zRS1hb0uY93mnOLnOksTgCNPZHDCTmzT7N0v4D/oABq2VyFFDIvMlzy1kD1WnpmhPldfm+J268QnGfMoR7ob6quZB660xc3V/9zN16ZeVKAhFfzwVHApubxTOYtIIQWLzGj9Q2eqYCXf2p3n8CYF8YV3L1LT6FxEK//MOBXZZbf7JC7tkSWb0EdRf2wuxlDocrxXzSTuIFlpBuG4Dcjf9wVCn3NuQJhqVAdxSX98K/sJpKYPELyYwTvSwG8v2MApAAgv7v+NfgSswXGCVlYpeildrVM4AafoZjBVb4OJK5YvDgmhyhzKP8copwAwi2/tGK8x/fiT0oTQhR60c7wZBuaj3/D0uDxNCcRct5/DYvb3dU1qMDmPfw0BNE7xhHouVLM12tdSnZxxFWgq5747aOpD+yYfgQBxToXsAbfEZLZLfRXPqcNjJWdnY1ZlqARyrzFKbBlGu/HhdEpldDxhdOFLkjytrA7NzvKJKfWvxbtv+jkVYL6SNdLfRoaUe0ecPJH/Gq12FhRXXudPRQT/xqQ5TXCJZ41gkmd7B9oMuVrmpDhaSKkX1loSj/Mhezzq5vIrZPd3+qK7wMeFrxvY7E4wIlK1gZdyQXBzUh8VPQzY6KFEDV4fekqkxkdKm4oX8Ij3IJfuX52+lIJdhL3t3p/j7mLLc1L9hvRudgWnujLEOi8syJv8EkKARC9OmO0S1okf1R805fenbS6MiM7zoFqza6iNn/9uJwgzCjxoTqpLoPwU2OYQyN3h4LnRoV5qsCo/RUunrdkAAQp85iTMSTWm0ux9R/jy+IHe62zwfrCq9xsdqMZ/0MZPRAyGIh5Cr0JbhXwT5s8meI5MgjJ107+hfSdo0ufSqGnceTfvfNVGUt8KO2PWFwgb7fyAiirnIL53lgKOo8dD4tDVZjfClGdIG7KJFty5M3rzyCyPl5ZeQJRD6YZVZG3HPFt4BDPDecPMK63zvrX9zSK3FV5GyBgZQQVMQbou+gpnGdSGw3NI0agN47hRYjFsbvGPrVjBKiPrfnBKkd2lyqKvd2cns/yYmgGXtehYasnPLU01dvRHicQ+TduvMR4NYMCytiDbiKqwBaYI+yt7cKNSf4cQQT9b3DD8pl9dQhzrnAY0tHxSLCVZzl7LCbYzaITQRLzBoIzCmPrFGLj6j2HuGc+gQ3Hd2/HisVAYxbW6Fu0SXQa9nnGW7k+3AJFRs5EvF++QffNW2rMYaBqr+c1jfwURzc7dGD8PNtDJkx3T6eSKx9l4iWR5/LDW+SXrk2OCzMdznsBNCJJbaKzUqs1HlIEuwQ/hHMjk4M+f4/SmbECE3cQXd+nseEoUnKbn/MiWi2lNyjacXuLTtrjDITK9jIZD0Ixfci5Eer2fuPJw7RXr1rb8bjlZWttpjN45NORLB8usjiiEdklPjQMUmTjXgALATorkWt6vc76zB4Fbk5jOwJ53+W1RqvUxt5S+WIhH55jOxYHqrEppguVMw1w4RIceaL/CYunwHxiD5w58hjJ4bR5fXjwARwM/EDGHgPvfV0dDxhBxbBtKrcemUYlBI40wwbOuDgfdnfcJNVCxaVX0Hto/VuqXvfP3eoQT2Vasz96y2/GbSS0rBGtNn9EzPwGZvBrumbl4ezQJp+HjSN65GLXjDNY+ZajGaOs+pK1O8ooAqODnfAhPd3LkMxQxlkqNy8l4KFUPA1lqd8ohK+4UThmfRCQDKPLTdFV/oTvEdBo0d/oN3xJM9ValH+0e3w86b+cmXnLzUxUzeIhmXWW8ctGtNmzV2fewh8ChPl75MCU5d88uzC03YbfZHOOtokTqkhLYhz4il7KSnA+EFEWB7GOvVqnkM/LAwDcJ/1qvqBah+WnDs9uQXTF+QXn/N1q/+83lu9JQWK0crV2mTJHk8efTHBn6oEN8P9pXf2BCJQzbukA/q50QvQU33p8P2VOqL/bHwsaolIhWrys5lyM4pwpMqxvnP5YIFjEhGhcCHLtqL5jvETh3X78HHiEhjmNzwtj3wa/NrgvzJhepqWWGGBK1DEYoMj21cFPpUeB/+2

View File

@ -1,11 +1,15 @@
const { Server } = require('socket.io')
const SFTPClient = require('ssh2-sftp-client')
const rawPath = require('path') const rawPath = require('path')
const fs = require('fs-extra') const fs = require('fs-extra')
const SFTPClient = require('ssh2-sftp-client')
const { readHostList, readSSHRecord, verifyAuthSync, AESDecryptSync } = require('../utils')
const { sftpCacheDir } = require('../config')
const CryptoJS = require('crypto-js') const CryptoJS = require('crypto-js')
const { Server } = require('socket.io')
const { sftpCacheDir } = require('../config')
const { verifyAuthSync } = require('../utils/verify-auth')
const { AESDecryptAsync } = require('../utils/encrypt')
const { isAllowedIp } = require('../utils/tools')
const { HostListDB, CredentialsDB } = require('../utils/db-class')
const hostListDB = new HostListDB().getInstance()
const credentialsDB = new CredentialsDB().getInstance()
// 读取切片 // 读取切片
const pipeStream = (path, writeStream) => { const pipeStream = (path, writeStream) => {
@ -34,6 +38,7 @@ function listenInput(sftpClient, socket) {
socket.on('rm_dir', async (path) => { socket.on('rm_dir', async (path) => {
const exists = await sftpClient.exists(path) const exists = await sftpClient.exists(path)
if (!exists) return socket.emit('not_exists_dir', '目录不存在或当前不可访问') if (!exists) return socket.emit('not_exists_dir', '目录不存在或当前不可访问')
consola.info('rm_dir: ', path)
try { try {
let res = await sftpClient.rmdir(path, true) // 递归删除 let res = await sftpClient.rmdir(path, true) // 递归删除
socket.emit('rm_success', res) socket.emit('rm_success', res)
@ -125,6 +130,7 @@ function listenInput(sftpClient, socket) {
const exists = await sftpClient.exists(fullPath) const exists = await sftpClient.exists(fullPath)
if (exists) continue if (exists) continue
await sftpClient.mkdir(fullPath, true) await sftpClient.mkdir(fullPath, true)
socket.emit('create_remote_dir_progress', fullPath)
consola.info('创建目录:', fullPath) consola.info('创建目录:', fullPath)
} }
socket.emit('create_remote_dir_success') socket.emit('create_remote_dir_success')
@ -209,36 +215,41 @@ module.exports = (httpServer) => {
}) })
serverIo.on('connection', (socket) => { serverIo.on('connection', (socket) => {
// 前者兼容nginx反代, 后者兼容nodejs自身服务 // 前者兼容nginx反代, 后者兼容nodejs自身服务
let clientIp = socket.handshake.headers['x-forwarded-for'] || socket.handshake.address let requestIP = socket.handshake.headers['x-forwarded-for'] || socket.handshake.address
if (!isAllowedIp(requestIP)) {
socket.emit('ip_forbidden', 'IP地址不在白名单中')
socket.disconnect()
return
}
let sftpClient = new SFTPClient() let sftpClient = new SFTPClient()
consola.success('terminal websocket 已连接') consola.success('terminal websocket 已连接')
socket.on('create', async ({ host: ip, token }) => { socket.on('create', async ({ hostId, token }) => {
const { code } = await verifyAuthSync(token, clientIp) const { code } = await verifyAuthSync(token, requestIP)
consola.log('code:', code)
if (code !== 1) { if (code !== 1) {
socket.emit('token_verify_fail') socket.emit('token_verify_fail')
socket.disconnect() socket.disconnect()
return return
} }
const targetHostInfo = await hostListDB.findOneAsync({ _id: hostId })
const hostList = await readHostList() if (!targetHostInfo) throw new Error(`Host with ID ${ hostId } not found`)
const targetHostInfo = hostList.find(item => item.host === ip) || {}
let { authType, host, port, username } = targetHostInfo let { authType, host, port, username } = targetHostInfo
if (!host) return socket.emit('create_fail', `查找${ ip }】凭证信息失败`) if (!host) return socket.emit('create_fail', `查找id【${ hostId }】凭证信息失败`)
let authInfo = { host, port, username } let authInfo = { host, port, username }
// 解密放到try里面防止报错【commonKey必须配对, 否则需要重新添加服务器密钥】 // 解密放到try里面防止报错【commonKey必须配对, 否则需要重新添加服务器密钥】
if (authType === 'credential') { if (authType === 'credential') {
let credentialId = await AESDecryptSync(targetHostInfo[authType]) let credentialId = await AESDecryptAsync(targetHostInfo[authType])
const sshRecordList = await readSSHRecord() const sshRecordList = await credentialsDB.findAsync({})
const sshRecord = sshRecordList.find(item => item._id === credentialId) const sshRecord = sshRecordList.find(item => item._id === credentialId)
authInfo.authType = sshRecord.authType authInfo.authType = sshRecord.authType
authInfo[authInfo.authType] = await AESDecryptSync(sshRecord[authInfo.authType]) authInfo[authInfo.authType] = await AESDecryptAsync(sshRecord[authInfo.authType])
} else { } else {
authInfo[authType] = await AESDecryptSync(targetHostInfo[authType]) authInfo[authType] = await AESDecryptAsync(targetHostInfo[authType])
} }
consola.info('准备连接Sftp面板', host) consola.info('准备连接Sftp面板', host)
targetHostInfo[targetHostInfo.authType] = await AESDecryptSync(targetHostInfo[targetHostInfo.authType]) targetHostInfo[targetHostInfo.authType] = await AESDecryptAsync(targetHostInfo[targetHostInfo.authType])
consola.log('连接信息', { username, port, authType }) consola.log('连接信息', { username, port, authType })
sftpClient sftpClient

View File

@ -1,31 +1,105 @@
const path = require('path')
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 } = require('../utils') const { verifyAuthSync } = require('../utils/verify-auth')
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 createTerminal(socket, sshClient) { async function getConnectionOptions(hostId) {
sshClient.shell({ term: 'xterm-color' }, (err, stream) => { 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) => {
targetSSHClient.shell({ term: 'xterm-color' }, (err, stream) => {
resolve(stream)
if (err) return socket.emit('output', err.toString()) if (err) return socket.emit('output', err.toString())
// 终端输出
stream stream
.on('data', (data) => { .on('data', (data) => {
socket.emit('output', data.toString()) socket.emit('output', data.toString())
}) })
.on('close', () => { .on('close', () => {
consola.info('关闭终端') consola.info('交互终端已关闭')
sshClient.end() targetSSHClient.end()
}) })
// web端输入 socket.emit('connect_shell_success') // 已连接终端web端可以执行指令了
socket.on('input', key => {
if (sshClient._sock.writable === false) return consola.info('终端连接已关闭')
stream.write(key)
}) })
socket.emit('connect_terminal') // 已连接终端web端可以执行指令了 })
}
// 监听按键重置终端大小 async function createTerminal(hostId, socket, targetSSHClient) {
socket.on('resize', ({ rows, cols }) => { return new Promise(async (resolve) => {
// consola.info('更改tty终端行&列: ', { rows, cols }) const targetHostInfo = await hostListDB.findOneAsync({ _id: hostId })
stream.setWindow(rows, cols) 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 {
let { authInfo: targetConnectionOptions } = await getConnectionOptions(hostId)
let jumpHostResult = connectByJumpHosts && (await connectByJumpHosts(jumpHosts, targetConnectionOptions.host, targetConnectionOptions.port, socket))
if (jumpHostResult) {
targetConnectionOptions.sock = jumpHostResult.sock
}
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 })
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, targetSSHClient)
resolve(stream)
}) })
.on('close', (err) => {
if (closeNoticeFlag) return closeNoticeFlag = false
const closeReason = err ? '发生错误导致连接断开' : '正常断开连接'
consola.info(`终端连接断开(${ closeReason }): ${ host }`)
socket.emit('connect_close', { reason: closeReason })
})
.on('error', (err) => {
closeNoticeFlag = true
sendNoticeAsync('host_login', '终端登录', `别名: ${ name } \n IP${ host } \n 端口:${ port } \n 状态: 登录失败`)
consola.error('连接终端失败:', host, err.message)
socket.emit('connect_terminal_fail', err.message)
})
.on('keyboard-interactive', function (name, instructions, instructionsLang, prompts, finish) {
finish([targetConnectionOptions[authType]])
})
.connect({
tryKeyboard: true,
...targetConnectionOptions
// debug: (info) => console.log(info)
})
} catch (err) {
consola.error('创建终端失败: ', host, err.message)
socket.emit('create_terminal_fail', err.message)
}
}) })
} }
@ -33,68 +107,57 @@ module.exports = (httpServer) => {
const serverIo = new Server(httpServer, { const serverIo = new Server(httpServer, {
path: '/terminal', path: '/terminal',
cors: { cors: {
origin: '*' // 'http://localhost:8080' origin: '*'
} }
}) })
serverIo.on('connection', (socket) => {
// 前者兼容nginx反代, 后者兼容nodejs自身服务
let clientIp = socket.handshake.headers['x-forwarded-for'] || socket.handshake.address
let sshClient = new SSHClient()
consola.success('terminal websocket 已连接')
socket.on('create', async ({ host: ip, token }) => { let connectionCount = 0
const { code } = await verifyAuthSync(token, clientIp)
serverIo.on('connection', (socket) => {
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地址不在白名单中')
socket.disconnect()
return
}
consola.success('terminal websocket 已连接')
let targetSSHClient = null
socket.on('create', async ({ hostId, token }) => {
const { code } = await verifyAuthSync(token, requestIP)
if (code !== 1) { if (code !== 1) {
socket.emit('token_verify_fail') socket.emit('token_verify_fail')
socket.disconnect() socket.disconnect()
return return
} }
const hostList = await readHostList() targetSSHClient = new SSHClient()
const targetHostInfo = hostList.find(item => item.host === ip) || {} let stream = null
let { authType, host, port, username } = targetHostInfo function listenerInput(key) {
if (!host) return socket.emit('create_fail', `查找【${ ip }】凭证信息失败`) if (targetSSHClient._sock.writable === false) return consola.info('终端连接已关闭,禁止输入')
let authInfo = { host, port, username } stream && stream.write(key)
// 统一使用commonKey解密
try {
// 解密放到try里面防止报错【commonKey必须配对, 否则需要重新添加服务器密钥】
if (authType === 'credential') {
let credentialId = await AESDecryptSync(targetHostInfo[authType])
const sshRecordList = await readSSHRecord()
const sshRecord = sshRecordList.find(item => item._id === credentialId)
authInfo.authType = sshRecord.authType
authInfo[authInfo.authType] = await AESDecryptSync(sshRecord[authInfo.authType])
} else {
authInfo[authType] = await AESDecryptSync(targetHostInfo[authType])
} }
consola.info('准备连接终端:', host) function resizeShell({ rows, cols }) {
// targetHostInfo[targetHostInfo.authType] = await AESDecryptSync(targetHostInfo[targetHostInfo.authType]) stream && stream.setWindow(rows, cols)
consola.log('连接信息', { username, port, authType }) }
sshClient socket.on('input', listenerInput)
.on('ready', () => { socket.on('resize', resizeShell)
consola.success('连接终端成功:', host) stream = await createTerminal(hostId, socket, targetSSHClient)
socket.emit('connect_success', `已连接到终端:${ host }`)
createTerminal(socket, sshClient)
}) })
.on('error', (err) => {
console.log(err) socket.on('get_ping', async (ip) => {
consola.error('连接终端失败:', err.level) try {
socket.emit('connect_fail', err.message) socket.emit('ping_data', await ping(ip, 2500))
}) } catch (error) {
.connect({ socket.emit('ping_data', { success: false, msg: error.message })
...authInfo
// debug: (info) => console.log(info)
})
} catch (err) {
consola.error('创建终端失败:', err.message)
socket.emit('create_fail', err.message)
} }
}) })
socket.on('disconnect', (reason) => { socket.on('disconnect', (reason) => {
consola.info('终端连接断开:', reason) connectionCount--
sshClient.end() consola.info(`终端socket连接断开: ${ reason } - 当前连接数: ${ connectionCount }`)
sshClient.destroy()
sshClient = null
}) })
}) })
} }
module.exports.getConnectionOptions = getConnectionOptions

View File

@ -1,26 +1,40 @@
module.exports = (content) => { module.exports = (content) => {
return `<!DOCTYPE html return `<!DOCTYPE html>
PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"> <html>
<html xmlns="http://www.w3.org/1999/xhtml">
<head> <head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" /> <style>
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> body {
font-family: Arial, sans-serif;
margin: 15px 5px;
color: #333;
background-color: #f4f4f4;
line-height: 1.6;
}
.container {
background-color: #fff;
padding: 20px;
border-radius: 8px;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
}
h1 {
color: #4CAF50;
}
p {
margin: 12px 0;
}
.footer {
text-align: center;
font-size: 0.9em;
color: #777;
}
</style>
</head> </head>
<body>
<body style="margin: 0; padding: 0;text-align: center;"> <div class="container">
<table border="0" cellpadding="0" cellspacing="0" width="100%"> <p>${ content }</p>
<tr> <p class="footer">通知发送时间: ${ new Date() }</p>
<td> </div>
<h3 style="font-size: 18px;color: #5992D3;padding:0 0 0 10px;">
${ content }
</h3>
</td>
</tr>
</table>
</body> </body>
</html> </html>
` `
} }

View File

@ -0,0 +1,43 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>403 禁止访问</title>
<link rel="icon" href="data:;base64,=">
<style>
body {
font-family: Arial, sans-serif;
background-color: #f4f4f4;
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
margin: 0;
}
.container {
text-align: center;
padding: 20px;
border-radius: 8px;
}
h1 {
color: #d9534f;
}
p {
color: #333;
}
</style>
</head>
<body>
<div class="container">
<h1>403 禁止访问</h1>
<p>抱歉,您没有权限访问此页面。</p>
</div>
</body>
</html>

View File

@ -1,10 +1,22 @@
const Datastore = require('@seald-io/nedb') const Datastore = require('@seald-io/nedb')
const { credentialsDBPath, hostListDBPath, keyDBPath, emailNotifyDBPath, notifyConfDBPath, groupConfDBPath, scriptsDBPath, onekeyDBPath } = require('../config') const {
credentialsDBPath,
hostListDBPath,
keyDBPath,
notifyDBPath,
notifyConfigDBPath,
groupConfDBPath,
scriptsDBPath,
onekeyDBPath,
logDBPath,
plusDBPath
} = require('../config')
module.exports.KeyDB = class KeyDB { module.exports.KeyDB = class KeyDB {
constructor() { constructor() {
if (!KeyDB.instance) { if (!KeyDB.instance) {
KeyDB.instance = new Datastore({ filename: keyDBPath, autoload: true }) KeyDB.instance = new Datastore({ filename: keyDBPath, autoload: true })
// KeyDB.instance.setAutocompactionInterval(5000)
} }
} }
getInstance() { getInstance() {
@ -16,6 +28,7 @@ module.exports.HostListDB = class HostListDB {
constructor() { constructor() {
if (!HostListDB.instance) { if (!HostListDB.instance) {
HostListDB.instance = new Datastore({ filename: hostListDBPath, autoload: true }) HostListDB.instance = new Datastore({ filename: hostListDBPath, autoload: true })
// HostListDB.instance.setAutocompactionInterval(5000)
} }
} }
getInstance() { getInstance() {
@ -23,21 +36,23 @@ module.exports.HostListDB = class HostListDB {
} }
} }
module.exports.SshRecordDB = class SshRecordDB { module.exports.CredentialsDB = class CredentialsDB {
constructor() { constructor() {
if (!SshRecordDB.instance) { if (!CredentialsDB.instance) {
SshRecordDB.instance = new Datastore({ filename: credentialsDBPath, autoload: true }) CredentialsDB.instance = new Datastore({ filename: credentialsDBPath, autoload: true })
// CredentialsDB.instance.setAutocompactionInterval(5000)
} }
} }
getInstance() { getInstance() {
return SshRecordDB.instance return CredentialsDB.instance
} }
} }
module.exports.NotifyDB = class NotifyDB { module.exports.NotifyDB = class NotifyDB {
constructor() { constructor() {
if (!NotifyDB.instance) { if (!NotifyDB.instance) {
NotifyDB.instance = new Datastore({ filename: notifyConfDBPath, autoload: true }) NotifyDB.instance = new Datastore({ filename: notifyDBPath, autoload: true })
// NotifyDB.instance.setAutocompactionInterval(5000)
} }
} }
getInstance() { getInstance() {
@ -45,10 +60,23 @@ module.exports.NotifyDB = class NotifyDB {
} }
} }
module.exports.NotifyConfigDB = class NotifyConfigDB {
constructor() {
if (!NotifyConfigDB.instance) {
NotifyConfigDB.instance = new Datastore({ filename: notifyConfigDBPath, autoload: true })
// NotifyConfigDB.instance.setAutocompactionInterval(5000)
}
}
getInstance() {
return NotifyConfigDB.instance
}
}
module.exports.GroupDB = class GroupDB { module.exports.GroupDB = class GroupDB {
constructor() { constructor() {
if (!GroupDB.instance) { if (!GroupDB.instance) {
GroupDB.instance = new Datastore({ filename: groupConfDBPath, autoload: true }) GroupDB.instance = new Datastore({ filename: groupConfDBPath, autoload: true })
// GroupDB.instance.setAutocompactionInterval(5000)
} }
} }
getInstance() { getInstance() {
@ -56,21 +84,11 @@ module.exports.GroupDB = class GroupDB {
} }
} }
module.exports.EmailNotifyDB = class EmailNotifyDB {
constructor() {
if (!EmailNotifyDB.instance) {
EmailNotifyDB.instance = new Datastore({ filename: emailNotifyDBPath, autoload: true })
}
}
getInstance() {
return EmailNotifyDB.instance
}
}
module.exports.ScriptsDB = class ScriptsDB { module.exports.ScriptsDB = class ScriptsDB {
constructor() { constructor() {
if (!ScriptsDB.instance) { if (!ScriptsDB.instance) {
ScriptsDB.instance = new Datastore({ filename: scriptsDBPath, autoload: true }) ScriptsDB.instance = new Datastore({ filename: scriptsDBPath, autoload: true })
// ScriptsDB.instance.setAutocompactionInterval(5000)
} }
} }
getInstance() { getInstance() {
@ -82,9 +100,33 @@ module.exports.OnekeyDB = class OnekeyDB {
constructor() { constructor() {
if (!OnekeyDB.instance) { if (!OnekeyDB.instance) {
OnekeyDB.instance = new Datastore({ filename: onekeyDBPath, autoload: true }) OnekeyDB.instance = new Datastore({ filename: onekeyDBPath, autoload: true })
// OnekeyDB.instance.setAutocompactionInterval(5000)
} }
} }
getInstance() { getInstance() {
return OnekeyDB.instance return OnekeyDB.instance
} }
} }
module.exports.LogDB = class LogDB {
constructor() {
if (!LogDB.instance) {
LogDB.instance = new Datastore({ filename: logDBPath, autoload: true })
// LogDB.instance.setAutocompactionInterval(5000)
}
}
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
}
}

View File

@ -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

View File

@ -1,60 +0,0 @@
const nodemailer = require('nodemailer')
const { readSupportEmailList, readUserEmailList } = require('./storage')
const commonTemp = require('../template/commonTemp')
const emailCode = {
SUCCESS: 0,
FAIL: -1
}
const emailTransporter = async (params = {}) => {
let { toEmail, title, html } = params
try {
if(!toEmail) throw Error('missing params: toEmail')
let userEmail = (await readUserEmailList()).find(({ auth }) => auth.user === toEmail)
if(!userEmail) throw Error(`${ toEmail } 不存在已保存的配置文件中, 请移除后重新添加`)
let { target } = userEmail
let emailServerConf = (await readSupportEmailList()).find((item) => item.target === target)
if(!emailServerConf) throw Error(`邮箱类型不支持:${ target }`)
const timeout = 1000*5
let options = Object.assign({}, userEmail, emailServerConf, { greetingTimeout: timeout, connectionTimeout: timeout })
let transporter = nodemailer.createTransport(options)
let info = await transporter.sendMail({
from: userEmail.auth.user, // sender address
to: userEmail.auth.user, // list of receivers
subject: `EasyNode: ${ title }`,
html
})
// consola.success('email发送成功', info.accepted)
return { code: emailCode.SUCCESS, msg: `send successful${ info.accepted }` }
} catch(error) {
// consola.error(`email发送失败(${ toEmail })`, error.message || error)
return { code: emailCode.FAIL, msg: error }
}
}
const sendEmailToConfList = (title, content) => {
// eslint-disable-next-line
return new Promise(async (res, rej) => {
let emailList = await readUserEmailList()
if(Array.isArray(emailList) && emailList.length >= 1) {
for (const item of emailList) {
const toEmail = item.auth.user
await emailTransporter({ toEmail, title, html: commonTemp(content) })
.then(({ code }) => {
if(code === 0) {
consola.success('已发送邮件通知: ', toEmail, title)
return res({ code: emailCode.SUCCESS })
}
consola.error('邮件通知发送失败: ', toEmail, title)
return rej({ code: emailCode.FAIL })
})
}
}
})
}
module.exports = {
emailTransporter,
sendEmailToConfList
}

View File

@ -1,13 +1,14 @@
const CryptoJS = require('crypto-js') const CryptoJS = require('crypto-js')
const rawCrypto = require('crypto') const rawCrypto = require('crypto')
const NodeRSA = require('node-rsa') const NodeRSA = require('node-rsa')
const { readKey } = require('./storage.js') const { KeyDB } = require('./db-class')
const keyDB = new KeyDB().getInstance()
// rsa非对称 私钥解密 // rsa非对称 私钥解密
const RSADecryptSync = async (ciphertext) => { const RSADecryptAsync = async (ciphertext) => {
if (!ciphertext) return if (!ciphertext) return
let { privateKey } = await readKey() let { privateKey } = await keyDB.findOneAsync({})
privateKey = await AESDecryptSync(privateKey) // 先解密私钥 privateKey = await AESDecryptAsync(privateKey) // 先解密私钥
const rsakey = new NodeRSA(privateKey) const rsakey = new NodeRSA(privateKey)
rsakey.setOptions({ encryptionScheme: 'pkcs1', environment: 'browser' }) // Must Set It When Frontend Use jsencrypt rsakey.setOptions({ encryptionScheme: 'pkcs1', environment: 'browser' }) // Must Set It When Frontend Use jsencrypt
const plaintext = rsakey.decrypt(ciphertext, 'utf8') const plaintext = rsakey.decrypt(ciphertext, 'utf8')
@ -15,17 +16,17 @@ const RSADecryptSync = async (ciphertext) => {
} }
// aes对称 加密(default commonKey) // aes对称 加密(default commonKey)
const AESEncryptSync = async (text, key) => { const AESEncryptAsync = async (text, key) => {
if (!text) return if (!text) return
let { commonKey } = await readKey() let { commonKey } = await keyDB.findOneAsync({})
let ciphertext = CryptoJS.AES.encrypt(text, key || commonKey).toString() let ciphertext = CryptoJS.AES.encrypt(text, key || commonKey).toString()
return ciphertext return ciphertext
} }
// aes对称 解密(default commonKey) // aes对称 解密(default commonKey)
const AESDecryptSync = async (ciphertext, key) => { const AESDecryptAsync = async (ciphertext, key) => {
if (!ciphertext) return if (!ciphertext) return
let { commonKey } = await readKey() let { commonKey } = await keyDB.findOneAsync({})
let bytes = CryptoJS.AES.decrypt(ciphertext, key || commonKey) let bytes = CryptoJS.AES.decrypt(ciphertext, key || commonKey)
let originalText = bytes.toString(CryptoJS.enc.Utf8) let originalText = bytes.toString(CryptoJS.enc.Utf8)
return originalText return originalText
@ -37,8 +38,8 @@ const SHA1Encrypt = (clearText) => {
} }
module.exports = { module.exports = {
RSADecryptSync, RSADecryptAsync,
AESEncryptSync, AESEncryptAsync,
AESDecryptSync, AESDecryptAsync,
SHA1Encrypt SHA1Encrypt
} }

View File

@ -0,0 +1,93 @@
const { getLocalNetIP } = require('./tools')
const { AESEncryptAsync } = require('./encrypt')
const version = require('../../package.json').version
const { plusServer1, plusServer2 } = require('./plus-server')
const { PlusDB } = require('./db-class')
const plusDB = new PlusDB().getInstance()
async function getLicenseInfo(key = '') {
const { key: plusKey } = await plusDB.findOneAsync({}) || {}
// console.log('plusKey: ', plusKey)
// console.log('key: ', key)
// console.log('process.env.PLUS_KEY: ', process.env.PLUS_KEY)
key = key || plusKey || process.env.PLUS_KEY
if (!key || key.length < 16) return { success: false, msg: 'Invalid Plus Key' }
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 { success: false, msg: 'get public ip failed' }
}
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(plusServer1 + '/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(plusServer2 + '/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 plusData = { key, decryptKey, expiryDate, usedIPCount, maxIPs, usedIPs }
let count = await plusDB.countAsync({})
if (count === 0) {
await plusDB.insertAsync(plusData)
} else {
await plusDB.removeAsync({}, { multi: true })
await plusDB.insertAsync(plusData)
}
return { success: true, msg: '激活成功' }
}
consola.error('activate plus failed: ', data)
return { success: false, msg: '激活失败' }
} catch (error) {
consola.error(`activate plus failed: ${ error.message || error.errMsg?.message }`)
if (error.clear) {
await plusDB.removeAsync({}, { multi: true })
}
return { success: false, msg: error.message || error.errMsg?.message }
}
}
module.exports = getLicenseInfo

View File

@ -1,62 +0,0 @@
const {
readSSHRecord,
writeSSHRecord,
readHostList,
writeHostList,
readKey,
writeKey,
readSupportEmailList,
readUserEmailList,
writeUserEmailList,
readNotifyList,
getNotifySwByType,
writeNotifyList,
readGroupList,
writeGroupList,
readScriptList,
writeScriptList,
readOneKeyRecord,
writeOneKeyRecord,
deleteOneKeyRecord
} = require('./storage')
const { RSADecryptSync, AESEncryptSync, AESDecryptSync, SHA1Encrypt } = require('./encrypt')
const { verifyAuthSync, isProd } = require('./verify-auth')
const { getNetIPInfo, throwError, isIP, randomStr, getUTCDate, formatTimestamp, shellThrottle } = require('./tools')
const { emailTransporter, sendEmailToConfList } = require('./email')
module.exports = {
getNetIPInfo,
throwError,
isIP,
randomStr,
getUTCDate,
formatTimestamp,
shellThrottle,
verifyAuthSync,
isProd,
RSADecryptSync,
AESEncryptSync,
AESDecryptSync,
SHA1Encrypt,
readSSHRecord,
writeSSHRecord,
readHostList,
writeHostList,
readKey,
writeKey,
readSupportEmailList,
readUserEmailList,
writeUserEmailList,
emailTransporter,
sendEmailToConfList,
readNotifyList,
getNotifySwByType,
writeNotifyList,
readGroupList,
writeGroupList,
readScriptList,
writeScriptList,
readOneKeyRecord,
writeOneKeyRecord,
deleteOneKeyRecord
}

101
server/app/utils/notify.js Normal file
View File

@ -0,0 +1,101 @@
const path = require('path')
const decryptAndExecuteAsync = require('./decrypt-file')
const nodemailer = require('nodemailer')
const axios = require('axios')
const commonTemp = require('../template/commonTemp')
const { NotifyDB, NotifyConfigDB } = require('./db-class')
const notifyConfigDB = new NotifyConfigDB().getInstance()
const notifyDB = new NotifyDB().getInstance()
function sendServerChan(sendKey, title, content) {
return new Promise((async (resolve, reject) => {
try {
consola.info('server酱通知预发送: ', title)
const url = `https://sctapi.ftqq.com/${ sendKey }.send`
const params = new URLSearchParams({ text: title, desp: content })
let { data } = await axios.post(url, params, {
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
}
})
resolve(data)
consola.info('server酱通知发送成功: ', title)
} catch (error) {
reject(error)
consola.error('server酱通知发送失败: ', error)
}
}))
}
function sendEmail({ service, user, pass }, title, content) {
return new Promise((async (resolve, reject) => {
try {
consola.info('邮箱通知预发送: ', title)
let transporter = nodemailer.createTransport({
service,
auth: {
user,
pass
}
})
await transporter.sendMail({
from: user,
to: user,
subject: title,
// text: '', // 纯文本版本内容如果收件人的邮件客户端不支持HTML显示就会显示这个文本
html: commonTemp(content)
})
consola.info('邮件通知发送成功: ', title)
resolve()
} catch (error) {
reject(error)
consola.error('邮件通知发送失败: ', error)
}
}))
}
// 异步发送通知
async function sendNoticeAsync(noticeAction, title, content) {
try {
let notifyList = await notifyDB.findAsync({})
let { sw } = notifyList.find((item) => item.type === noticeAction) // 获取对应动作的通知开关
// console.log('notify swtich: ', noticeAction, sw)
if (!sw) return consola.info('通知开关关闭, 不发送通知: ', noticeAction)
let notifyConfig = await notifyConfigDB.findOneAsync({})
let { type } = notifyConfig
if (!type) return consola.error('通知类型不存在: ', type)
title = `EasyNode-${ title }`
content += `\n通知发送时间:${ new Date() }`
switch (type) {
case 'sct':
let { sendKey } = notifyConfig['sct']
if (!sendKey) return consola.info('未发送server酱通知, sendKey 为空')
await sendServerChan(sendKey, title, content)
break
case 'email':
let { service, user, pass } = notifyConfig['email']
if (!service || !user || !pass) return consola.info('未发送邮件通知通知, 未配置邮箱: ', { service, user, pass })
await sendEmail({ service, user, pass }, title, content)
break
case 'tg':
let { token, chatId } = notifyConfig['tg']
if (!token || !chatId) return consola.info('未发送Telegram通知, 未配置token或chatId: ', { token, chatId })
let { sendTg } = await decryptAndExecuteAsync(path.join(__dirname, 'plus.js'))
if (!sendTg) return consola.info('未发送Telegram通知, Plus功能解析失败')
await sendTg({ token, chatId }, title, content)
break
default:
consola.info('未配置通知类型: ', type)
break
}
} catch (error) {
consola.error('通知发送失败: ', error)
}
}
module.exports = {
sendNoticeAsync,
sendServerChan,
sendEmail
}

View File

@ -1,84 +0,0 @@
const osu = require('node-os-utils')
const os = require('os')
let cpu = osu.cpu
let mem = osu.mem
let drive = osu.drive
let netstat = osu.netstat
let osuOs = osu.os
let users = osu.users
async function cpuInfo() {
let cpuUsage = await cpu.usage(200)
let cpuCount = cpu.count()
let cpuModel = cpu.model()
return {
cpuUsage,
cpuCount,
cpuModel
}
}
async function memInfo() {
let memInfo = await mem.info()
return {
...memInfo
}
}
async function driveInfo() {
let driveInfo = {}
try {
driveInfo = await drive.info()
} catch {
// console.log(driveInfo)
}
return driveInfo
}
async function netstatInfo() {
let netstatInfo = await netstat.inOut()
return netstatInfo === 'not supported' ? {} : netstatInfo
}
async function osInfo() {
let type = os.type()
let platform = os.platform()
let release = os.release()
let uptime = osuOs.uptime()
let ip = osuOs.ip()
let hostname = osuOs.hostname()
let arch = osuOs.arch()
return {
type,
platform,
release,
ip,
hostname,
arch,
uptime
}
}
async function openedCount() {
let openedCount = await users.openedCount()
return openedCount === 'not supported' ? 0 : openedCount
}
module.exports = async () => {
let data = {}
try {
data = {
cpuInfo: await cpuInfo(),
memInfo: await memInfo(),
driveInfo: await driveInfo(),
netstatInfo: await netstatInfo(),
osInfo: await osInfo(),
openedCount: await openedCount()
}
return data
} catch(err){
console.error('获取系统信息出错:', err)
return err.toString()
}
}

View File

@ -0,0 +1,4 @@
module.exports = {
plusServer1: 'https://en1.221022.xyz',
plusServer2: 'https://en2.221022.xyz'
}

1
server/app/utils/plus.js Normal file
View File

@ -0,0 +1 @@
U2FsdGVkX1+Gus2FIC0WsNp0rUXPA+Ui1NQUjtnqP6Ycb1pyHglCADvKu51oxYaGJ0ZdoRZYo7YP3tQgIhp3f96WxP1/QFdypVrVlS7+jbAH6Gzc4CPlD3UeFsCm1j32ArFX60tPSSkq6+DJ3OF6pIVxstGIbCkmv5NQaf0J95zCxgqGm+fo/nZmZ6oj21uspGWZjhHssFRol0KpzINFDSWE9+/hJ43ybT5G6OHvEiaF83YH6h3CXAa6zz2zV18LKvnO8A4nTYR2/EBmGiP6NE3YqQ7hTE7SFmEDtRaxKJfyBxs0bHDCcFifVZh8GE25VyDwvOihUHztgvIRMh9vkgehzx9YN3sZdAsBJqcWyqi1mEPZU/l+zq2tbO+EczCvz6JQ77RZToQxm0vXzJc/ctcCEoVvjDx1pJhsQiTj5tJirFgcYz4VC7ihFYIq2XUQNISZaLynpYUUPdjvIfXGcvk0500SK9VAKb6603Z3fABdsENDGuxl2UKXMed4sL/PFwLy9siEX3BgMg1hFFiwoqqEp/x75341BoeRavEIJBEv8BdTS66mel1lUa/L3so7LyjGpdgfzOZlv+0t6Uhzy82HwYkAWmvuYpK6s6JItsG1ftYrOBzHZbpu36wn0e4N4NLqBnm6Hx1+tQJY7lTmgokgUy+5sVtp4LEsTbgE64HbDLYhME4m/3Yw5ij5D1OhoNwm/9r6MEYyJOyv8j8nDjudLRe1YQ0D2JLQsr04LYpVrjU1+Tsg780K0j0JdnFfVhe/SdkVU8nbkIIfRkv/86N6U2ZQaCYaScZmKYdBQmsK//I2yuYym0tM5q2d5kesYTxy8uAtVIXL1rE065eZFPlg/7Mgu0sqUsspG+EeDJE=

View File

@ -1,336 +0,0 @@
const { KeyDB, HostListDB, SshRecordDB, NotifyDB, GroupDB, EmailNotifyDB, ScriptsDB, OnekeyDB } = require('./db-class')
const readKey = async () => {
return new Promise((resolve, reject) => {
const keyDB = new KeyDB().getInstance()
keyDB.findOne({}, (err, doc) => {
if (err) {
reject(err)
} else {
resolve(doc)
}
})
})
}
const writeKey = async (keyObj = {}) => {
const keyDB = new KeyDB().getInstance()
return new Promise((resolve, reject) => {
keyDB.update({}, { $set: keyObj }, { upsert: true }, (err, numReplaced) => {
if (err) {
reject(err)
} else {
keyDB.compactDatafile()
resolve(numReplaced)
}
})
})
}
const readSSHRecord = async () => {
const sshRecordDB = new SshRecordDB().getInstance()
return new Promise((resolve, reject) => {
sshRecordDB.find({}, (err, docs) => {
if (err) {
consola.error('读取ssh-record-db错误: ', err)
reject(err)
} else {
resolve(docs)
}
})
})
}
const writeSSHRecord = async (record = []) => {
return new Promise((resolve, reject) => {
const sshRecordDB = new SshRecordDB().getInstance()
sshRecordDB.remove({}, { multi: true }, (err) => {
if (err) {
consola.error('清空SSHRecord出错:', err)
reject(err)
} else {
sshRecordDB.insert(record, (err, newDocs) => {
if (err) {
consola.error('写入新的ssh记录出错:', err)
reject(err)
} else {
sshRecordDB.compactDatafile()
resolve(newDocs)
}
})
}
})
})
}
const readHostList = async () => {
return new Promise((resolve, reject) => {
const hostListDB = new HostListDB().getInstance()
hostListDB.find({}, (err, docs) => {
if (err) {
consola.error('读取host-list-db错误:', err)
reject(err)
} else {
resolve(docs)
}
})
})
}
const writeHostList = async (record = []) => {
return new Promise((resolve, reject) => {
const hostListDB = new HostListDB().getInstance()
hostListDB.remove({}, { multi: true }, (err) => {
if (err) {
consola.error('清空HostList出错:', err)
reject(err)
} else {
// 插入新的数据列表
hostListDB.insert(record, (err, newDocs) => {
if (err) {
consola.error('写入新的HostList出错:', err)
reject(err)
} else {
hostListDB.compactDatafile()
resolve(newDocs)
}
})
}
})
})
}
const readEmailNotifyConf = () => {
return new Promise((resolve, reject) => {
const emailNotifyDB = new EmailNotifyDB().getInstance()
emailNotifyDB.findOne({}, (err, docs) => {
if (err) {
consola.error('读取email-notify-conf-db错误:', err)
reject(err)
} else {
resolve(docs)
}
})
})
}
const writeUserEmailList = (user) => {
const emailNotifyDB = new EmailNotifyDB().getInstance()
// eslint-disable-next-line no-async-promise-executor
return new Promise(async (resolve, reject) => {
let support = await readSupportEmailList()
const emailConf = { support, user }
emailNotifyDB.update({}, { $set: emailConf }, { upsert: true }, (err) => {
if (err) {
reject({ code: -1, msg: err.message || err })
} else {
emailNotifyDB.compactDatafile()
resolve({ code: 0 })
}
})
})
}
const readSupportEmailList = async () => {
let support = []
try {
support = (await readEmailNotifyConf()).support
} catch (error) {
consola.error('读取email support错误: ', error)
}
return support
}
const readUserEmailList = async () => {
let user = []
try {
user = (await readEmailNotifyConf()).user
} catch (error) {
consola.error('读取email config错误: ', error)
}
return user
}
const getNotifySwByType = async (type) => {
if (!type) throw Error('missing params: type')
try {
let notifyList = await readNotifyList()
let { sw } = notifyList.find((item) => item.type === type)
return sw
} catch (error) {
consola.error(`通知类型[${ type }]不存在`)
return false
}
}
const readNotifyList = async () => {
return new Promise((resolve, reject) => {
const notifyDB = new NotifyDB().getInstance()
notifyDB.find({}, (err, docs) => {
if (err) {
consola.error('读取notify list错误: ', err)
reject(err)
} else {
resolve(docs)
}
})
})
}
const writeNotifyList = async (notifyList) => {
return new Promise((resolve, reject) => {
const notifyDB = new NotifyDB().getInstance()
notifyDB.remove({}, { multi: true }, (err) => {
if (err) {
consola.error('清空notify list出错:', err)
reject(err)
} else {
notifyDB.insert(notifyList, (err, newDocs) => {
if (err) {
consola.error('写入新的notify list出错:', err)
reject(err)
} else {
notifyDB.compactDatafile()
resolve(newDocs)
}
})
}
})
})
}
const readGroupList = async () => {
return new Promise((resolve, reject) => {
const groupDB = new GroupDB().getInstance()
groupDB.find({}, (err, docs) => {
if (err) {
consola.error('读取group list错误: ', err)
reject(err)
} else {
resolve(docs)
}
})
})
}
const writeGroupList = async (list = []) => {
return new Promise((resolve, reject) => {
const groupDB = new GroupDB().getInstance()
groupDB.remove({}, { multi: true }, (err) => {
if (err) {
consola.error('清空group list出错:', err)
reject(err)
} else {
groupDB.insert(list, (err, newDocs) => {
if (err) {
consola.error('写入新的group list出错:', err)
reject(err)
} else {
groupDB.compactDatafile()
resolve(newDocs)
}
})
}
})
})
}
const readScriptList = async () => {
return new Promise((resolve, reject) => {
const scriptsDB = new ScriptsDB().getInstance()
scriptsDB.find({}, (err, docs) => {
if (err) {
consola.error('读取scripts list错误: ', err)
reject(err)
} else {
resolve(docs)
}
})
})
}
const writeScriptList = async (list = []) => {
return new Promise((resolve, reject) => {
const scriptsDB = new ScriptsDB().getInstance()
scriptsDB.remove({}, { multi: true }, (err) => {
if (err) {
consola.error('清空scripts list出错:', err)
reject(err)
} else {
scriptsDB.insert(list, (err, newDocs) => {
if (err) {
consola.error('写入新的group list出错:', err)
reject(err)
} else {
scriptsDB.compactDatafile()
resolve(newDocs)
}
})
}
})
})
}
const readOneKeyRecord = async () => {
return new Promise((resolve, reject) => {
const onekeyDB = new OnekeyDB().getInstance()
onekeyDB.find({}, (err, docs) => {
if (err) {
consola.error('读取onekey record错误: ', err)
reject(err)
} else {
resolve(docs)
}
})
})
}
const writeOneKeyRecord = async (records =[]) => {
return new Promise((resolve, reject) => {
const onekeyDB = new OnekeyDB().getInstance()
onekeyDB.insert(records, (err, newDocs) => {
if (err) {
consola.error('写入新的onekey记录出错:', err)
reject(err)
} else {
onekeyDB.compactDatafile()
resolve(newDocs)
}
})
})
}
const deleteOneKeyRecord = async (ids =[]) => {
return new Promise((resolve, reject) => {
const onekeyDB = new OnekeyDB().getInstance()
onekeyDB.remove({ _id: { $in: ids } }, { multi: true }, function (err, numRemoved) {
if (err) {
consola.error('Error deleting onekey record(s):', err)
reject(err)
} else {
onekeyDB.compactDatafile()
resolve(numRemoved)
}
})
})
}
module.exports = {
readSSHRecord,
writeSSHRecord,
readHostList,
writeHostList,
readKey,
writeKey,
readNotifyList,
getNotifySwByType,
writeNotifyList,
readGroupList,
writeGroupList,
readSupportEmailList,
readUserEmailList,
writeUserEmailList,
readScriptList,
writeScriptList,
readOneKeyRecord,
writeOneKeyRecord,
deleteOneKeyRecord
}

View File

@ -1,47 +0,0 @@
// based off of https://github.com/apaszke/tcp-ping
// rewritten with modern es6 syntax & promises
const { io: ClientIO } = require('socket.io-client')
const testConnectAsync = (options) => {
let connectTimes = 0
options = Object.assign({ retryTimes: 3, timeout: 5000, host: 'http://localhost', port: '80' }, options)
const { retryTimes, host, port, timeout } = options
// eslint-disable-next-line
return new Promise(async (resolve, reject) => {
while (connectTimes < retryTimes) {
try {
connectTimes++
await connect({ host, port, timeout })
break
} catch (error) {
// 重连次数达到限制仍未连接成功
if(connectTimes === retryTimes) {
reject({ message: error.message, host, port, connectTimes })
return
}
}
}
resolve({ status: 'connect_success', host, port, connectTimes })
})
}
const connect = (options) => {
const { host, port, timeout } = options
return new Promise((resolve, reject) => {
let io = ClientIO(`${ host }:${ port }`, {
path: '/client/os-info',
forceNew: false,
timeout,
reconnection: false
})
.on('connect', () => {
resolve()
io.disconnect()
})
.on('connect_error', (error) => {
reject(error)
})
})
}
module.exports = testConnectAsync

View File

@ -1,4 +1,7 @@
const { exec } = require('child_process')
const os = require('os')
const net = require('net') const net = require('net')
const iconv = require('iconv-lite')
const axios = require('axios') const axios = require('axios')
const request = axios.create({ timeout: 3000 }) const request = axios.create({ timeout: 3000 })
@ -86,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) { function isLocalIP(ip) {
// Check if IPv4 or IPv6 address // Check if IPv4 or IPv6 address
const isIPv4 = net.isIPv4(ip) const isIPv4 = net.isIPv4(ip)
@ -226,13 +258,70 @@ let shellThrottle = (fn, delay = 1000) => {
return throttled return throttled
} }
const isProd = () => {
const EXEC_ENV = process.env.EXEC_ENV || 'production'
return EXEC_ENV === 'production'
}
let allowedIPs = process.env.ALLOWED_IPS ? process.env.ALLOWED_IPS.split(',') : ''
if (allowedIPs) consola.warn('allowedIPs:', allowedIPs)
const isAllowedIp = (requestIP) => {
if (allowedIPs.length === 0) return true
let flag = allowedIPs.some(item => requestIP.includes(item))
if (!flag) consola.warn('requestIP:', requestIP, '不在允许的IP列表中')
return flag
}
const ping = (ip, timeout = 5000) => {
return new Promise((resolve) => {
setTimeout(() => {
resolve({ success: false, msg: 'ping timeout!' })
}, timeout)
let isWin = os.platform() === 'win32'
const command = isWin ? `ping -n 1 ${ ip }` : `ping -c 1 ${ ip }`
const options = isWin ? { encoding: 'buffer' } : {}
exec(command, options, (error, stdout) => {
if (error) {
resolve({ success: false, msg: 'ping error!' })
return
}
let output
if (isWin) {
output = iconv.decode(stdout, 'cp936')
} else {
output = stdout.toString()
}
// console.log('output:', output)
let match
if (isWin) {
match = output.match(/平均 = (\d+)ms/)
if (!match) {
match = output.match(/Average = (\d+)ms/)
}
} else {
match = output.match(/rtt min\/avg\/max\/mdev = [\d.]+\/([\d.]+)\/[\d.]+\/[\d.]+/)
}
if (match) {
resolve({ success: true, time: parseFloat(match[1]) })
} else {
resolve({ success: false, msg: 'Could not find time in ping output!' })
}
})
})
}
module.exports = { module.exports = {
getNetIPInfo, getNetIPInfo,
getLocalNetIP,
throwError, throwError,
isIP, isIP,
randomStr, randomStr,
getUTCDate, getUTCDate,
formatTimestamp, formatTimestamp,
resolvePath, resolvePath,
shellThrottle shellThrottle,
isProd,
isAllowedIp,
ping
} }

View File

@ -1,7 +1,8 @@
const { AESDecryptSync } = require('./encrypt') const { AESDecryptAsync } = require('./encrypt')
const { readKey } = require('./storage')
const jwt = require('jsonwebtoken') const jwt = require('jsonwebtoken')
const { KeyDB } = require('./db-class')
const keyDB = new KeyDB().getInstance()
const enumLoginCode = { const enumLoginCode = {
SUCCESS: 1, SUCCESS: 1,
@ -9,12 +10,12 @@ const enumLoginCode = {
ERROR_TOKEN: -2 ERROR_TOKEN: -2
} }
// 校验token与登录IP // 校验token
const verifyAuthSync = async (token, clientIp) => { const verifyAuthSync = async (token, clientIp) => {
consola.info('verifyAuthSync IP', clientIp) consola.info('verifyAuthSync IP', clientIp)
try { try {
token = await AESDecryptSync(token) // 先aes解密 token = await AESDecryptAsync(token) // 先aes解密
const { commonKey } = await readKey() const { commonKey } = await keyDB.findOneAsync({})
const { exp } = jwt.verify(token, commonKey) const { exp } = jwt.verify(token, commonKey)
if (Date.now() > (exp * 1000)) return { code: -1, msg: 'token expires' } // 过期 if (Date.now() > (exp * 1000)) return { code: -1, msg: 'token expires' } // 过期
return { code: enumLoginCode.SUCCESS, msg: 'success' } // 验证成功 return { code: enumLoginCode.SUCCESS, msg: 'success' } // 验证成功
@ -23,12 +24,6 @@ const verifyAuthSync = async (token, clientIp) => {
} }
} }
const isProd = () => {
const EXEC_ENV = process.env.EXEC_ENV || 'production'
return EXEC_ENV === 'production'
}
module.exports = { module.exports = {
verifyAuthSync, verifyAuthSync
isProd
} }

View File

@ -1,2 +1,4 @@
const consola = require('consola')
global.consola = consola
require('dotenv').config() require('dotenv').config()
require('./app/main.js') require('./app/main.js')

View File

@ -1,6 +1,6 @@
{ {
"name": "server", "name": "server",
"version": "1.0.0", "version": "3.0.3",
"description": "easynode-server", "description": "easynode-server",
"bin": "./bin/www", "bin": "./bin/www",
"scripts": { "scripts": {
@ -21,13 +21,14 @@
"dependencies": { "dependencies": {
"@koa/cors": "^5.0.0", "@koa/cors": "^5.0.0",
"@seald-io/nedb": "^4.0.4", "@seald-io/nedb": "^4.0.4",
"axios": "^1.7.2", "axios": "^1.7.4",
"consola": "^3.2.3", "consola": "^3.2.3",
"cross-env": "^7.0.3", "cross-env": "^7.0.3",
"crypto-js": "^4.2.0", "crypto-js": "^4.2.0",
"dotenv": "^16.4.5", "dotenv": "^16.4.5",
"fs-extra": "^11.2.0", "fs-extra": "^11.2.0",
"global": "^4.4.0", "global": "^4.4.0",
"iconv-lite": "^0.6.3",
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"koa": "^2.15.3", "koa": "^2.15.3",
"koa-body": "^6.0.1", "koa-body": "^6.0.1",
@ -42,9 +43,12 @@
"node-os-utils": "^1.3.7", "node-os-utils": "^1.3.7",
"node-rsa": "^1.1.1", "node-rsa": "^1.1.1",
"node-schedule": "^2.1.1", "node-schedule": "^2.1.1",
"node-telegram-bot-api": "^0.66.0",
"nodemailer": "^6.9.14", "nodemailer": "^6.9.14",
"qrcode": "^1.5.4",
"socket.io": "^4.7.5", "socket.io": "^4.7.5",
"socket.io-client": "^4.7.5", "socket.io-client": "^4.7.5",
"speakeasy": "^2.0.0",
"ssh2": "^1.15.0", "ssh2": "^1.15.0",
"ssh2-sftp-client": "^10.0.3" "ssh2-sftp-client": "^10.0.3"
}, },

View File

@ -34,8 +34,10 @@ module.exports = {
'vue/singleline-html-element-content-newline': 0, 'vue/singleline-html-element-content-newline': 0,
// js // js
'space-before-blocks': ['error', 'always',],
'space-in-parens': ['error', 'never',],
'keyword-spacing': ['error', { 'before': true, 'after': true },],
'no-async-promise-executor': 0, 'no-async-promise-executor': 0,
'comma-dangle': 0,
'import/no-extraneous-dependencies': 0, 'import/no-extraneous-dependencies': 0,
'no-console': 'off', 'no-console': 'off',
'no-debugger': process.env.NODE_ENV === 'production' ? 'error' : 'off', 'no-debugger': process.env.NODE_ENV === 'production' ? 'error' : 'off',

View File

@ -1,8 +1,11 @@
<html lang="zh"> <html lang="zh">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" href="/favicon.ico" /> <link rel="icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=0,user-scalable=yes"> <meta name="viewport"
content="width=device-width,initial-scale=1.0,minimum-scale=1.0,maximum-scale=1.0,user-scalable=0">
<!-- <meta name="viewport" content="width=device-width, initial-scale=0,user-scalable=yes"> -->
<title>EasyNode</title> <title>EasyNode</title>
<script src="//at.alicdn.com/t/c/font_3309550_x7zmcgwaxf.js"></script> <script src="//at.alicdn.com/t/c/font_3309550_x7zmcgwaxf.js"></script>
</head> </head>
@ -10,6 +13,8 @@
<body> <body>
<div id="app"></div> <div id="app"></div>
<script type="module" src="/src/main.js"></script> <script type="module" src="/src/main.js"></script>
<!-- <script src="//cdn.jsdelivr.net/npm/eruda"></script>
<script>eruda.init();</script> -->
</body> </body>
</html> </html>

View File

@ -1,6 +1,6 @@
{ {
"name": "web", "name": "web",
"version": "2.1.6", "version": "3.0.3",
"description": "easynode-web", "description": "easynode-web",
"private": true, "private": true,
"scripts": { "scripts": {
@ -29,10 +29,11 @@
"@xterm/addon-search": "^0.15.0", "@xterm/addon-search": "^0.15.0",
"@xterm/addon-web-links": "^0.11.0", "@xterm/addon-web-links": "^0.11.0",
"@xterm/xterm": "^5.5.0", "@xterm/xterm": "^5.5.0",
"axios": "^1.7.2", "axios": "^1.7.4",
"codemirror": "^6.0.1", "codemirror": "^6.0.1",
"crypto-js": "^4.2.0", "crypto-js": "^4.2.0",
"csv-parse": "^5.5.6", "csv-parse": "^5.5.6",
"dayjs": "^1.11.13",
"element-plus": "^2.7.6", "element-plus": "^2.7.6",
"jsencrypt": "^3.3.2", "jsencrypt": "^3.3.2",
"pinia": "^2.1.7", "pinia": "^2.1.7",
@ -45,12 +46,13 @@
"devDependencies": { "devDependencies": {
"@vitejs/plugin-vue": "^5.0.5", "@vitejs/plugin-vue": "^5.0.5",
"@vitejs/plugin-vue-jsx": "^4.0.0", "@vitejs/plugin-vue-jsx": "^4.0.0",
"code-inspector-plugin": "^0.17.2",
"eslint": "^8.56.0", "eslint": "^8.56.0",
"eslint-plugin-vue": "^9.27.0", "eslint-plugin-vue": "^9.27.0",
"sass": "^1.77.7", "sass": "^1.77.7",
"unplugin-auto-import": "^0.17.6", "unplugin-auto-import": "^0.17.6",
"unplugin-vue-components": "^0.27.2", "unplugin-vue-components": "^0.27.2",
"vite": "^5.3.3", "vite": "^5.3.6",
"vite-plugin-compression": "^0.5.1", "vite-plugin-compression": "^0.5.1",
"vue-eslint-parser": "^9.4.3" "vue-eslint-parser": "^9.4.3"
} }

View File

@ -19,11 +19,17 @@ export default {
removeSSH(id) { removeSSH(id) {
return axios({ url: `/remove-ssh/${ id }`, method: 'delete' }) return axios({ url: `/remove-ssh/${ id }`, method: 'delete' })
}, },
// existSSH(host) { getPlusInfo() {
// return axios({ url: '/exist-ssh', method: 'post', data: { host } }) return axios({ url: '/plus-info', method: 'get' })
// }, },
getCommand(host) { getPlusDiscount() {
return axios({ url: '/command', method: 'get', params: { host } }) return axios({ url: '/plus-discount', method: 'get' })
},
getCommand(hostId) {
return axios({ url: '/command', method: 'get', params: { hostId } })
},
decryptPrivateKey(data) {
return axios({ url: '/decrypt-private-key', method: 'post', data })
}, },
getHostList() { getHostList() {
return axios({ url: '/host-list', method: 'get' }) return axios({ url: '/host-list', method: 'get' })
@ -34,6 +40,9 @@ export default {
updateHost(data) { updateHost(data) {
return axios({ url: '/host-save', method: 'put', data }) return axios({ url: '/host-save', method: 'put', data })
}, },
batchUpdateHost(data) {
return axios({ url: '/batch-update-host', method: 'put', data })
},
removeHost(data) { removeHost(data) {
return axios({ url: '/host-remove', method: 'post', data }) return axios({ url: '/host-remove', method: 'post', data })
}, },
@ -47,28 +56,28 @@ export default {
return axios({ url: '/login', method: 'post', data }) return axios({ url: '/login', method: 'post', data })
}, },
getLoginRecord() { getLoginRecord() {
return axios({ url: '/get-login-record', method: 'get' }) return axios({ url: '/log', method: 'get' })
}, },
updatePwd(data) { updatePwd(data) {
return axios({ url: '/pwd', method: 'put', data }) return axios({ url: '/pwd', method: 'put', data })
}, },
// updateHostSort(data) { getMFA2QR() {
// return axios({ url: '/host-sort', method: 'put', data }) return axios({ url: '/mfa2-code', method: 'post' })
// },
getUserEmailList() {
return axios({ url: '/user-email', method: 'get' })
}, },
getSupportEmailList() { getMFA2Status() {
return axios({ url: '/support-email', method: 'get' }) return axios({ url: '/mfa2-status', method: 'get' })
}, },
updateUserEmailList(data) { enableMFA2(data) {
return axios({ url: '/user-email', method: 'post', data }) return axios({ url: '/mfa2-enable', method: 'post', data })
}, },
deleteUserEmail(email) { disableMFA2() {
return axios({ url: `/user-email/${ email }`, method: 'delete' }) return axios({ url: '/mfa2-disable', method: 'post' })
}, },
pushTestEmail(data) { getNotifyConfig() {
return axios({ url: '/push-email', method: 'post', data }) return axios({ url: '/notify-config', method: 'get' })
},
updateNotifyConfig(data) {
return axios({ url: '/notify-config', method: 'put', data })
}, },
getNotifyList() { getNotifyList() {
return axios({ url: '/notify', method: 'get' }) return axios({ url: '/notify', method: 'get' })
@ -88,8 +97,11 @@ export default {
deleteGroup(id) { deleteGroup(id) {
return axios({ url: `/group/${ id }`, method: 'delete' }) return axios({ url: `/group/${ id }`, method: 'delete' })
}, },
getScriptList() { getScriptList(params = {}) {
return axios({ url: '/script', method: 'get' }) return axios({ url: '/script', method: 'get', params })
},
importScript(data) {
return axios({ url: '/import-script', method: 'post', data })
}, },
getLocalScriptList() { getLocalScriptList() {
return axios({ url: '/local-script', method: 'get' }) return axios({ url: '/local-script', method: 'get' })
@ -103,6 +115,9 @@ export default {
deleteScript(id) { deleteScript(id) {
return axios({ url: `/script/${ id }`, method: 'delete' }) return axios({ url: `/script/${ id }`, method: 'delete' })
}, },
batchRemoveScript(data) {
return axios({ url: '/batch-remove-script', method: 'post', data })
},
getOnekeyRecord() { getOnekeyRecord() {
return axios({ url: '/onekey', method: 'get' }) return axios({ url: '/onekey', method: 'get' })
}, },
@ -111,5 +126,11 @@ export default {
}, },
getEasynodeVersion() { getEasynodeVersion() {
return axios({ url: '/version', method: 'get' }) return axios({ url: '/version', method: 'get' })
},
getPlusConf() {
return axios({ url: '/plus-conf', method: 'get' })
},
updatePlusKey(data) {
return axios({ url: '/plus-conf', method: 'post', data })
} }
} }

View File

@ -5,10 +5,12 @@
</template> </template>
<script setup> <script setup>
import { ref } from 'vue'
import zhCn from 'element-plus/es/locale/lang/zh-cn' import zhCn from 'element-plus/es/locale/lang/zh-cn'
import { ref, getCurrentInstance } from 'vue'
const { proxy: { $store } } = getCurrentInstance()
const locale = ref(zhCn) const locale = ref(zhCn)
$store.setDefaultTheme()
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>

BIN
web/src/assets/discount.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

BIN
web/src/assets/plus.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.9 KiB

View File

@ -1,18 +0,0 @@
// vue transition 动画
.list-move, /* apply transition to moving elements */
.list-enter-active,
.list-leave-active {
transition: all 0.5s ease;
}
.list-enter-from,
.list-leave-to {
opacity: 0;
transform: translateY(-30px);
}
/* ensure leaving items are taken out of layout flow so that moving
animations can be calculated correctly. */
.list-leave-active {
position: absolute;
}

View File

@ -1,22 +0,0 @@
// element css bug
.el-notification__content {
text-align: initial;
}
.el-date-editor {
--el-date-editor-width: 100%;
}
.el-input__wrapper {
width: 100%;
}
.el-tabs__nav-scroll .el-tabs__nav {
padding-left: 0;
}
.el-tabs__content {
padding: 0 10px;
}
// :root {
// --active-color: red;
// }

View File

@ -0,0 +1,88 @@
// $--colors: (
// "primary": (
// "base": #589ef8,
// ),
// );
// @forward "element-plus/theme-chalk/src/dark/var.scss" with (
// $colors: $--colors
// );
/** element内置黑暗主题 */
@use 'element-plus/theme-chalk/src/dark/css-vars.scss' as *;
/** 自定义黑暗主题 */
html.dark {
// * admin
--bg-color: #000;
--main-bg-color: #181818;
--v-border-light: 1px solid #4c4c4d;
body {
background-color: var(--bg-color) !important;
}
.login_container {
// background: rgba(171, 181, 196, 0.3);
.login_box {
background-color: var(--main-bg-color) !important;
}
}
.top_bar_container {
background-color: var(--main-bg-color) !important;
}
.router_box {
background-color: var(--main-bg-color) !important;
}
.aside_container {
background-color: var(--main-bg-color) !important;
}
.terminal_top {
border-bottom: 1px solid #454242;
}
.sftp_tab_container {
background-color: var(--main-bg-color) !important;
.dir-list {
.active {
background-color: #454242 !important;
}
li:hover {
background-color: #454242 !important;
}
}
section {
.left {
border: none !important;
}
}
}
.info_box {
border-right: 1px solid #454242;
.el-progress-bar__innerText {
span {
color: #fff !important;
}
}
}
// scroll-bar
::-webkit-scrollbar-thumb {
background-color: #5c5c5c;
border-radius: 10px;
transition: all .2s ease-in-out;
}
::-webkit-scrollbar-thumb:hover {
cursor: pointer;
background-color: #6d6d6d;
}
.el-menu {
border-right: none;
}
.el-menu-item:not(.is-active):hover {
color: var(--el-menu-active-color);
}
}

View File

@ -0,0 +1,37 @@
// $--colors: (
// "primary": (
// "base": green,
// ),
// "success": (
// "base": #21ba45,
// ),
// "warning": (
// "base": #f2711c,
// ),
// "danger": (
// "base": #db2828,
// ),
// "error": (
// "base": #db2828,
// ),
// "info": (
// "base": #42b8dd,
// ),
// );
// You should use them in scss, because we calculate it by sass.
// comment next lines to use default color
// @forward "element-plus/theme-chalk/src/common/var.scss" with (
// // do not use same name, it will override.
// // $colors: $--colors,
// // $button-padding-horizontal: ("default": 50px)
// );
// if you want to import all
// @use "element-plus/theme-chalk/src/index.scss" as *;
// You can comment it to hide debug info.
// @debug $--colors;
// custom dark variables
@use "./dark.scss";

View File

@ -1,39 +1,52 @@
// 滚动条 html {
html, body, div, ul, section, textarea { font-size: 15px;
}
html,
body,
div,
ul,
section,
textarea {
box-sizing: border-box; box-sizing: border-box;
// 滚动条整体部分 // 滚动条整体部分
&::-webkit-scrollbar { &::-webkit-scrollbar {
height: 8px; width: 6px;
width: 2px; height: 6px;
background-color: #ffffff;
} }
// 底层轨道 // 底层轨道
&::-webkit-scrollbar-track { // &::-webkit-scrollbar-track {
background-color: #ffffff; // background-color: #ffffff;
border-radius: 10px; // border-radius: 3px;
} // }
// 滚动滑块 // 滚动滑块
&::-webkit-scrollbar-thumb { &::-webkit-scrollbar-thumb {
background-color: #0003;
border-radius: 10px; border-radius: 10px;
// background-color: #1989fa; transition: all .2s ease-in-out;
background-image: -webkit-gradient(linear, 40% 0%, 75% 84%, from(#a18cd1), to(#fbc2eb), color-stop(.6, #54DE5D));
} }
&::-webkit-scrollbar-thumb:hover { &::-webkit-scrollbar-thumb:hover {
background-color: #067ef7; cursor: pointer;
background-color: #0000004d;
}
&::-webkit-scrollbar-track {
border-radius: 10px;
} }
} }
::view-transition-new(root),
::view-transition-old(root) {
/* 关闭默认动画,否则影响自定义动画的执行 */
animation: none;
}
// 全局背景 // 全局背景
body { body {
// background-position: center center;
// background-attachment: fixed;
// background-size: cover;
// background-repeat: no-repeat;
// // background-image: url(../bg.jpg), linear-gradient(to bottom, #010179, #F5C4C1, #151799);
background-color: #E7EBF4; background-color: #E7EBF4;
background-image: url(https://gw.alipayobjects.com/zos/rmsportal/TVYTbAXWheQpRcWDaDMu.svg); background-image: url(https://gw.alipayobjects.com/zos/rmsportal/TVYTbAXWheQpRcWDaDMu.svg);
background-repeat: no-repeat; background-repeat: no-repeat;

View File

@ -0,0 +1,87 @@
.mobile_menu_btn {
margin-right: auto;
font-size: 18px;
}
.mobile_menu_drawer {
width: auto !important;
.mobile_logo_wrap {
display: flex;
align-items: center;
justify-content: center;
margin-top: 15px;
img {
width: 30px;
}
h1 {
font-size: 14px;
margin-left: 3px;
}
}
.el-drawer__body {
padding: 0;
}
}
@media screen and (min-width: 969px) {
[class^="mobile_"] {
display: none;
}
}
@media screen and (max-width: 968px) {
.login_box {
width: 86vw!important;
}
.view_container {
.aside_container {
display: none;
}
.top_bar_container {
width: 100%;
.bar_wrap {
h2 {
display: none;
}
}
}
.terminal_container {
.terminal_link_tips {
width: 100%;
}
.terminal_wrap {
.terminal_and_sftp_wrap {
flex: auto;
.sftp_tab_container {
section {
.left {
min-width: 150px;
max-width: 150px;
}
.right {
.filter_input {
width: auto;
min-width: auto;
margin: 0 5px;
}
.path {
display: inline-block;
padding-right: 15px;
}
.path_input {
width: auto;
min-width: auto;
}
}
}
}
}
}
}
}
.el-dialog {
--el-dialog-width: 94%!important;
}
}

View File

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

View File

Before

Width:  |  Height:  |  Size: 164 KiB

After

Width:  |  Height:  |  Size: 164 KiB

View File

Before

Width:  |  Height:  |  Size: 501 KiB

After

Width:  |  Height:  |  Size: 501 KiB

View File

Before

Width:  |  Height:  |  Size: 1.3 MiB

After

Width:  |  Height:  |  Size: 1.3 MiB

View File

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 39 KiB

View File

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 11 KiB

View File

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 38 KiB

View File

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 58 KiB

View File

Before

Width:  |  Height:  |  Size: 177 KiB

After

Width:  |  Height:  |  Size: 177 KiB

BIN
web/src/assets/wx.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

View File

@ -2,107 +2,39 @@
<div class="aside_container"> <div class="aside_container">
<div class="logo_wrap"> <div class="logo_wrap">
<img src="@/assets/logo.png" alt="logo"> <img src="@/assets/logo.png" alt="logo">
<h1>EasyNode</h1> <Transition name="el-fade-in-linear">
<h1 v-show="!menuCollapse">EasyNode</h1>
</Transition>
</div>
<MenuList />
<div class="collapse" @click="handleCollapse">
<el-icon v-if="menuCollapse"><Expand /></el-icon>
<el-icon v-else><Fold /></el-icon>
</div> </div>
<el-menu
:default-active="defaultActiveMenu"
class="menu"
@select="handleSelect"
>
<el-menu-item v-for="(item, index) in menuList" :key="index" :index="item.index">
<template #title>
<el-icon>
<component :is="item.icon" />
</el-icon>
<span>{{ item.name }}</span>
</template>
</el-menu-item>
</el-menu>
<!-- <div class="logout_wrap">
<el-button type="info" link @click="handleLogout">退出登录</el-button>
</div> -->
</div> </div>
</template> </template>
<script setup> <script setup>
import { reactive, markRaw, getCurrentInstance, computed, watchEffect } from 'vue' import { getCurrentInstance, computed } from 'vue'
import { import {
Menu as IconMenu, Expand,
Key, Fold
Setting,
ScaleToOriginal,
ArrowRight,
Pointer,
FolderOpened
} from '@element-plus/icons-vue' } from '@element-plus/icons-vue'
import { useRoute } from 'vue-router' import MenuList from './menuList.vue'
const route = useRoute() const { proxy: { $store } } = getCurrentInstance()
const { proxy: { $router, $store } } = getCurrentInstance() let menuCollapse = computed(() => $store.menuCollapse)
let menuList = reactive([ const handleCollapse = () => {
{ $store.setMenuCollapse(!menuCollapse.value)
name: '实例配置',
icon: markRaw(IconMenu),
index: '/server'
},
{
name: '连接终端',
icon: markRaw(ScaleToOriginal),
index: '/terminal'
},
{
name: '凭据管理',
icon: markRaw(Key),
index: '/credentials'
},
{
name: '分组管理',
icon: markRaw(FolderOpened),
index: '/group'
},
{
name: '脚本库',
icon: markRaw(ArrowRight),
index: '/scripts'
},
{
name: '批量指令',
icon: markRaw(Pointer),
index: '/onekey'
},
{
name: '系统设置',
icon: markRaw(Setting),
index: '/setting'
},
])
// eslint-disable-next-line no-useless-escape
const regex = /^\/([^\/]+)/
let defaultActiveMenu = computed(() => {
const match = route.path.match(regex)
return match[0]
})
watchEffect(() => {
let idx = route.path.match(regex)[0]
let targetRoute = menuList.find(item => item.index === idx)
$store.setTitle(targetRoute?.name || '')
})
const handleSelect = (path) => {
// console.log(path)
$router.push(path)
} }
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.aside_container { .aside_container {
background-color: #fff; background-color: #fff;
border-right: 1px solid var(--el-menu-border-color); // width: 180px;
width: 180px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
:deep(.el-menu) { :deep(.el-menu) {
@ -110,20 +42,29 @@ const handleSelect = (path) => {
} }
.logo_wrap { .logo_wrap {
display: flex; display: flex;
justify-content: center;
align-items: center; align-items: center;
padding: 15px 0; padding: 15px 0 15px 20px;
position: relative;
img { img {
height: 30px; height: 30px;
width: 30px; width: 30px;
} }
h1 { h1 {
color: #1890ff; position: absolute;
font-size: 16px; left: 52px;
margin: 0 5px; font-size: 14px;
color: var(--el-menu-active-color);
font-weight: 600; font-weight: 600;
font-size: 16px; }
vertical-align: middle; }
.collapse {
margin-top: auto;
margin-left: auto;
margin-bottom: 10px;
margin-right: 10px;
cursor: pointer;
&:hover {
color: #1890ff;
} }
} }
.logout_wrap { .logout_wrap {

View File

@ -0,0 +1,44 @@
<template>
<el-tooltip
:disabled="isPlusActive"
placement="top"
>
<template #content>
<div class="plus_support_tip">
此功能需要激活Plus后使用,
<el-button
size="small"
type="primary"
link
@click="gotoPlusPage"
>
去激活
</el-button>
</div>
</template>
<slot />
</el-tooltip>
</template>
<script setup>
import { computed, getCurrentInstance } from 'vue'
import { useRouter } from 'vue-router'
const { proxy: { $store } } = getCurrentInstance()
const router = useRouter()
const isPlusActive = computed(() => $store.isPlusActive)
const gotoPlusPage = () => {
router.push('/setting?tabKey=plus')
}
</script>
<style lang="scss">
.plus_support_tip {
display: flex;
align-items: center;
}
</style>

View File

@ -0,0 +1,113 @@
<template>
<div class="mobile_float_menu_container">
<ul class="keyboard">
<li
v-for="item in keys"
:key="item.key"
:class="['key', { long_press: item.type === LONG_PRESS }]"
@click="handleClickKey(item)"
>
<div :class="{ active: (item.key === 'Ctrl' && longPressCtrl) || (item.key === 'Alt' && longPressAlt) }">
{{ item.key }}
</div>
</li>
<li class="key placeholder" />
<li class="key placeholder" />
<li class="key placeholder" />
</ul>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { virtualKeyType } from '@/utils/enum'
defineProps({
longPressCtrl: {
type: Boolean,
default: false
},
longPressAlt: {
type: Boolean,
default: false
}
})
const emit = defineEmits(['update:show', 'click-key',])
const { LONG_PRESS, SINGLE_PRESS } = virtualKeyType
const keys = ref([
{ key: 'Ctrl', ascii: null, type: LONG_PRESS, ansi: '' },
{ key: 'Esc', ascii: 27, type: SINGLE_PRESS, ansi: '\x1B' },
{ key: 'Tab', ascii: 9, type: SINGLE_PRESS, ansi: '\x09' },
{ key: 'Backspace', ascii: 8, type: SINGLE_PRESS, ansi: '\x7F' },
// { key: 'Delete', ascii: 46, type: SINGLE_PRESS, ansi: '\x1B[3~' },
{ key: '←', ascii: 37, type: SINGLE_PRESS, ansi: '\x1B[D' },
{ key: '↑', ascii: 38, type: SINGLE_PRESS, ansi: '\x1B[A' },
{ key: '↓', ascii: 40, type: SINGLE_PRESS, ansi: '\x1B[B' },
{ key: '→', ascii: 39, type: SINGLE_PRESS, ansi: '\x1B[C' },
{ key: 'Home', ascii: 36, type: SINGLE_PRESS, ansi: '\x1B[H' },
{ key: 'End', ascii: 35, type: SINGLE_PRESS, ansi: '\x1B[F' },
{ key: 'PageUp', ascii: 33, type: SINGLE_PRESS, ansi: '\x1B[5~' },
{ key: 'PageDown', ascii: 34, type: SINGLE_PRESS, ansi: '\x1B[6~' },
{ key: 'Alt', ascii: null, type: LONG_PRESS, ansi: '' },
{ key: 'F1', ascii: 112, type: SINGLE_PRESS, ansi: '\x1BOP' },
{ key: 'F2', ascii: 113, type: SINGLE_PRESS, ansi: '\x1BOQ' },
{ key: 'F3', ascii: 114, type: SINGLE_PRESS, ansi: '\x1BOR' },
{ key: 'F4', ascii: 115, type: SINGLE_PRESS, ansi: '\x1BOS' },
{ key: 'F5', ascii: 116, type: SINGLE_PRESS, ansi: '\x1B[15~' },
{ key: 'F6', ascii: 117, type: SINGLE_PRESS, ansi: '\x1B[17~' },
{ key: 'F7', ascii: 118, type: SINGLE_PRESS, ansi: '\x1B[18~' },
{ key: 'F8', ascii: 119, type: SINGLE_PRESS, ansi: '\x1B[19~' },
{ key: 'F9', ascii: 120, type: SINGLE_PRESS, ansi: '\x1B[20~' },
{ key: 'F10', ascii: 121, type: SINGLE_PRESS, ansi: '\x1B[21~' },
{ key: 'F11', ascii: 122, type: SINGLE_PRESS, ansi: '\x1B[23~' },
{ key: 'F12', ascii: 123, type: SINGLE_PRESS, ansi: '\x1B[24~' },
])
const handleClickKey = (key) => {
emit('click-key', key)
}
</script>
<style scoped lang="scss">
.mobile_float_menu_container {
height: 55px;
padding-top: 5px;
overflow-y: auto;
&::-webkit-scrollbar {
width: 0px;
height: 0px;
}
.keyboard {
list-style: none;
display: flex;
flex-wrap: wrap;
justify-content: space-around;
padding: 0;
.key.placeholder {
opacity: 0;
}
.key {
width: 25%;
height: 25px;
display: flex;
align-items: center;
justify-content: center;
font-size: 10px;
box-sizing: border-box;
padding: 0 8px;
// margin-bottom: 6px;
// border: 1px solid #cccccc5b;
// border-radius: 2px;
}
.long_press {
.active {
// color: red;
font-weight: bolder;
text-decoration: underline;
}
}
}
}</style>

View File

@ -23,7 +23,7 @@
<template #footer> <template #footer>
<footer> <footer>
<div class="btns"> <div class="btns">
<el-button type="primary" @click="handleSave">执行</el-button> <el-button type="primary" @click="handleSave">发送到终端</el-button>
<el-button type="info" @click="visible = false">关闭</el-button> <el-button type="info" @click="visible = false">关闭</el-button>
</div> </div>
</footer> </footer>

View File

@ -0,0 +1,96 @@
<template>
<el-menu
:default-active="defaultActiveMenu"
:collapse="menuCollapse"
class="menu"
:collapse-transition="true"
@select="handleSelect"
>
<el-menu-item v-for="(item, index) in list" :key="index" :index="item.index">
<el-icon>
<component :is="item.icon" />
</el-icon>
<template #title>
<span>{{ item.name }}</span>
</template>
</el-menu-item>
</el-menu>
</template>
<script setup>
import { reactive, markRaw, getCurrentInstance, computed, watchEffect } from 'vue'
import { useRoute } from 'vue-router'
import {
Menu as IconMenu,
Key,
Setting,
ScaleToOriginal,
ArrowRight,
Pointer,
FolderOpened
} from '@element-plus/icons-vue'
const { proxy: { $router, $store } } = getCurrentInstance()
const emit = defineEmits(['select',])
const route = useRoute()
const list = reactive([
{
name: '实例配置',
icon: markRaw(IconMenu),
index: '/server'
},
{
name: '连接终端',
icon: markRaw(ScaleToOriginal),
index: '/terminal'
},
{
name: '凭据管理',
icon: markRaw(Key),
index: '/credentials'
},
{
name: '分组管理',
icon: markRaw(FolderOpened),
index: '/group'
},
{
name: '脚本库',
icon: markRaw(ArrowRight),
index: '/scripts'
},
{
name: '批量指令',
icon: markRaw(Pointer),
index: '/onekey'
},
{
name: '系统设置',
icon: markRaw(Setting),
index: '/setting'
},
])
const menuCollapse = computed(() => $store.menuCollapse)
// eslint-disable-next-line no-useless-escape
const regex = /^\/([^\/]+)/
const defaultActiveMenu = computed(() => {
const match = route.path.match(regex)
return match[0]
})
watchEffect(() => {
const idx = route.path.match(regex)[0]
const targetRoute = list.find(item => item.index === idx)
$store.setTitle(targetRoute?.name || '')
})
const handleSelect = (path) => {
// console.log(path)
$router.push(path)
emit('select', path)
}
</script>

View File

@ -0,0 +1,206 @@
<template>
<div class="comparison-container">
<!-- 基础版卡片 -->
<el-card class="comparison-card basic-card">
<template #header>
<div class="card-header">
<span class="title">基础功能(免费)</span>
<el-tag size="small">Basic</el-tag>
</div>
</template>
<div class="feature-list">
<div v-for="(feature, index) in basicFeatures" :key="index" class="feature-item">
<el-icon>
<Check />
</el-icon>
<span>{{ feature }}</span>
</div>
</div>
</el-card>
<!-- Plus版卡片 -->
<el-card class="comparison-card plus-card">
<template #header>
<div class="card-header">
<div>
<span class="title">Plus专属功能</span>
<span class="link" style="margin-right: 15px;" @click="() => plusTipsShow = true">Plus说明</span>
</div>
<el-tag type="success" size="small">PLUS</el-tag>
</div>
</template>
<div class="feature-list">
<div v-for="(feature, index) in plusFeatures" :key="index" class="feature-item plus">
<el-icon color="#67c23a">
<Check />
</el-icon>
<span>{{ feature }}</span>
</div>
</div>
</el-card>
<el-dialog
v-model="plusTipsShow"
title="Plus说明"
top="20vh"
width="30%"
:append-to-body="false"
:close-on-click-modal="false"
>
<div class="about_content">
<p style="line-height: 2;letter-spacing: 1px;">
<!-- <strong style="color: #F56C6C;font-weight: 600;">PLUS说明:</strong><br> -->
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<strong>EasyNode</strong>最初是一个简单的Web终端工具随着用户群的不断扩大功能需求也日益增长为了实现大家的功能需求我投入了大量的业余时间进行开发和维护
一直在为爱发电渐渐的也没了开发的动力
<br>
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;为了项目的可持续发展<strong>后续</strong>版本开始推出<strong>PLUS</strong>版本后续特性功能开发也会优先在<strong>PLUS</strong>版本中实现但即使不升级到<strong>PLUS</strong>也不会影响到<strong>EasyNode</strong>的基础功能使用注意:
暂不支持纯内网用户激活PLUS功能
<br>
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<span style="text-decoration: underline;">
为了感谢前期赞赏过的用户, <strong>PLUS</strong>功能正式发布前所有进行过赞赏的用户无论金额大小均可联系作者TG: <a
class="link"
href="https://t.me/chaoszhu"
target="_blank"
>@chaoszhu</a> 凭打赏记录免费获取永久<strong>PLUS</strong>授权码
</span>
</p>
<div class="about_footer">
<el-button type="info" @click="plusTipsShow = false">关闭</el-button>
<el-button type="primary" @click="handlePlusSupport">购买Plus Key</el-button>
</div>
</div>
</el-dialog>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { Check } from '@element-plus/icons-vue'
import { handlePlusSupport } from '@/utils'
const plusTipsShow = ref(false)
//
const basicFeatures = [
'服务器管理',
'服务器导入导出',
'服务器分组',
'凭据管理',
'脚本库',
'批量连接',
'批量指令',
'通知方式(有限制)',
]
// Plus
const plusFeatures = [
'包含基础版全部功能',
'服务器跳板机功能,支持任意数量服务器的连续跳板',
'批量修改实例配置(优化版)',
'脚本库批量导出导入',
'凭据管理支持解密带密码保护的密钥',
'通知方式无限制',
'本地socket断开自动重连',
'功能需求更高开发优先级',
]
</script>
<style scoped lang="scss">
.comparison-container {
display: flex;
gap: 20px;
padding-bottom: 20px;
max-width: 1200px;
}
.comparison-card {
flex: 1;
min-width: 300px;
}
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.title {
font-size: 18px;
font-weight: bold;
margin-right: 10px;
}
.link {
text-decoration: underline;
}
.feature-list {
// padding: 10px 0;
line-height: 1.3;
}
.feature-item {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 0;
/* border-bottom: 1px solid #eee; */
border-bottom: 1px solid var(--el-border-color);
}
.feature-item:last-child {
border-bottom: none;
}
.feature-item .el-icon {
color: #409EFF;
}
.plus-card .feature-item.plus {
color: #67c23a;
}
.about_content {
h1 {
font-size: 24px;
font-weight: 600;
margin: 20px 0;
}
p {
line-height: 1.8;
margin: 12px 0;
font-size: 14px;
}
.link {
color: #409EFF;
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
.conspicuous {
color: #F56C6C;
font-weight: 500;
}
.about_footer {
display: flex;
justify-content: center;
margin-top: 20px;
}
}
/* 响应式设计 */
@media (max-width: 768px) {
.comparison-container {
flex-direction: column;
}
.comparison-card {
width: 100%;
}
}
</style>

Some files were not shown because too many files have changed in this diff Show More