Compare commits

...

308 Commits
v1.1 ... 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
chaos-zhu
0c328e96e3 客户端支持交换内存状态回传 2024-08-14 10:52:07 +08:00
chaos-zhu
0e252abc4f 📝 更新版本说明 2024-08-13 16:17:06 +08:00
chaos-zhu
5a6e5c54d1 📝 更新版本说明 2024-08-13 15:53:14 +08:00
chaos-zhu
9889528070 SFTP支持上传嵌套文件夹 2024-08-13 15:51:14 +08:00
chaos-zhu
afda15de68 SFTP支持上传文件夹 2024-08-13 15:10:41 +08:00
chaos-zhu
b8da64f8dd 🆕 调整实例状态展示 2024-08-13 12:04:37 +08:00
chaos-zhu
2869d4f212 支持终端主题与背景图设置 2024-08-12 17:30:19 +08:00
chaos-zhu
a9e9e13cd7 📝 更新版本说明 2024-08-12 17:28:12 +08:00
chaos-zhu
cff5f69518 支持终端主题与背景图设置 2024-08-12 17:22:46 +08:00
chaos-zhu
6ef28e7883 📝 更新版本说明 2024-08-12 17:12:47 +08:00
chaos-zhu
5c9d0fb422 📝 更新版本说明 2024-08-12 17:12:20 +08:00
chaos-zhu
268fa61b04 支持终端主题与背景图设置 2024-08-12 17:10:30 +08:00
chaos-zhu
4434a6d350 📝 更新版本说明 2024-08-12 12:35:07 +08:00
chaos-zhu
2977e61ed5 支持cd全路径指令联动SFTP面板 2024-08-12 12:34:41 +08:00
chaos-zhu
324463d649 📝 更新版本说明 2024-08-11 23:29:10 +08:00
chaos-zhu
25595007bd 📝 更新版本 2024-08-11 23:26:38 +08:00
chaos-zhu
52ad7e3d25 🐛 修复打开或关闭sftp终端光标位置错误 2024-08-11 23:23:46 +08:00
chaos-zhu
edc3eb5718 📝 更新版本文档 2024-08-10 10:01:59 +08:00
chaos-zhu
b173e0aeab 📝 更新版本文档 2024-08-09 12:47:57 +08:00
chaos-zhu
b0d8e2dbde 🆕 版本检测更新 2024-08-09 12:30:50 +08:00
chaos-zhu
def84a6cae 🆕 版本检测更新 2024-08-09 12:29:27 +08:00
chaos-zhu
b1532ca9da 🐛 修复终端配置自动连接 2024-08-09 12:17:59 +08:00
chaos-zhu
69dd8dcdeb 📝 更新版本文档 2024-08-09 12:05:44 +08:00
chaos-zhu
d83795d7af 新增导出功能&服务器列表排序缓存 2024-08-09 11:44:28 +08:00
chaos-zhu
ba67533e9a 🆕 update 2024-08-08 22:59:50 +08:00
chaos-zhu
bda9f89bc7 优化服务监控连接 2024-08-05 18:50:44 +08:00
chaos-zhu
07494d15f6 📝 更新部署文档 2024-08-05 17:36:46 +08:00
chaos-zhu
d7f0e47e80 🐛 修复终端tab索引 2024-08-05 16:01:39 +08:00
chaos-zhu
856db2cf15 🐛 修复终端tab索引 2024-08-05 16:00:40 +08:00
chaos-zhu
7abb325c19 📝 版本描述更新 2024-08-05 10:34:23 +08:00
chaos-zhu
736029d402 🚨 fix lint 2024-08-05 10:32:39 +08:00
chaos-zhu
d175aeb253 👷 支持action自动化构建镜像 2024-08-05 09:53:12 +08:00
chaos-zhu
a345cc3e44 👷 移除arm/v7&新增系统底层依赖包 2024-08-05 09:41:53 +08:00
chaos-zhu
fd66cba9da 👷 移除arm/v7&新增系统底层依赖包 2024-08-05 09:39:55 +08:00
chaos-zhu
ff59ff97bc 👷 移除arm/v7&新增系统底层依赖包 2024-08-05 09:37:19 +08:00
chaos-zhu
68b0384485 👷 action支持arm架构 2024-08-05 09:17:32 +08:00
chaos-zhu
6cb941a7ec 👷 fix warning 2024-08-05 09:10:41 +08:00
chaos-zhu
f93949d9ba 👷 修复手动构建镜像yml 2024-08-05 09:06:50 +08:00
chaos-zhu
640413123f 👷 支持action自动化构建镜像 2024-08-05 09:02:55 +08:00
chaos-zhu
9d5ab456e8 👷 支持action自动化构建镜像 2024-08-05 08:54:11 +08:00
chaos-zhu
f5f46aeda3 支持版本更新检测 2024-08-04 23:10:00 +08:00
chaos-zhu
16f86e8024 实例控制台直达 2024-08-04 21:33:43 +08:00
chaos-zhu
ebe1057684 支持批量下发指令安装客户端 2024-08-04 21:27:49 +08:00
chaos-zhu
1cb0313cbd 支持批量连接终端 2024-08-04 05:41:28 +08:00
chaos-zhu
be0ee9e86e 支持批量连接终端 2024-08-04 05:30:01 +08:00
chaos-zhu
5f8c3aabe7 优化实例选择逻辑 2024-08-04 05:24:33 +08:00
chaos-zhu
e33cffbfe7 优化实例选择逻辑 2024-08-04 04:59:22 +08:00
chaos-zhu
54f54c1f55 优化监控服务,无感更新状态 2024-08-04 04:17:16 +08:00
chaos-zhu
09e107b8d9 支持批量修改与删除&调整实例面板UI 2024-08-04 02:46:55 +08:00
chaos-zhu
0eb83ad48b 📝 更新文档 2024-08-02 16:30:49 +08:00
chaos-zhu
71958699ea 🆕 内置常用脚本 2024-08-02 16:00:19 +08:00
chaos-zhu
44d9557f17 📝 更新docker部署说明 2024-08-02 12:52:46 +08:00
chaos-zhu
f926ca7acd 📝 更新v2.1.0文档 2024-08-02 12:42:47 +08:00
chaos-zhu
9e3e6dc806 🐳 优化面板镜像构建 2024-08-02 12:36:33 +08:00
chaos-zhu
7513825d28 支持服务器批量指令下发 2024-08-02 11:28:38 +08:00
chaos-zhu
22c4e2cd46 支持服务器批量后台命令同步 2024-08-01 23:14:47 +08:00
chaos-zhu
912ad6561d 支持快捷脚本库&执行 2024-07-31 21:51:03 +08:00
chaos-zhu
794e54f339 👷 更新客户端部署文档 2024-07-31 18:47:34 +08:00
chaos-zhu
e2659c307d 👷 更新客户端部署文档 2024-07-31 18:45:44 +08:00
chaos-zhu
a05056df32 🆕 内置客户端安装脚本 2024-07-31 18:44:46 +08:00
chaos-zhu
0c6ea82be5 支持快捷脚本&简化客户端安装脚本 2024-07-31 18:05:39 +08:00
chaos-zhu
af9f762c25 更新文档 2024-07-31 15:11:15 +08:00
chaos-zhu
05a2c55bff 更新文档 2024-07-31 15:08:19 +08:00
chaos-zhu
886e4da748 Merge branch 'main' of github.com:chaos-zhu/easynode 2024-07-31 14:57:09 +08:00
chaos-zhu
258ee82a82 更新客户端安装脚本 2024-07-31 14:56:43 +08:00
chaos-zhu
061ea08f74 更新客户端安装脚本 2024-07-31 14:55:19 +08:00
chaos-zhu
cb39561d78 🐛 修复客户端arm构建方式 2024-07-31 14:49:15 +08:00
chaos-zhu
b41f10cc0b 调整客户端构建方式 2024-07-31 14:17:09 +08:00
chaos-zhu
5ff574ce02 调整构建方式 2024-07-31 14:14:41 +08:00
chaos-zhu
396467f219 客户端构建 2024-07-31 14:11:54 +08:00
chaos-zhu
64b6bdbbd5
Merge pull request #68 from chaos-zhu/v2.0
 支持多会话同步指令
2024-07-30 19:01:06 +08:00
chaos-zhu
9b432e8109 支持多会话同步指令 2024-07-30 19:00:16 +08:00
chaos-zhu
0a1e92588c 支持多会话同步指令 2024-07-30 18:56:22 +08:00
chaos-zhu
f80cea5d73 👷 更新部署文档 2024-07-29 15:50:57 +08:00
chaos-zhu
0de1463957 👷 更新部署文档 2024-07-29 15:45:00 +08:00
chaos-zhu
d8a4f92410 👷 更新部署文档 2024-07-29 15:27:36 +08:00
chaos-zhu
3d4cf8bbcf 🔨 更新客户端安装脚本 2024-07-29 15:08:29 +08:00
chaos-zhu
79ebd30fbc 👷 更新部署文档 2024-07-29 15:00:10 +08:00
chaos-zhu
43f477a0cc 👷 更新docker镜像构建&文档 2024-07-29 14:33:40 +08:00
chaos-zhu
f2115ccf20 👷 更新docker镜像构建&文档 2024-07-29 13:56:41 +08:00
chaos-zhu
ac7f4eb509 支持实例从csv&json导入 2024-07-29 11:48:00 +08:00
chaos-zhu
a95caf6d2f 💄 update null status 2024-07-29 09:17:44 +08:00
chaoszhu
707c87455a 🆕 format 2024-07-28 21:30:55 +08:00
chaoszhu
f820e28540 🐛 修复group&ssh ID索引 2024-07-25 22:25:18 +08:00
chaoszhu
9dfea65c2f 🐛 修改部分文案&修复copy Ip错误 2024-07-25 21:37:32 +08:00
chaoszhu
afbfc98e68 新增全屏&UI调整 2024-07-25 20:47:10 +08:00
chaoszhu
98dbf26ea3 ⬆️ 升级xterm版本 2024-07-23 12:37:31 +08:00
chaoszhu
afce77f25e 🆕 移除敏感打印信息&修复UI 2024-07-23 11:34:07 +08:00
chaoszhu
66f4153981 🐛 修复非凭据登录终端处理逻辑bug 2024-07-23 11:27:00 +08:00
chaoszhu
660ca88dac temp push 2024-07-23 00:51:55 +08:00
chaoszhu
851d063773 完善终端UI与逻辑 2024-07-23 00:34:31 +08:00
chaoszhu
6a13c961c3 支持多端连接服务器 2024-07-22 18:14:05 +08:00
chaoszhu
655e9bc8af 🆕 重构监控数据结构 2024-07-21 23:58:25 +08:00
chaoszhu
5b2b776155 新增凭证管理功能&字段储存 2024-07-21 02:25:38 +08:00
chaoszhu
5c3818dd73 host ssh数据结构调整 2024-07-20 18:57:59 +08:00
chaoszhu
eaa5e5e65d 🐛 修复socket响应数据bug 2024-07-19 12:04:29 +08:00
chaoszhu
db07d663e2 分组与服务器降序排序 2024-07-19 10:56:21 +08:00
chaoszhu
514c9499e6 调整分组配置 2024-07-19 10:52:09 +08:00
chaoszhu
483f17a591 面板UI改造工程 2024-07-18 18:16:45 +08:00
chaoszhu
7b8014c36b 🚩 移除服务端监控服务&新增用户名 2024-07-18 12:40:18 +08:00
chaoszhu
908558915d 🔧 fix eslint config 2024-07-17 12:19:59 +08:00
chaoszhu
62478abf95 optimization 2024-07-16 16:24:17 +08:00
chaoszhu
f2fe091d2d ⬆️ 升级依赖 2024-07-11 12:40:16 +08:00
chaoszhu
644b9f1de2 ⬆️ 升级依赖 2024-07-11 12:13:33 +08:00
chaoszhu
8d52e34d6f update 2024-07-10 16:52:59 +08:00
chaoszhu
b8e08666a6 update db 2024-07-10 13:21:47 +08:00
chaoszhu
873e20cbcf
Merge pull request #65 from chaos-zhu/dependabot/npm_and_yarn/server/koa/cors-5.0.0
⬆️ Bump @koa/cors from 3.3.0 to 5.0.0 in /server
2024-01-04 13:41:51 +08:00
chaoszhu
c29ae6a45d
Merge pull request #64 from chaos-zhu/dependabot/npm_and_yarn/koa/cors-5.0.0
⬆️ Bump @koa/cors from 3.3.0 to 5.0.0
2024-01-04 13:41:35 +08:00
dependabot[bot]
eaa623e440
⬆️ Bump @koa/cors from 3.3.0 to 5.0.0 in /server
Bumps [@koa/cors](https://github.com/koajs/cors) from 3.3.0 to 5.0.0.
- [Changelog](https://github.com/koajs/cors/blob/master/History.md)
- [Commits](https://github.com/koajs/cors/compare/3.3.0...5.0.0)

---
updated-dependencies:
- dependency-name: "@koa/cors"
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-01-04 03:28:00 +00:00
dependabot[bot]
dd39a6f031
⬆️ Bump @koa/cors from 3.3.0 to 5.0.0
Bumps [@koa/cors](https://github.com/koajs/cors) from 3.3.0 to 5.0.0.
- [Changelog](https://github.com/koajs/cors/blob/master/History.md)
- [Commits](https://github.com/koajs/cors/compare/3.3.0...5.0.0)

---
updated-dependencies:
- dependency-name: "@koa/cors"
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-01-04 03:27:45 +00:00
chaoszhu
83a04c4854
Merge pull request #63 from chaos-zhu/dependabot/npm_and_yarn/server/crypto-js-4.2.0
⬆️ Bump crypto-js from 4.1.1 to 4.2.0 in /server
2024-01-04 11:24:41 +08:00
dependabot[bot]
d5e6f87062
⬆️ Bump crypto-js from 4.1.1 to 4.2.0 in /server
Bumps [crypto-js](https://github.com/brix/crypto-js) from 4.1.1 to 4.2.0.
- [Commits](https://github.com/brix/crypto-js/compare/4.1.1...4.2.0)

---
updated-dependencies:
- dependency-name: crypto-js
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-01-04 03:22:31 +00:00
chaoszhu
0f4fb1c229
Merge pull request #62 from chaos-zhu/dependabot/npm_and_yarn/crypto-js-4.2.0
⬆️ Bump crypto-js from 4.1.1 to 4.2.0
2024-01-04 11:22:06 +08:00
dependabot[bot]
9bdf9f7392
⬆️ Bump crypto-js from 4.1.1 to 4.2.0
Bumps [crypto-js](https://github.com/brix/crypto-js) from 4.1.1 to 4.2.0.
- [Commits](https://github.com/brix/crypto-js/compare/4.1.1...4.2.0)

---
updated-dependencies:
- dependency-name: crypto-js
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-01-04 03:21:52 +00:00
chaoszhu
78fb3c60ed
🐛 fix docker volume md 2023-04-24 14:18:16 +08:00
chaos-zhu
92cb268f3f Merge branch 'v1.2' of https://github.com/chaos-zhu/easynode into v1.2 2023-03-15 13:32:04 +08:00
chaos-zhu
c539271665 🐛 fix sh 2023-03-15 13:31:27 +08:00
chaoszhu
f42912a037 🐛 fix delay 2023-02-18 18:28:59 +08:00
chaos-zhu
8cd547724f Merge branch 'v1.2' of https://github.com/chaos-zhu/easynode into v1.2 2023-02-07 14:18:41 +08:00
chaos-zhu
40b8fc0267 Merge branch 'v1.2' of https://github.com/chaos-zhu/easynode into v1.2 2023-02-07 13:53:05 +08:00
chaos-zhu
ef82255517 💡 udpate web 2023-02-07 11:51:48 +08:00
chaoszhu
0bf16290e2
Update README.md 2023-02-07 11:44:49 +08:00
chaoszhu
a0bdbeca78
Update README.md 2023-02-02 11:14:36 +08:00
chaoszhu
7fcafc6844
Merge pull request #29 from chaos-zhu/dependabot/npm_and_yarn/server/jsonwebtoken-9.0.0
⬆️ Bump jsonwebtoken from 8.5.1 to 9.0.0 in /server
2023-01-04 11:49:26 +08:00
dependabot[bot]
00b4216eaa
⬆️ Bump jsonwebtoken from 8.5.1 to 9.0.0 in /server
Bumps [jsonwebtoken](https://github.com/auth0/node-jsonwebtoken) from 8.5.1 to 9.0.0.
- [Release notes](https://github.com/auth0/node-jsonwebtoken/releases)
- [Changelog](https://github.com/auth0/node-jsonwebtoken/blob/master/CHANGELOG.md)
- [Commits](https://github.com/auth0/node-jsonwebtoken/compare/v8.5.1...v9.0.0)

---
updated-dependencies:
- dependency-name: jsonwebtoken
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2023-01-04 03:44:29 +00:00
chaoszhu
752639b3ea 2022-12-12 21:01:50 +08:00
chaoszhu
b26b373ae2 2022-12-12 21:00:12 +08:00
chaos-zhu
d258f1e1fa 🐛 fix node version 2022-12-12 20:52:41 +08:00
chaos-zhu
bcfbe0d6b3 2022-12-12 20:35:52 +08:00
zhulj
0d0b24de75 🆕 1741 2022-09-29 17:41:54 +08:00
zhulj
6fb865756c 🆕 1740 2022-09-29 17:40:03 +08:00
zhulj
e012009b3c 🆕 1737 2022-09-29 17:37:33 +08:00
zhulj
2cbdd30b17 🆕 1735 2022-09-29 17:35:17 +08:00
zhulj
066c93efe8 🆕 1729 2022-09-29 17:29:21 +08:00
zhulj
2e707daa39 🆕 1725 2022-09-29 17:25:05 +08:00
zhulj
c2305f0d7c 🆕 1723 2022-09-29 17:23:06 +08:00
zhulj
39bf41f222 🆕 2022-09-29 17:12:29 +08:00
zhulj
4fa5645a29 🆕 2022-09-29 17:10:17 +08:00
zhulj
eca1782cf2 🆕 2022-09-29 17:06:50 +08:00
chaoszhu
dfe937f52b
Update vercel.json 2022-09-29 16:59:12 +08:00
chaoszhu
4f7d6bc886
Update server.js 2022-09-29 15:59:30 +08:00
chaoszhu
bfd8f84f3f
🐛 fix vercel config 2022-09-29 15:53:01 +08:00
chaoszhu
1460a2bff3
🆕 2022-09-29 15:48:21 +08:00
chaoszhu
d118ccfd54
🆕 2022-09-29 15:47:55 +08:00
chaoszhu
8abd68c5fc
🆕 2022-09-29 15:45:47 +08:00
chaoszhu
d8b13e506e
Create vercel.json 2022-09-29 15:44:05 +08:00
chaoszhu
132c598b51
Merge pull request #19 from nzlov/patch-1
Update README.md
2022-09-14 09:35:58 +08:00
nzlov
f0461fc2b2
Update README.md
'websocker' => 'websocket'
2022-09-13 17:23:19 +08:00
chaoszhu
6959897457
Merge pull request #18 from darrenliuwei/patch-1
Update README.md
2022-09-13 15:50:08 +08:00
Darren Liu
8da6482ede
Update README.md
错别字修改
2022-09-13 14:08:08 +08:00
chaoszhu
632f3b2b48 🐛 fix CRLF 2022-09-12 22:59:34 +08:00
chaoszhu
f79dc5eb42 udpate docker images 2022-09-12 22:54:49 +08:00
chaoszhu
206a14632a v1.2 release 2022-09-12 22:47:56 +08:00
chaoszhu
ca60f4a87c v1.2 release 2022-09-12 22:46:41 +08:00
193 changed files with 19112 additions and 7488 deletions

15
.dockerignore Normal file
View File

@ -0,0 +1,15 @@
node_modules
!.gitkeep
dist
easynode-server.zip
server/app/static/*
server/app/socket/sftp-cache/*
!server/app/socket/sftp-cache/.gitkeep
server/app/logs/*
server/app/db/*
!server/app/db/README.md
plan.md
.env
.env.local
.git
doc_images

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

40
.github/workflows/docker-builder.yml vendored Normal file
View File

@ -0,0 +1,40 @@
name: Build Server to DockerHub
on:
release:
types: [published]
workflow_dispatch:
inputs:
tag_name:
description: 'Tag Name (leave empty for default latest)'
required: false
default: 'latest'
jobs:
build-and-push:
runs-on: ubuntu-latest
steps:
- name: Check out the repository
uses: actions/checkout@v3
- name: Log in to Docker Hub
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
- name: Build and push Docker image
uses: docker/build-push-action@v3
with:
context: .
file: ./Dockerfile
push: true
platforms: linux/amd64,linux/arm64
tags: |
chaoszhu/easynode:${{ github.event.release.tag_name || inputs.tag_name }}
chaoszhu/easynode:latest
# - name: Clean up post-build
# run: docker system prune -af

16
.gitignore vendored
View File

@ -2,6 +2,16 @@ node_modules
!.gitkeep
dist
easynode-server.zip
server/app/static/upload/*
server/app/socket/temp/*
server/app/logs/*
server/app/static/*
server/app/socket/sftp-cache/*
!server/app/socket/sftp-cache/.gitkeep
server/app/logs/*
server/app/db/*
!server/app/db/README.md
plan.md
.env
.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,248 @@
## [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)
### Features
* SFTP支持上传嵌套文件夹 ✔
* 修复面板服务缓存文件夹偶尔不存在的bug ✔
## [2.1.5](https://github.com/chaos-zhu/easynode/releases) (2024-08-12)
### Features
* 新增终端设置 ✔
* 支持更多终端主题 ✔
* 支持终端背景图片(当前版本只缓存在前端且只可以使用内置背景图片) ✔
## [2.1.4](https://github.com/chaos-zhu/easynode/releases) (2024-08-12)
### Features
* 新增cd全路径命令联动SFTP面板 ✔
* 修复SFTP文件编辑文件名称显示错误的bug ✔
## [2.1.3](https://github.com/chaos-zhu/easynode/releases) (2024-08-11)
### Features
* 修复开启or关闭SFTP功能开关时终端光标位置错误的bug ✔
## [2.1.2](https://github.com/chaos-zhu/easynode/releases) (2024-08-09)
### Features
* 新增导入导出功能(EasyNode JSON) ✔
* 新增服务器列表排序与排序缓存 ✔
* 优化客户端连接状态展示 ✔
* 优化版本更新提示 ✔
## [2.1.1](https://github.com/chaos-zhu/easynode/releases) (2024-08-05)
### Features
* 支持批量操作:批量修改实例通用信息(ssh配置等)、批量删除、批量安装客户端监控应用 ✔
* 自动化构建镜像 ✔
* 调整&优化面板UI ✔
* 内置常用脚本(逐渐添加中...) ✔
## [2.1.0](https://github.com/chaos-zhu/easynode/releases) (2024-08-02)
### Features
* 支持脚本库功能 ✔
* 支持批量指令下发功能 ✔
* 支持多会话同步指令 ✔
* 重写Dockerfile,大幅减少镜像体积 ✔
* 调整优化面板UI ✔
## [2.0.0](https://github.com/chaos-zhu/easynode/releases) (2024-07-29)
### Features
* 重构前端UI ✔
* 新增多个功能菜单 ✔
* 重构文件储存方式 ✔
* 升级前后端依赖 ✔
* 优化前端工程 ✔
* 修复不同ssh密钥算法登录失败的bug ✔
* 移除上一次IP登录校验的判断 ✔
* 前端工程迁移至项目根目录 ✔
* 添加ssh密钥or密码保存至本地功能 ✔
## [1.2.1](https://github.com/chaos-zhu/easynode/releases) (2022-12-12)
### Features
* 新增支持终端长命令输入模式 ✔
* 新增前端静态文件缓存 ✔
* 【重要】v1.2.1开始移除创建https服务 ✔
### Bug Fixes
* v1.2的若干bug...
## [1.2.0](https://github.com/chaos-zhu/easynode/releases) (2022-09-12)
### Features
* 新增邮件通知: 包括登录面板、密码修改、服务器到期、服务器离线等 ✔
* 支持服务器分组(为新版UI作准备的) ✔
* 面板功能调整支持http延迟显示、支持服务器控制台直达与到期时间字段 ✔
* 优化终端输入、支持状态面板收缩 ✔
* **全新SFTP功能支持上传下载进度条展示**
* **支持在线文件编辑与保存**
### Bug Fixes
* v1.1的若干bug...
---
## [1.1.0](https://github.com/chaos-zhu/easynode/releases) (2022-06-27)
### Features

21
Dockerfile Normal file
View File

@ -0,0 +1,21 @@
FROM node:20.16-alpine3.20 AS builder_web
WORKDIR /easynode/web
COPY ./web .
COPY yarn.lock .
RUN yarn
RUN yarn build
FROM node:20.16-alpine3.20 AS builder_server
WORKDIR /easynode/server
COPY ./server .
COPY yarn.lock .
COPY --from=builder_web /easynode/web/dist ./app/static
RUN yarn
FROM node:20.16-alpine3.20
RUN apk add --no-cache iputils
WORKDIR /easynode
COPY --from=builder_server /easynode/server .
ENV HOST=0.0.0.0
EXPOSE 8082
CMD ["npm", "start"]

32
Q&A.md
View File

@ -1,15 +1,31 @@
# 用于收集一些疑难杂症
# Q&A
- **欢迎pr~**
## ssh连接失败
## 甲骨文CentOS7/8启动服务失败
首先确定用户名/密码/密钥没错接着排查服务端ssh登录日志例如Debian12 `journalctl -u ssh -f`
如果出现类似以下日志:
```shell
Jul 10 12:29:11 iZ2ze5f4ne9xf8n3h5Z sshd[8020]: userauth_pubkey: signature algorithm ssh-rsa not in PubkeyAcceptedAlgorithms [preauth]
```
说明客户端 `ssh-rsa` 签名算法不在 `PubkeyAcceptedAlgorithms` 列表中,目标服务器不接受 ssh-rsa 签名算法的公钥认证。
**解决: **
编辑 /etc/ssh/sshd_config 文件,添加或修改以下配置
```shell
PubkeyAcceptedAlgorithms +ssh-rsa
```
重新启动 SSH 服务: `sudo systemctl restart sshd`
## CentOS7/8启动服务失败
> 先关闭SELinux
```shell
vi /etc/selinux/config
SELINUX=enforcing
// 修改为禁用
SELINUX=enforcing
# 修改为禁用
SELINUX=disabled
```
@ -17,6 +33,8 @@ SELINUX=disabled
> 查看SELinux状态sestatus
## 甲骨文ubuntu20.04客户端服务启动成功,无法连接?
## 客户端服务启动成功,无法连接?
> 端口未开放:`iptables -I INPUT -s 0.0.0.0/0 -p tcp --dport 22022 -j ACCEPT` 或者 `rm -rf /etc/iptables && reboot`
> 1. 检查防火墙配置
> 2. iptables端口未开放`iptables -I INPUT -s 0.0.0.0/0 -p tcp --dport 22022 -j ACCEPT` 或者 `rm -rf /etc/iptables && reboot`

223
README.md
View File

@ -1,182 +1,123 @@
<div align="center">
# EasyNode
> 一个简易的个人Linux服务器管理面板(基于Node.js)
_✨ 一个多功能Linux服务器WEB终端面板(webSSH&webSFTP) ✨_
<!-- - [EasyNode](#easynode) -->
- [功能简介](#功能简介)
- [安装指南](#安装指南)
- [服务端安装](#服务端安装)
- [Docker镜像](#docker镜像)
- [一键脚本](#一键脚本)
- [手动部署](#手动部署)
- [客户端安装](#客户端安装)
- [X86架构](#x86架构)
- [ARM架构](#arm架构)
- [升级指南](#升级指南)
- [服务端](#服务端)
- [客户端](#客户端)
- [版本日志](#版本日志)
- [安全与说明](#安全与说明)
- [Q&A](#qa)
- [感谢Star](#感谢star)
- [License](#license)
</div>
## 功能简介
<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">
</a>
<a href="https://hub.docker.com/repository/docker/chaoszhu/easynode">
<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>
> 多服务器管理; 通过`websocker实时更新`服务器基本信息: **系统、公网IP、CPU、内存、硬盘、网卡**等
<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>
![服务器列表](./images/v1.1-panel.png)
## 功能
> 基于浏览器解决`SSH跨端同步`问题——**Web SSH**
+ [x] 功能完善的**SSH终端**&**SFTP**
+ [x] 批量导入、导出、编辑服务器配置、脚本等
+ [x] 脚本库
+ [x] 实例分组
+ [x] 凭据托管
+ [x] 多渠道通知
+ [x] 批量下发指令
+ [x] 自定义终端主题
![webssh功能](./images/v1.1-webssh.png)
## 面板展示
## 安装指南
![面板展示](./doc_images/merge.gif)
### 服务端安装
## 项目部署
- 依赖Node.js环境
- 默认账户密码 `admin/admin`
- web端口8082
- 占用端口8082(http端口)、8083(https端口)、22022(客户端端口)
- 建议使用**境外服务器**(最好延迟低)安装服务端客户端信息监控与webssh功能都将以`该服务器作为跳板机`
- https服务需自行配置证书或者使用`nginx反代`解决(推荐)
#### Docker镜像
> 注意网速统计功能可能受限docker网络将使用host模式(与宿主机共享端口,占用: 8082、22022)
### docker镜像
```shell
docker run -d --net=host -v /easynode-server:/easynode-server/server/app/config/storage chaoszhu/easynode:v1.1
docker run -d -p 8082:8082 --restart=always -v /root/easynode/db:/easynode/app/db chaoszhu/easynode
```
环境变量:
- `DEBUG`: 启动debug日志 0关闭 1开启, 默认关闭
- `ALLOWED_IPS`: 可以访问服务的IP白名单, 多个使用逗号分隔, 支持填写部分ip前缀, 例如: `-e ALLOWED_IPS=127.0.0.1,196.168`
访问http://yourip:8082
## 监控服务安装
#### 一键脚本
- 监控服务用于实时向服务端&web端推送**系统、公网IP、CPU、内存、硬盘、网卡**等基础信息
- 依赖Linux基础命令curl wget git zip tar如未安装请先安装
- 默认端口:**22022**
> ubuntu/debian: apt install curl wget git zip tar -y
>
> centos: yum install curl wget git zip tar -y
> 安装
```shell
wget -qO- --no-check-certificate https://ghproxy.com/https://raw.githubusercontent.com/chaos-zhu/easynode/v1.1/easynode-server-install.sh | bash
```
# 使用默认端口22022安装
curl -o- https://git.221022.xyz/https://raw.githubusercontent.com/chaos-zhu/easynode/main/client/easynode-client-install.sh | bash
访问http://yourip:8082
- 查看日志:`pm2 log easynode-server`
- 启动服务:`pm2 start easynode-server`
- 停止服务:`pm2 stop easynode-server`
- 停止服务:`pm2 delete easynode-server`
#### 手动部署
1. 安装Node.js
2. 安装pm2、安装yarn
3. 拉取代码git clone https://github.com/chaos-zhu/easynode.git
4. cd server目录
5. 安装依赖yarn
6. 启动服务pm2 start server/app/main.js --name easynode-server
7. 访问http://yourip:8082
- 默认登录密码admin(首次部署完成后请及时修改).
6. 部署https服务
- 部署https服务需要自己上传域名证书至`\server\app\config\pem`,并且证书和私钥分别命名:`key.pem``cert.pem`
- 配置域名vim server/app/config/index.js 在domain字段中填写你解析到服务器的域名
- pm2 restart easynode-server
- 不出意外你就可以访问https服务https://domain:8083
---
### 客户端安装
- 占用端口22022
#### X86架构
```shell
wget -qO- --no-check-certificate https://ghproxy.com/https://raw.githubusercontent.com/chaos-zhu/easynode/v1.1/easynode-client-install-x86.sh | bash
```
#### ARM架构
```shell
wget -qO- --no-check-certificate https://ghproxy.com/https://raw.githubusercontent.com/chaos-zhu/easynode/v1.1/easynode-client-install-arm.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
wget -qO- --no-check-certificate https://ghproxy.com/https://raw.githubusercontent.com/chaos-zhu/easynode/v1.1/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`
>
> 查看客户端日志: `journalctl --follow -u easynode-client`
>
> 查看监控服务状态:`systemctl status easynode-client`
>
> 查看监控服务日志: `journalctl --follow -u easynode-client`
>
> 查看详细日志journalctl -xe
---
## 升级指南
- **v1.0 to v1.1**
## 安全与建议
### 服务端
首先声明任何系统无法保障没有bug的存在EasyNode也一样。
> v1.1对所有的敏感信息全部加密所有的v1.0为加密的信息全部失效. 主要影响已存储的ssh密钥.
>
> **还原客户端列表:** 先备份`app\config\storage\host-list.json`, 使用一键脚本或者手动部署的同志安装好使用备份文件覆盖此文件即可。
>
> 使用docker镜像的v1.0一键脚本**未做**文件夹映射,有能力的自己从镜像里把备份抠出来再重新构建镜像.
面板提供MFA2功能并且可配置访问此服务的IP白名单, 如需加强可以使用**iptables**进一步限制IP访问。
如果需要更高级别的安全性,建议面板服务不要暴露到公网。
### 客户端
webssh与监控服务都将以`该服务器作为中转`。中国大陆用户建议使用香港、新加坡、日本、韩国等地区的低延迟服务器来安装服务端面板
> v1.1未对客户端包进行改动,客户端无需重复安装. 不会备份的在面板重新添加客户端机器即可.
## 常见问题
### 版本日志
- [QA](./Q%26A.md)
- [CHANGELOG](./CHANGELOG.md)
<!-- ## Plus版功能
## 安全与说明
> 本人非专业后端,此服务全凭兴趣开发. 由于知识受限,并不能保证没有漏洞的存在,生产服务器请慎重使用此服务.
> 所有服务器信息相关接口已做`jwt鉴权`, 安全信息均使用加密传输与储存!
> webssh功能需要的密钥信息全部保存在服务端服务器的`app\config\storage\ssh-record.json`中. 在保存ssh密钥信息到服务器储存与传输过程皆已加密`不放心最好套https使用`
<!-- ## 技术架构
> 待更新... -->
<!-- ## 后续版本功能方向
- SFTP
- 在线文件编辑
- 终端常用指令
- 面板UI优化(列表\卡片\自定义背景)
- 登录IP白名单
- 版本更新检测
- 终端tab一键分屏
- 登录有效期(目前默认1h)
- 面板登录通知(tg or wx?)
- 定时任务 -->
## Q&A
- [Q&A](./Q%26A.md)
## 感谢Star
[![Stargazers repo roster for @chaos-zhu/easynode](https://reporoster.com/stars/chaos-zhu/easynode)](https://github.com/chaos-zhu/easynode/stargazers)
<!-- [![Stargazers over time](https://starchart.cc/chaos-zhu/easynode.svg)](https://starchart.cc/chaos-zhu/easynode) -->
## License
[MIT](LICENSE). Copyright (c).
- 跳板机功能,拯救被墙实例与龟速终端输入
- 本地socket断开自动重连,无需手动重新连接
- 批量修改实例配置(优化版)
- 脚本库批量导出导入
- 凭据管理支持解密带密码保护的密钥
- 提出的功能需求享有更高的开发优先级 -->

View File

@ -1,6 +1,9 @@
// 规则参见https://cn.eslint.org/docs/rules/
module.exports = {
root: true, // 当前配置文件不能往父级查找
'globals': {
'consola': true
},
env: {
node: true,
es6: true
@ -20,6 +23,7 @@ module.exports = {
'no-debugger': process.env.NODE_ENV === 'production' ? 'error' : 'off',
'template-curly-spacing': ['error', 'always'], // 模板字符串空格
'default-case': 0,
'no-empty': 0,
'object-curly-spacing': ['error', 'always'],
'no-multi-spaces': ['error'],
indent: ['error', 2, { 'SwitchCase': 1 }], // 缩进2
@ -58,7 +62,6 @@ module.exports = {
'no-dupe-args': 2, // 禁止function重复参数
'no-dupe-keys': 2, // 禁止object重复key
'no-duplicate-case': 2,
'no-empty': 2, // 禁止空语句块
'no-func-assign': 2, // 禁止重复声明函数
'no-inner-declarations': 2, // 禁止在嵌套的语句块中出现变量或 function 声明
'no-sparse-arrays': 2, // 禁止稀缺数组

View File

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

59
client/app/lib/swap.js Normal file
View File

@ -0,0 +1,59 @@
let exec = require('child_process').exec
let os = require('os')
function getSwapMemory() {
return new Promise((resolve, reject) => {
if (os.platform() === 'win32') {
// Windows-specific command
const command = 'powershell -command "Get-CimInstance Win32_OperatingSystem | Select-Object TotalVirtualMemorySize, FreeVirtualMemory"'
exec(command, { encoding: 'utf8' }, (error, stdout, stderr) => {
if (error) {
console.error('exec error:', error)
return reject(error)
}
if (stderr) {
console.error('stderr:', stderr)
return reject(stderr)
}
const lines = stdout.trim().split('\n')
const values = lines[lines.length - 1].trim().split(/\s+/)
const totalVirtualMemory = parseInt(values[0], 10) / 1024
const freeVirtualMemory = parseInt(values[1], 10) / 1024
const usedVirtualMemory = totalVirtualMemory - freeVirtualMemory
resolve({
swapTotal: totalVirtualMemory,
swapFree: freeVirtualMemory,
swapUsed: usedVirtualMemory,
swapPercentage: ((usedVirtualMemory / totalVirtualMemory) * 100).toFixed(1)
})
})
} else {
exec('free -m | grep Swap', (error, stdout, stderr) => {
if (error) {
console.error('exec error:', error)
return reject(error)
}
if (stderr) {
console.error('stderr:', stderr)
return reject(stderr)
}
const swapInfo = stdout.trim().split(/\s+/)
const swapTotal = parseInt(swapInfo[1], 10)
const swapUsed = parseInt(swapInfo[2], 10)
const swapFree = parseInt(swapInfo[3], 10)
resolve({
swapTotal,
swapUsed,
swapFree,
swapPercentage: ((swapUsed / swapTotal) * 100).toFixed(1)
})
})
}
})
}
module.exports = getSwapMemory

View File

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

View File

@ -25,13 +25,13 @@ function ipSchedule() {
getIpInfo()
})
// 每日凌晨两点整,刷新ip信息(兼容动态ip服务器)
// 每日凌晨两点整,刷新ip信息
let rule2 = new schedule.RecurrenceRule()
rule2.hour = 2
rule2.minute = 0
rule2.second = 0
schedule.scheduleJob(rule2, () => {
console.log('Task: refresh ip info', new Date())
console.log('Task: refresh ip info: ', new Date())
getIpInfo()
})
}

View File

@ -1,4 +1,5 @@
const osu = require('node-os-utils')
const osSwap = require('../lib/swap')
const os = require('os')
let cpu = osu.cpu
@ -9,7 +10,7 @@ let osuOs = osu.os
let users = osu.users
async function cpuInfo() {
let cpuUsage = await cpu.usage(200)
let cpuUsage = await cpu.usage(500)
let cpuCount = cpu.count()
let cpuModel = cpu.model()
return {
@ -26,6 +27,13 @@ async function memInfo() {
}
}
async function swapInfo() {
let swapInfo = await osSwap()
return {
...swapInfo
}
}
async function driveInfo() {
let driveInfo = {}
try {
@ -71,6 +79,7 @@ module.exports = async () => {
data = {
cpuInfo: await cpuInfo(),
memInfo: await memInfo(),
swapInfo: await swapInfo(),
driveInfo: await driveInfo(),
netstatInfo: await netstatInfo(),
osInfo: await osInfo(),

0
client/bin/www Normal file → Executable file
View File

View File

@ -5,12 +5,18 @@ if [ "$(id -u)" != "0" ] ; then
exit 1
fi
clientPort=${clientPort:-22022}
SERVER_NAME=easynode-client
FILE_PATH=/root/local/easynode-client
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
if [ $? != 4 ]
@ -18,7 +24,7 @@ then
echo "***********************停用旧服务***********************"
systemctl stop ${SERVER_NAME}
systemctl disable ${SERVER_NAME}
systemctl daemon-reload
systemctl daemon-reload
fi
if [ -f "${SERVICE_PATH}/${SERVER_NAME}.service" ]
@ -42,8 +48,18 @@ echo "***********************创建文件PATH***********************"
mkdir -p ${FILE_PATH}
echo "***********************下载开始***********************"
DOWNLOAD_FILE_URL="https://ghproxy.com/https://github.com/chaos-zhu/easynode/releases/download/v1.1/easynode-client-arm"
DOWNLOAD_SERVICE_URL="https://ghproxy.com/https://raw.githubusercontent.com/chaos-zhu/easynode/v1.1/client/easynode-client.service"
ARCH=$(uname -m)
echo "***********************系统架构: $ARCH***********************"
if [ "$ARCH" = "x86_64" ] ; then
DOWNLOAD_FILE_URL="${SERVER_PROXY}https://github.com/chaos-zhu/easynode/releases/download/${CLIENT_VERSION}/easynode-client-x64"
elif [ "$ARCH" = "aarch64" ] ; then
DOWNLOAD_FILE_URL="${SERVER_PROXY}https://github.com/chaos-zhu/easynode/releases/download/${CLIENT_VERSION}/easynode-client-arm64"
else
echo "不支持的架构:$ARCH. 只支持x86_64和aarch64其他架构请自行构建"
exit 1
fi
# -O 指定路径和文件名(这里是二进制文件, 不需要扩展名)
wget -O ${FILE_PATH}/${SERVER_NAME} --no-check-certificate --no-cache ${DOWNLOAD_FILE_URL}
@ -53,6 +69,8 @@ then
exit 1
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}
if [ $? != 0 ]
@ -67,6 +85,8 @@ echo "***********************下载成功***********************"
chmod +x ${FILE_PATH}/${SERVER_NAME}
chmod 777 ${FILE_PATH}/${SERVER_NAME}.service
sed -i "s/clientPort=22022/clientPort=${clientPort}/g" ${FILE_PATH}/${SERVER_NAME}.service
# echo "***********************移动service&reload***********************"
mv ${FILE_PATH}/${SERVER_NAME}.service ${SERVICE_PATH}
@ -76,7 +96,6 @@ systemctl daemon-reload
echo "***********************启动服务***********************"
systemctl start ${SERVER_NAME}
# echo "***********************设置开机启动***********************"
systemctl enable ${SERVER_NAME}

View File

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

View File

@ -1,6 +1,6 @@
{
"name": "easynode-client",
"version": "1.0.0",
"version": "1.0.1",
"description": "easynode-client",
"bin": "./bin/www",
"pkg": {
@ -9,8 +9,8 @@
"scripts": {
"client": "nodemon ./app/main.js",
"pkgwin": "pkg . -t node16-win-x64",
"pkglinux:x86": "pkg . -t node16-linux-x64",
"pkglinux:arm": "pkg . -t node16-linux-arm64"
"pkglinux:x64": "pkg . -t node16-linux-x64 -o dist/easynode-client-x64",
"pkglinux:arm64": "pkg . -t node16-linux-arm64 -o dist/easynode-client-arm64"
},
"keywords": [],
"author": "",
@ -21,15 +21,15 @@
]
},
"dependencies": {
"axios": "^0.21.4",
"koa": "^2.13.1",
"node-os-utils": "^1.3.6",
"node-schedule": "^2.1.0",
"socket.io": "^4.4.1"
"axios": "0.27.2",
"eslint": "8.56.0",
"koa": "2.15.3",
"node-os-utils": "1.3.7",
"node-schedule": "2.1.1",
"socket.io": "4.7.5"
},
"devDependencies": {
"eslint": "^7.32.0",
"nodemon": "^2.0.15",
"pkg": "5.6"
"nodemon": "^3.1.4",
"pkg": "5.8"
}
}

BIN
doc_images/1.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

BIN
doc_images/2.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

BIN
doc_images/3.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

BIN
doc_images/4.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

BIN
doc_images/5.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

BIN
doc_images/6.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

BIN
doc_images/7.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

BIN
doc_images/merge.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 900 KiB

BIN
doc_images/wx.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

View File

@ -1,83 +0,0 @@
#!/bin/bash
if [ "$(id -u)" != "0" ] ; then
echo "***********************请切换到root再尝试执行***********************"
exit 1
fi
SERVER_NAME=easynode-client
FILE_PATH=/root/local/easynode-client
SERVICE_PATH=/etc/systemd/system
SERVER_VERSION=v1.0
echo "***********************开始安装 easynode-client_${SERVER_VERSION}***********************"
systemctl status ${SERVER_NAME} > /dev/null 2>&1
if [ $? != 4 ]
then
echo "***********************停用旧服务***********************"
systemctl stop ${SERVER_NAME}
systemctl disable ${SERVER_NAME}
systemctl daemon-reload
fi
if [ -f "${SERVICE_PATH}/${SERVER_NAME}.service" ]
then
echo "***********************移除旧服务***********************"
chmod 777 ${SERVICE_PATH}/${SERVER_NAME}.service
rm -Rf ${SERVICE_PATH}/${SERVER_NAME}.service
systemctl daemon-reload
fi
if [ -d ${FILE_PATH} ]
then
echo "***********************移除旧文件***********************"
chmod 777 ${FILE_PATH}
rm -Rf ${FILE_PATH}
fi
# 开始安装
echo "***********************创建文件PATH***********************"
mkdir -p ${FILE_PATH}
echo "***********************下载开始***********************"
DOWNLOAD_FILE_URL="https://ghproxy.com/https://github.com/chaos-zhu/easynode/releases/download/v1.1/easynode-client-x86"
DOWNLOAD_SERVICE_URL="https://ghproxy.com/https://raw.githubusercontent.com/chaos-zhu/easynode/v1.1/client/easynode-client.service"
# -O 指定路径和文件名(这里是二进制文件, 不需要扩展名)
wget -O ${FILE_PATH}/${SERVER_NAME} --no-check-certificate --no-cache ${DOWNLOAD_FILE_URL}
if [ $? != 0 ]
then
echo "***********************下载${SERVER_NAME}失败***********************"
exit 1
fi
wget -O ${FILE_PATH}/${SERVER_NAME}.service --no-check-certificate --no-cache ${DOWNLOAD_SERVICE_URL}
if [ $? != 0 ]
then
echo "***********************下载${SERVER_NAME}.service失败***********************"
exit 1
fi
echo "***********************下载成功***********************"
# echo "***********************设置权限***********************"
chmod +x ${FILE_PATH}/${SERVER_NAME}
chmod 777 ${FILE_PATH}/${SERVER_NAME}.service
# echo "***********************移动service&reload***********************"
mv ${FILE_PATH}/${SERVER_NAME}.service ${SERVICE_PATH}
# echo "***********************daemon-reload***********************"
systemctl daemon-reload
echo "***********************启动服务***********************"
systemctl start ${SERVER_NAME}
# echo "***********************设置开机启动***********************"
systemctl enable ${SERVER_NAME}
echo "***********************安装成功***********************"

View File

@ -1,69 +0,0 @@
#!/bin/bash
if [ "$(id -u)" != "0" ] ; then
echo "***********************请切换到root再尝试执行***********************"
exit 1
fi
# 编写中...
echo '开始安装nvm'
rm -rf /root/.nvm
# 国内
bash -c "$(curl -fsSL https://gitee.com/chaoszhu_0/nvm-cn/raw/master/install.sh)"
# 国外
# bash -c "$(curl -fsSL https://raw.githubusercontent.com/chaos-zhu/nvm-cn/master/install.sh)"
if [ $? != "0" ] ; then
echo '安装失败'
exit 1
fi
. /root/.nvm/nvm.sh
echo "nvm version: $(nvm -v)"
export VM_NODEJS_ORG_MIRROR=https://npm.taobao.org/mirrors/node
echo '开始安装node&npm'
nvm install --lts
echo "node version: $(node -v) 安装成功"
echo "npm version: $(npm -v) 安装成功"
echo '开始安装pm2'
npm config set registry https://registry.npm.taobao.org
npm i -g pm2
echo "pm2 version: $(pm2 -v) 安装成功"
echo '开始下载EasyNode'
DOWNLOAD_FILE_URL="https://ghproxy.com/https://github.com/chaos-zhu/easynode/releases/download/v1.1/easynode-server.zip"
SERVER_NAME=easynode-server
SERVER_ZIP=easynode-server.zip
FILE_PATH=/root
wget -O ${FILE_PATH}/${SERVER_ZIP} --no-check-certificate --no-cache ${DOWNLOAD_FILE_URL}
if [ $? != 0 ]
then
echo "***********************下载EasyNode.zip失败***********************"
exit 1
fi
echo '开始解压'
unzip -o -d ${FILE_PATH}/${SERVER_NAME} ${SERVER_ZIP}
cd ${FILE_PATH}/${SERVER_NAME} || exit
echo '安装依赖'
npm i -g yarn
yarn
echo '启动服务'
pm2 start ${FILE_PATH}/${SERVER_NAME}/app/main.js --name easynode-server

Binary file not shown.

Before

Width:  |  Height:  |  Size: 955 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 967 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 325 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 701 KiB

View File

@ -1,9 +1,12 @@
{
"name": "easynode",
"version": "1.0.0",
"description": "easy to manage the server",
"description": "web ssh",
"private": true,
"workspaces": ["server", "client"],
"workspaces": [
"server",
"web",
"client"
],
"repository": {
"type": "git",
"url": "git+https://github.com/chaos-zhu/easynode.git"
@ -17,8 +20,17 @@
],
"author": "chaoszhu",
"license": "ISC",
"scripts": {
"dev": "concurrently \"yarn workspace web run dev\" \"yarn workspace server run local\"",
"clean": "rimraf web/node_modules server/node_modules client/node_modules node_modules",
"encrypt": "node ./local-script/encrypt-file.js"
},
"bugs": {
"url": "https://github.com/chaos-zhu/easynode/issues"
},
"homepage": "https://github.com/chaos-zhu/easynode#readme"
"homepage": "https://github.com/chaos-zhu/easynode#readme",
"devDependencies": {
"concurrently": "^8.2.2",
"rimraf": "^6.0.1"
}
}

8
server/.env.template Normal file
View File

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

99
server/.eslintrc.js Normal file
View File

@ -0,0 +1,99 @@
// 规则参见https://cn.eslint.org/docs/rules/
module.exports = {
root: true, // 当前配置文件不能往父级查找
'globals': { 'consola': true },
env: {
node: true,
es6: true
},
extends: [
'eslint:recommended' // 应用Eslint全部默认规则
],
'parserOptions': {
'ecmaVersion': 'latest',
'sourceType': 'module' // 目标类型 Node项目得添加这个
},
// 自定义规则,可以覆盖 extends 的配置【安装Eslint插件可以静态检查本地文件是否符合以下规则】
'ignorePatterns': ['*.html', 'node-os-utils'],
rules: {
// 0: 关闭规则(允许) 1/2: 警告warning/错误error(不允许)
'no-console': 'off',
'no-debugger': process.env.NODE_ENV === 'production' ? 'error' : 'off',
'template-curly-spacing': ['error', 'always'], // 模板字符串空格
'default-case': 0,
'no-empty': 0,
'object-curly-spacing': ['error', 'always'],
'no-multi-spaces': ['error'],
indent: ['error', 2, { 'SwitchCase': 1 }], // 缩进2
quotes: ['error', 'single'], // 引号single单引 double双引
semi: ['error', 'never'], // 结尾分号never禁止 always必须
'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-multi-assign': 0,
'no-restricted-globals': 0,
'no-case-declarations': 0,
'space-before-function-paren': 0, // 函数定义时括号前面空格
'no-async-promise-executor': 0, // 允许在回调中使用async函数
'one-var': 0, // 允许连续声明
// 'no-undef': 0, // 允许未定义的变量【会使env配置无效】
'linebreak-style': 0, // 检测CRLF/LF检测【默认LF】
'no-extra-boolean-cast': 0, // 允许意外的Boolean值转换
'no-constant-condition': 0, // if语句中禁止常量表达式
'no-prototype-builtins': 0, // 允许使用Object.prototypes内置对象(如xxx.hasOwnProperty)
'no-regex-spaces': 0, // 允许正则匹配多个空格
'no-unexpected-multiline': 0, // 允许多行表达式
'no-fallthrough': 0, // 允许switch穿透
'no-delete-var': 0, // 允许 delete 删除对象属性
'no-mixed-spaces-and-tabs': 0, // 允许空格tab混用
'no-class-assign': 0, // 允许修改class类型
'no-param-reassign': 0, // 允许对函数params赋值
'max-len': 0, // 允许长行
'func-names': 0, // 允许命名函数
'import/no-unresolved': 0, // 不检测模块not fund
'import/prefer-default-export': 0, // 允许单个导出
'no-const-assign': 1, // 警告修改const命名的变量
'no-unused-vars': 1, // 警告:已声明未使用
'no-unsafe-negation': 1, // 警告:使用 in / instanceof 关系运算符时,左边表达式请勿使用 ! 否定操作符
'use-isnan': 1, // 警告:使用 isNaN() 检查 NaN
'no-var': 2, // 禁止使用var声明
'no-empty-pattern': 2, // 空解构赋值
'eqeqeq': 2, // 必须使用 全等=== 或 非全等 !==
'no-cond-assign': 2, // if语句中禁止赋值
'no-dupe-args': 2, // 禁止function重复参数
'no-dupe-keys': 2, // 禁止object重复key
'no-duplicate-case': 2,
'no-func-assign': 2, // 禁止重复声明函数
'no-inner-declarations': 2, // 禁止在嵌套的语句块中出现变量或 function 声明
'no-sparse-arrays': 2, // 禁止稀缺数组
'no-unreachable': 2, // 禁止非条件return、throw、continue 和 break 语句后出现代码
'no-unsafe-finally': 2, // 禁止finally出现控制流语句return、throw等因为这会导致try...catch捕获不到
'valid-typeof': 2, // 强制 typeof 表达式与有效的字符串进行比较
// auto format options
'prefer-const': 0, // 禁用声明自动化
'no-extra-parens': 0, // 允许函数周围出现不明括号
'no-extra-semi': 2, // 禁止不必要的分号
// curly: ['error', 'multi'], // if、else、for、while 语句单行代码时不使用大括号
'dot-notation': 0, // 允许使用点号或方括号来访问对象属性
'dot-location': ['error', 'property'], // 点操作符位置,要求跟随下一行
'no-else-return': 2, // 禁止if中有return后又else
'no-implicit-coercion': [2, { allow: ['!!', '~', '+'] }], // 禁止隐式转换allow字段内符号允许
'no-trailing-spaces': 1, //一行结束后面不要有空格
'no-multiple-empty-lines': [1, { 'max': 1 }], // 空行最多不能超过1行
'no-useless-return': 2,
'wrap-iife': 0, // 允许自调用函数
'yoda': 0, // 允许yoda语句
'strict': 0, // 允许strict
'no-undef-init': 0, // 允许将变量初始化为undefined
'prefer-promise-reject-errors': 0, // 允许使用非 Error 对象作为 Promise 拒绝的原因
'consistent-return': 0, // 允许函数不使用return
'no-new': 0, // 允许单独new
'no-restricted-syntax': 0, // 允许特定的语法
'no-plusplus': 0,
'import/extensions': 0, // 忽略扩展名
'global-require': 0,
'no-return-assign': 0
}
}

View File

@ -1,13 +0,0 @@
FROM node:16.15.0-alpine3.14
ARG TARGET_DIR=/easynode-server
WORKDIR ${TARGET_DIR}
RUN yarn config set registry https://registry.npm.taobao.org
COPY package.json ${TARGET_DIR}
COPY yarn.lock ${TARGET_DIR}
RUN yarn
COPY . ${TARGET_DIR}
ENV HOST 0.0.0.0
EXPOSE 8082
EXPOSE 8083
EXPOSE 22022
CMD ["npm", "run", "server"]

31
server/README.md Normal file
View File

@ -0,0 +1,31 @@
# 面板服务端
- 基于Koa
## 遇到的问题
> MaxListenersExceededWarning: Possible EventEmitter memory leak detected. 11 input listeners added to [Socket]. Use emitter.setMaxListeners() to increase limit
- ssh连接数过多(默认最多11个)
- 每次连接新建一个vps实例断开则销毁
> Error signing data with key: error:0D07209B:asn1 encoding routines:ASN1_get_object:too long
- 经比对ssh的rsa密钥在前端往后端的存储过程中丢失了部分字符
> 获取客户端信息跨域请求客户端系统信息建立ws socket实时更新网络
- 问题服务端前端套上https后前端无法请求客户端(http)的信息, 也无法建立ws socket连接(原因是https下无法建立http/ws协议请求)
- 方案1: 所有客户端与服务端通信,再全部由服务端与前端通信(考虑:服务端/客户端性能问题). Node实现http+https||nginx转发实现https
- 方案2: 给所有客户端加上https(客户端只有ip没法给个人ip签订证书)
## 构建运行包
### 坑
> log4js: 该module使用到了fs.mkdir()等读写apipkg打包后的环境不支持设置保存日志的目录需使用process.cwd()】
> win闪退: 在linux机器上构建可查看输出日志
## 客户端
> **构建客户端服务, 后台运行** `nohup ./easynode-server &`
> 功能服务器基本信息【ssh信息保存在主服务器】

View File

@ -1,30 +1,26 @@
const path = require('path')
const fs = require('fs')
const getCertificate =() => {
try {
return {
cert: fs.readFileSync(path.join(__dirname, './pem/cert.pem')),
key: fs.readFileSync(path.join(__dirname, './pem/key.pem'))
}
} catch (error) {
return null
}
}
consola.info('debug日志', process.env.DEBUG === '1' ? '开启' : '关闭')
module.exports = {
domain: '', // 域名(必须配置, 跨域使用[不配置将所有域名可访问api])
httpPort: 8082,
httpsPort: 8083,
clientPort: 22022, // 勿更改
certificate: getCertificate(),
uploadDir: path.join(process.cwd(),'./app/static/upload'),
staticDir: path.join(process.cwd(),'./app/static'),
sshRecordPath: path.join(__dirname,'./storage/ssh-record.json'),
keyPath: path.join(__dirname,'./storage/key.json'),
hostListPath: path.join(__dirname,'./storage/host-list.json'),
defaultClientPort: 22022,
uploadDir: path.join(process.cwd(),'app/db'),
staticDir: path.join(process.cwd(),'app/static'),
sftpCacheDir: path.join(process.cwd(),'app/socket/sftp-cache'),
credentialsDBPath: path.join(process.cwd(),'app/db/credentials.db'),
keyDBPath: path.join(process.cwd(),'app/db/key.db'),
hostListDBPath: path.join(process.cwd(),'app/db/host.db'),
groupConfDBPath: path.join(process.cwd(),'app/db/group.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'),
logDBPath: path.join(process.cwd(),'app/db/log.db'),
plusDBPath: path.join(process.cwd(),'app/db/plus.db'),
apiPrefix: '/api/v1',
logConfig: {
outDir: path.join(process.cwd(),'./app/logs'),
flag: false // 是否记录日志
recordLog: process.env.DEBUG === '1' // 是否记录日志
}
}

View File

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

View File

@ -1,6 +0,0 @@
[
{
"host": "localhost",
"name": "test"
}
]

View File

@ -1,7 +0,0 @@
{
"pwd": "admin",
"jwtExpires": "1h",
"commonKey": "",
"publicKey": "",
"privateKey": ""
}

View File

@ -1 +0,0 @@
[]

View File

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

View File

@ -1,68 +0,0 @@
const { readHostList, writeHostList, readSSHRecord, writeSSHRecord } = require('../utils')
function getHostList({ res }) {
const data = readHostList()
res.success({ data })
}
function saveHost({ res, request }) {
let { body: { host: newHost, name } } = request
if(!newHost || !name) return res.fail({ msg: '参数错误' })
let hostList = readHostList()
if(hostList.some(({ host }) => host === newHost)) return res.fail({ msg: `主机${ newHost }已存在` })
hostList.push({ host: newHost, name })
writeHostList(hostList)
res.success()
}
function updateHost({ res, request }) {
let { body: { host: newHost, name: newName, oldHost } } = request
if(!newHost || !newName || !oldHost) return res.fail({ msg: '参数错误' })
let hostList = readHostList()
if(!hostList.some(({ host }) => host === oldHost)) return res.fail({ msg: `主机${ newHost }不存在` })
let targetIdx = hostList.findIndex(({ host }) => host === oldHost)
hostList.splice(targetIdx, 1, { name: newName, host: newHost })
writeHostList(hostList)
res.success()
}
function removeHost({ res, request }) {
let { body: { host } } = request
let hostList = readHostList()
let hostIdx = hostList.findIndex(item => item.host === host)
if(hostIdx === -1) return res.fail({ msg: `${ host }不存在` })
hostList.splice(hostIdx, 1)
writeHostList(hostList)
// 查询是否存在ssh记录
let sshRecord = readSSHRecord()
let sshIdx = sshRecord.findIndex(item => item.host === host)
let flag = sshIdx !== -1
if(flag) sshRecord.splice(sshIdx, 1)
writeSSHRecord(sshRecord)
res.success({ data: `${ host }已移除, ${ flag ? '并移除ssh记录' : '' }` })
}
function updateHostSort({ res, request }) {
let { body: { list } } = request
if(!list) return res.fail({ msg: '参数错误' })
let hostList = readHostList()
if(hostList.length !== list.length) return res.fail({ msg: '失败: host数量不匹配' })
let sortResult = []
for (let i = 0; i < list.length; i++) {
const curHost = list[i]
let temp = hostList.find(({ host }) => curHost.host === host)
if(!temp) return res.fail({ msg: `查找失败: ${ curHost.name }` })
sortResult.push(temp)
}
writeHostList(sortResult)
res.success({ msg: 'success' })
}
module.exports = {
getHostList,
saveHost,
updateHost,
removeHost,
updateHostSort
}

View File

@ -0,0 +1,116 @@
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 }) {
let data = await hostListDB.findAsync({})
data?.sort((a, b) => Number(b.index || 0) - Number(a.index || 0))
for (const item of data) {
try {
let { authType, _id: id, credential } = item
if (credential) credential = await AESDecryptAsync(credential)
const isConfig = Boolean(authType && item[authType])
Object.assign(item, { id, isConfig, password: '', privateKey: '', credential })
} catch (error) {
consola.error('getHostList error: ', error.message)
}
}
res.success({ data })
}
async function addHost({ res, request }) {
let { body } = request
if (!body.name || !body.host) return res.fail({ msg: 'missing params: name or host' })
let newRecord = { ...body }
const { authType, tempKey } = newRecord
if (newRecord[authType] && tempKey) {
const clearTempKey = await RSADecryptAsync(tempKey)
const clearSSHKey = await AESDecryptAsync(newRecord[authType], clearTempKey)
newRecord[authType] = await AESEncryptAsync(clearSSHKey)
}
await hostListDB.insertAsync(newRecord)
res.success()
}
async function updateHost({ res, request }) {
let {
body
} = request
if (typeof body !== 'object') return res.fail({ msg: '参数错误' })
const updateFiled = { ...body }
const { id, authType, tempKey } = updateFiled
if (authType && updateFiled[authType]) {
const clearTempKey = await RSADecryptAsync(tempKey)
const clearSSHKey = await AESDecryptAsync(updateFiled[authType], clearTempKey)
updateFiled[authType] = await AESEncryptAsync(clearSSHKey)
delete updateFiled.tempKey
} else {
delete updateFiled.authType
delete updateFiled.password
delete updateFiled.privateKey
delete updateFiled.credential
}
console.log('updateFiled: ', updateFiled)
await hostListDB.updateAsync({ _id: id }, { $set: { ...updateFiled } })
res.success({ msg: '修改成功' })
}
async function batchUpdateHost({ res, request }) {
let { updateHosts } = (await decryptAndExecuteAsync(path.join(__dirname, 'plus.js'))) || {}
if (updateHosts) {
await updateHosts({ res, request })
} else {
return res.fail({ data: false, msg: 'Plus专属功能!' })
}
}
async function removeHost({ res, request }) {
let { body: { ids } } = request
if (!Array.isArray(ids)) return res.fail({ msg: '参数错误' })
const numRemoved = await hostListDB.removeAsync({ _id: { $in: ids } }, { multi: true })
res.success({ data: `已移除,数量: ${ numRemoved }` })
}
async function importHost({ res, request }) {
let { body: { importHost, isEasyNodeJson = false } } = request
if (!Array.isArray(importHost)) return res.fail({ msg: '参数错误' })
let hostList = await hostListDB.findAsync({})
// 考虑到批量导入可能会重复太多,先过滤已存在的host:port
let hostListSet = new Set(hostList.map(({ host, port }) => `${ host }:${ port }`))
let newHostList = importHost.filter(({ host, port }) => !hostListSet.has(`${ host }:${ port }`))
let newHostListLen = newHostList.length
if (newHostListLen === 0) return res.fail({ msg: '导入的实例已存在' })
if (isEasyNodeJson) {
newHostList = newHostList.map((item) => {
item.credential = ''
item.isConfig = false
delete item.id
delete item.isConfig
return item
})
} else {
let extraFiels = {
expired: null, expiredNotify: false, group: 'default', consoleUrl: '', remark: '',
authType: 'privateKey', password: '', privateKey: '', credential: '', command: ''
}
newHostList = newHostList.map((item, index) => {
item.port = Number(item.port) || 0
item.index = newHostListLen - index
return Object.assign(item, { ...extraFiels })
})
}
await hostListDB.insertAsync(newHostList)
res.success({ data: { len: newHostList.length } })
}
module.exports = {
getHostList,
addHost,
updateHost,
removeHost,
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

@ -0,0 +1,56 @@
const path = require('path')
const decryptAndExecuteAsync = require('../utils/decrypt-file')
const { sendServerChan, sendEmail } = require('../utils/notify')
const { NotifyConfigDB, NotifyDB } = require('../utils/db-class')
const notifyDB = new NotifyDB().getInstance()
const notifyConfigDB = new NotifyConfigDB().getInstance()
async function getNotifyConfig({ res }) {
const data = await notifyConfigDB.findOneAsync({})
return res.success({ data })
}
async function updateNotifyConfig({ res, request }) {
let { body: { noticeConfig } } = request
let { type } = noticeConfig
try {
// console.log('noticeConfig: ', noticeConfig[type])
switch (type) {
case 'sct':
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 })
return res.success({ msg: '测试通过 | 保存成功' })
} catch (error) {
return res.fail({ msg: error.message })
}
}
async function getNotifyList({ res }) {
const data = await notifyDB.findAsync({})
res.success({ data })
}
async function updateNotifyList({ res, request }) {
let { body: { type, sw } } = request
if (!([true, false].includes(sw))) return res.fail({ msg: `Error type for sw${ sw }, must be Boolean` })
await notifyDB.updateAsync({ type }, { $set: { sw } })
res.success()
}
module.exports = {
getNotifyConfig,
updateNotifyConfig,
getNotifyList,
updateNotifyList
}

View File

@ -0,0 +1,27 @@
const { OnekeyDB } = require('../utils/db-class')
const onekeyDB = new OnekeyDB().getInstance()
async function getOnekeyRecord({ res }) {
let data = await onekeyDB.findAsync({})
data = data.map(item => {
return { ...item, id: item._id }
})
data?.sort((a, b) => Number(b.date) - Number(a.date))
res.success({ data })
}
const removeOnekeyRecord = async ({ res, request }) => {
let { body: { ids } } = request
if (ids === 'ALL') {
await onekeyDB.removeAsync({}, { multi: true })
res.success({ data: '移除全部成功' })
} else {
await onekeyDB.removeAsync({ _id: { $in: ids } })
res.success({ data: '移除成功' })
}
}
module.exports = {
getOnekeyRecord,
removeOnekeyRecord
}

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

@ -0,0 +1,73 @@
const path = require('path')
const decryptAndExecuteAsync = require('../utils/decrypt-file')
const { randomStr } = require('../utils/tools')
const { ScriptsDB } = require('../utils/db-class')
const localShellJson = require('../config/shell.json')
const scriptsDB = new ScriptsDB().getInstance()
let localShell = JSON.parse(JSON.stringify(localShellJson)).map((item) => {
return { ...item, id: randomStr(10), index: '--', description: item.description + '|内置脚本' }
})
async function getScriptList({ res }) {
let data = await scriptsDB.findAsync({})
data = data.map(item => {
return { ...item, id: item._id }
})
data?.sort((a, b) => Number(b.index || 0) - Number(a.index || 0))
data.push(...localShell)
res.success({ data })
}
async function getLocalScriptList({ res }) {
res.success({ data: localShell })
}
const addScript = async ({ res, request }) => {
let { body: { name, description, command, index } } = request
if (!name || !command) return res.fail({ data: false, msg: '参数错误' })
index = Number(index) || 0
let record = { name, description, command, index }
await scriptsDB.insertAsync(record)
res.success({ data: '添加成功' })
}
const updateScriptList = async ({ res, request }) => {
let { params: { id } } = request
let { body: { name, description, command, index } } = request
if (!name || !command) return res.fail({ data: false, msg: '参数错误' })
await scriptsDB.updateAsync({ _id: id }, { name, description, command, index })
res.success({ data: '修改成功' })
}
const removeScript = async ({ res, request }) => {
let { params: { id } } = request
await scriptsDB.removeAsync({ _id: id })
res.success({ data: '移除成功' })
}
const batchRemoveScript = async ({ res, request }) => {
let { body: { ids } } = request
if (!Array.isArray(ids)) return res.fail({ msg: '参数错误' })
const numRemoved = await scriptsDB.removeAsync({ _id: { $in: ids } }, { multi: true })
res.success({ data: `批量移除成功,数量: ${ numRemoved }` })
}
const importScript = async ({ res, request }) => {
let { impScript } = (await decryptAndExecuteAsync(path.join(__dirname, 'plus.js'))) || {}
if (impScript) {
await impScript({ res, request })
} else {
return res.fail({ data: false, msg: 'Plus专属功能!' })
}
}
module.exports = {
addScript,
getScriptList,
getLocalScriptList,
updateScriptList,
removeScript,
batchRemoveScript,
importScript
}

View File

@ -1,57 +0,0 @@
const { readSSHRecord, writeSSHRecord, AESEncrypt } = require('../utils')
const updateSSH = async ({ res, request }) => {
let { body: { host, port, username, type, password, privateKey, randomKey, command } } = request
let record = { host, port, username, type, password, privateKey, randomKey, command }
if(!host || !port || !username || !type || !randomKey) return res.fail({ data: false, msg: '参数错误' })
// 再做一次对称加密(方便ssh连接时解密)
record.randomKey = AESEncrypt(randomKey)
let sshRecord = readSSHRecord()
let idx = sshRecord.findIndex(item => item.host === host)
if(idx === -1)
sshRecord.push(record)
else
sshRecord.splice(idx, 1, record)
writeSSHRecord(sshRecord)
console.log('新增凭证:', host)
res.success({ data: '保存成功' })
}
const removeSSH = async ({ res, request }) => {
let { body: { host } } = request
let sshRecord = readSSHRecord()
let idx = sshRecord.findIndex(item => item.host === host)
if(idx === -1) return res.fail({ msg: '凭证不存在' })
sshRecord.splice(idx, 1)
console.log('移除凭证:', host)
writeSSHRecord(sshRecord)
res.success({ data: '移除成功' })
}
const existSSH = async ({ res, request }) => {
let { body: { host } } = request
let sshRecord = readSSHRecord()
let idx = sshRecord.findIndex(item => item.host === host)
console.log('查询凭证:', host)
if(idx === -1) return res.success({ data: false }) // host不存在
res.success({ data: true }) // 存在
}
const getCommand = async ({ res, request }) => {
let { host } = request.query
if(!host) return res.fail({ data: false, msg: '参数错误' })
let sshRecord = readSSHRecord()
let record = sshRecord.find(item => item.host === host)
console.log('查询登录后执行的指令:', host)
if(!record) return res.fail({ data: false, msg: 'host not found' }) // host不存在
const { command } = record
if(!command) return res.success({ data: false }) // command不存在
res.success({ data: command }) // 存在
}
module.exports = {
updateSSH,
removeSSH,
existSSH,
getCommand
}

View File

@ -0,0 +1,109 @@
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 }) {
let data = await credentialsDB.findAsync({})
data = data?.map(item => {
const { name, authType, _id: id, date } = item
return { id, name, authType, privateKey: '', password: '', date }
}) || []
data.sort((a, b) => b.date - a.date)
res.success({ data })
}
const addSSH = async ({ res, request }) => {
let { body: { name, authType, password, privateKey, tempKey } } = request
let record = { name, authType, password, privateKey }
if (!name || !record[authType]) return res.fail({ data: false, msg: '参数错误' })
let count = await credentialsDB.countAsync({ name })
if (count > 0) return res.fail({ data: false, msg: '已存在同名凭证' })
const clearTempKey = await RSADecryptAsync(tempKey)
console.log('clearTempKey:', clearTempKey)
const clearSSHKey = await AESDecryptAsync(record[authType], clearTempKey)
// console.log(`${ authType }原密文: `, clearSSHKey)
record[authType] = await AESEncryptAsync(clearSSHKey)
// console.log(`${ authType }__commonKey加密存储: `, record[authType])
await credentialsDB.insertAsync({ ...record, date: Date.now() })
consola.info('添加凭证:', name)
res.success({ data: '保存成功' })
}
const updateSSH = async ({ res, request }) => {
let { body: { id, name, authType, password, privateKey, date, tempKey } } = request
let record = { name, authType, password, privateKey, date }
if (!id || !name) return res.fail({ data: false, msg: '请输入凭据名称' })
let oldRecord = await credentialsDB.findOneAsync({ _id: id })
if (!oldRecord) return res.fail({ data: false, msg: '凭证不存在' })
// 判断原记录是否存在当前更新记录的认证方式
if (!oldRecord[authType] && !record[authType]) return res.fail({ data: false, msg: `请输入${ authType === 'password' ? '密码' : '密钥' }` })
if (!record[authType] && oldRecord[authType]) {
record[authType] = oldRecord[authType]
} else {
const clearTempKey = await RSADecryptAsync(tempKey)
console.log('clearTempKey:', clearTempKey)
const clearSSHKey = await AESDecryptAsync(record[authType], clearTempKey)
// console.log(`${ authType }原密文: `, clearSSHKey)
record[authType] = await AESEncryptAsync(clearSSHKey)
// console.log(`${ authType }__commonKey加密存储: `, record[authType])
}
await credentialsDB.updateAsync({ _id: id }, record)
consola.info('修改凭证:', name)
res.success({ data: '保存成功' })
}
const removeSSH = async ({ res, request }) => {
let { params: { id } } = request
let count = await credentialsDB.countAsync({ _id: id })
if (count === 0) return res.fail({ msg: '凭证不存在' })
// 将删除的凭证id从host中删除
let hostList = await hostListDB.findAsync({})
if (Array.isArray(hostList) && hostList.length > 0) {
for (let host of hostList) {
let { credential } = host
credential = await AESDecryptAsync(credential)
if (credential === id) {
host.credential = ''
await hostListDB.updateAsync({ _id: host._id }, host)
}
}
}
await hostListDB.compactDatafileAsync()
consola.info('移除凭证:', id)
await credentialsDB.removeAsync({ _id: id })
res.success({ data: '移除成功' })
}
const getCommand = async ({ res, request }) => {
let { hostId } = request.query
if (!hostId) return res.fail({ data: false, msg: '参数错误' })
let hostInfo = await hostListDB.findAsync({})
let record = hostInfo?.find(item => item._id === hostId)
consola.info('查询登录后执行的指令:', hostId)
if (!record) return res.fail({ data: false, msg: 'host not found' })
const { command } = record
if (!command) return res.success({ data: false })
res.success({ data: command })
}
const decryptPrivateKey = async ({ res, request }) => {
let { dePrivateKey } = (await decryptAndExecuteAsync(path.join(__dirname, 'plus.js'))) || {}
if (dePrivateKey) {
await dePrivateKey({ res, request })
} else {
return res.fail({ data: false, msg: 'Plus专属功能无法解密私钥!' })
}
}
module.exports = {
getSSHList,
addSSH,
updateSSH,
removeSSH,
getCommand,
decryptPrivateKey
}

View File

@ -1,67 +1,226 @@
const jwt = require('jsonwebtoken')
const { getNetIPInfo, readKey, writeKey, RSADecrypt, AESEncrypt, SHA1Encrypt } = require('../utils')
const axios = require('axios')
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 getpublicKey = ({ res }) => {
let { publicKey: data } = readKey()
if(!data) return res.fail({ msg: 'publicKey not found, Try to restart the server', status: 500 })
const keyDB = new KeyDB().getInstance()
const logDB = new LogDB().getInstance()
const plusDB = new PlusDB().getInstance()
const getpublicKey = async ({ res }) => {
let { publicKey: data } = await keyDB.findOneAsync({})
if (!data) return res.fail({ msg: 'publicKey not found, Try to restart the server', status: 500 })
res.success({ data })
}
const generateTokenAndRecordIP = async (clientIp) => {
console.log('密码校验成功, 准备生成token')
let { commonKey, jwtExpires } = readKey()
let token = jwt.sign({ date: Date.now() }, commonKey, { expiresIn: jwtExpires }) // 生成token
token = AESEncrypt(token) // 对称加密token后再传输给前端
console.log('aes对称加密token', token)
// 记录客户端登录IP用于判断是否异地(只保留最近10条)
const localNetIPInfo = await getNetIPInfo(clientIp)
global.loginRecord.unshift(localNetIPInfo)
if(global.loginRecord.length > 10) global.loginRecord = global.loginRecord.slice(0, 10)
return { token, jwtExpires }
}
let timer = null
const allowErrCount = 5 // 允许错误的次数
const forbidTimer = 60 * 5 // 禁止登录时间
let loginErrCount = 0 // 每一轮的登录错误次数
let loginErrTotal = 0 // 总的登录错误次数
let loginCountDown = forbidTimer
let forbidLogin = false
const login = async ({ res, request }) => {
let { body: { ciphertext }, ip: clientIp } = request
if(!ciphertext) return res.fail({ msg: '参数错误' })
let { body: { loginName, ciphertext, jwtExpires, mfa2Token }, ip: clientIp } = request
if (!loginName && !ciphertext) return res.fail({ msg: '请求非法!' })
if (forbidLogin) return res.fail({ msg: `禁止登录! 倒计时[${ loginCountDown }s]后尝试登录或重启面板服务` })
loginErrCount++
loginErrTotal++
if (loginErrCount >= allowErrCount) {
const { ip, country, city } = await getNetIPInfo(clientIp)
// 异步发送通知&禁止登录
sendNoticeAsync('err_login', '登录错误提醒', `错误登录次数: ${ loginErrTotal }\n地点:${ country + city }\nIP: ${ ip }`)
forbidLogin = true
loginErrCount = 0
// forbidTimer秒后解禁
setTimeout(() => {
forbidLogin = false
}, loginCountDown * 1000)
// 计算登录倒计时
timer = setInterval(() => {
if (loginCountDown <= 0) {
clearInterval(timer)
timer = null
loginCountDown = forbidTimer
return
}
loginCountDown--
}, 1000)
}
// 登录流程
try {
console.log('ciphertext', ciphertext)
let password = RSADecrypt(ciphertext)
let { pwd } = readKey()
if(password === 'admin' && pwd === 'admin') {
const { token, jwtExpires } = await generateTokenAndRecordIP(clientIp)
return res.success({ data: { token, jwtExpires }, msg: '登录成功,请及时修改默认密码' })
let loginPwd = await RSADecryptAsync(ciphertext)
let { user, pwd, enableMFA2, secret } = await keyDB.findOneAsync({})
if (enableMFA2) {
const isValid = speakeasy.totp.verify({ secret, encoding: 'base32', token: mfa2Token, window: 1 })
console.log('MFA2 verfify:', isValid)
if (!isValid) return res.fail({ msg: '验证失败' })
}
password = SHA1Encrypt(password)
if(password !== pwd) return res.fail({ msg: '密码错误' })
const { token, jwtExpires } = await generateTokenAndRecordIP(clientIp)
if (loginName === user && loginPwd === 'admin' && pwd === 'admin') {
const token = await beforeLoginHandler(clientIp, jwtExpires)
return res.success({ data: { token, jwtExpires }, msg: '登录成功,请及时修改默认用户名和密码' })
}
loginPwd = SHA1Encrypt(loginPwd)
if (loginName !== user || loginPwd !== pwd) return res.fail({ msg: `用户名或密码错误 ${ loginErrTotal }/${ allowErrCount }` })
const token = await beforeLoginHandler(clientIp, jwtExpires)
return res.success({ data: { token, jwtExpires }, msg: '登录成功' })
} catch (error) {
console.log('解密失败:', error)
res.fail({ msg: '解密失败, 请查看服务端日志' })
console.log('登录失败:', error.message)
res.fail({ msg: '登录失败, 请查看服务端日志' })
}
}
const beforeLoginHandler = async (clientIp, jwtExpires) => {
loginErrCount = loginErrTotal = 0 // 登录成功, 清空错误次数
// consola.success('登录成功, 准备生成token', new Date())
// 生产token
let { commonKey } = await keyDB.findOneAsync({})
let token = jwt.sign({ date: Date.now() }, commonKey, { expiresIn: jwtExpires }) // 生成token
token = await AESEncryptAsync(token) // 对称加密token后再传输给前端
// 记录客户端登录IP(用于判断是否异地且只保留最近10<31><30>)
const clientIPInfo = await getNetIPInfo(clientIp)
const { ip, country, city } = clientIPInfo || {}
consola.info('登录成功:', new Date(), { ip, country, city })
// 登录通知
sendNoticeAsync('login', '登录提醒', `地点:${ country + city }\nIP: ${ ip }`)
await logDB.insertAsync({ ip, country, city, date: Date.now(), type: 'login' })
return token
}
const updatePwd = async ({ res, request }) => {
let { body: { oldPwd, newPwd } } = request
let rsaOldPwd = RSADecrypt(oldPwd)
let { body: { oldLoginName, oldPwd, newLoginName, newPwd } } = request
let rsaOldPwd = await RSADecryptAsync(oldPwd)
oldPwd = rsaOldPwd === 'admin' ? 'admin' : SHA1Encrypt(rsaOldPwd)
let keyObj = readKey()
if(oldPwd !== keyObj.pwd) return res.fail({ data: false, msg: '旧密码校验失败' })
let keyObj = await keyDB.findOneAsync({})
let { user, pwd } = keyObj
if (oldLoginName !== user || oldPwd !== pwd) return res.fail({ data: false, msg: '原用户名或密码校验失败' })
// 旧密钥校验通过,加密保存新密码
newPwd = SHA1Encrypt(RSADecrypt(newPwd))
newPwd = await RSADecryptAsync(newPwd) === 'admin' ? 'admin' : SHA1Encrypt(await RSADecryptAsync(newPwd))
keyObj.user = newLoginName
keyObj.pwd = newPwd
writeKey(keyObj)
await keyDB.updateAsync({}, keyObj)
sendNoticeAsync('updatePwd', '用户密码修改提醒', `原用户名:${ user }\n更新用户名: ${ newLoginName }`)
res.success({ data: true, msg: 'success' })
}
const getLoginRecord = async ({ res }) => {
res.success({ data: global.loginRecord, msg: 'success' })
const getEasynodeVersion = async ({ res }) => {
try {
// 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')
res.success({ data, msg: 'success' })
} catch (error) {
consola.error('Failed to fetch Easynode latest version:', error)
res.fail({ msg: 'Failed to fetch Easynode latest version' })
}
}
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 = {
login,
getpublicKey,
updatePwd,
getLoginRecord
getEasynodeVersion,
getMFA2Status,
getMFA2Code,
enableMFA2,
disableMFA2,
getPlusInfo,
getPlusDiscount,
getPlusConf,
updatePlusKey
}

104
server/app/db.js Normal file
View File

@ -0,0 +1,104 @@
const NodeRSA = require('node-rsa')
const { randomStr } = require('./utils/tools')
const { AESEncryptAsync } = require('./utils/encrypt')
const { KeyDB, GroupDB, NotifyDB, NotifyConfigDB } = require('./utils/db-class')
async function initKeyDB() {
const keyDB = new KeyDB().getInstance()
let count = await keyDB.countAsync({})
if (count !== 0) return consola.info('公私钥已存在[重新生成会导致已保存的ssh密钥信息失效]')
let newConfig = {
user: 'admin',
pwd: 'admin',
commonKey: randomStr(16),
publicKey: '',
privateKey: ''
}
await keyDB.insertAsync(newConfig)
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')
newConfig.privateKey = await AESEncryptAsync(privateKey, newConfig.commonKey) // 加密私钥
newConfig.publicKey = publicKey // 公开公钥
await keyDB.updateAsync({}, { $set: newConfig }, { upsert: true })
consola.info('Task: 已生成新的非对称加密公私钥')
}
async function initGroupDB() {
const groupDB = new GroupDB().getInstance()
let count = await groupDB.countAsync({})
if (count === 0) {
consola.log('初始化groupDB✔')
const defaultData = [{ '_id': 'default', 'name': '默认分组', 'index': 0 }]
return groupDB.insertAsync(defaultData)
}
return Promise.resolve()
}
async function initNotifyDB() {
const notifyDB = new NotifyDB().getInstance()
let count = await notifyDB.countAsync({})
if (count !== 0) return
consola.log('初始化notifyDB✔')
let defaultData = [{
'type': 'login',
'desc': '登录面板提醒',
'sw': false
}, {
'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 = {
type: 'sct',
sct: {
sendKey: ''
},
email: {
service: 'QQ',
user: '',
pass: ''
},
tg: {
token: '',
chatId: ''
}
}
if (notifyConfig) {
await notifyConfigDB.removeAsync({ _id: notifyConfig._id })
delete notifyConfig._id
return notifyConfigDB.insertAsync(Object.assign({}, defaultData, notifyConfig))
}
return notifyConfigDB.insertAsync(defaultData)
}
module.exports = async () => {
await initKeyDB()
await initNotifyDB()
await initGroupDB()
await initNotifyConfigDB()
}

View File

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

View File

@ -1,10 +1,13 @@
const { httpServer, httpsServer, clientHttpServer } = require('./server')
const initLocal = require('./init')
const { httpServer } = require('./server')
const initDB = require('./db')
const scheduleJob = require('./schedule')
const getLicenseInfo = require('./utils/get-plus')
initLocal()
async function main() {
await initDB()
httpServer()
scheduleJob()
getLicenseInfo()
}
httpServer()
httpsServer()
clientHttpServer()
main()

View File

@ -1,21 +1,20 @@
const { verifyAuth } = require('../utils')
const { apiPrefix } = require('../config')
const { verifyAuthSync } = require('../utils/verify-auth')
let whitePath = [
'/login',
'/get-pub-pem'
].map(item => (apiPrefix + item))
console.log('路由白名单:', whitePath)
consola.info('路由白名单:', whitePath)
const useAuth = async ({ request, res }, next) => {
const { path, headers: { token } } = request
console.log('path: ', path)
// console.log('token: ', token)
if(whitePath.includes(path)) return next()
if(!token) return res.fail({ msg: '未登录', status: 403 })
consola.info('verify path: ', path)
if (whitePath.includes(path)) return next()
if (!token) return res.fail({ msg: '未登录', status: 403 })
// 验证token
const { code, msg } = verifyAuth(token, request.ip)
switch(code) {
const { code, msg } = await verifyAuthSync(token, request.ip)
switch (code) {
case 1:
return await next()
case -1:

View File

@ -1,4 +1,4 @@
const koaBody = require('koa-body')
const { koaBody } = require('koa-body')
const { uploadDir } = require('../config')
module.exports = koaBody({
@ -7,10 +7,6 @@ module.exports = koaBody({
uploadDir, // 上传目录
keepExtensions: true, // 保持文件的后缀
multipart: true, // 多文件上传
maxFieldsSize: 2 * 1024 * 1024, // 文件上传大小 单位B
onFileBegin: (name, file) => { // 文件上传前的设置
// console.log(`name: ${name}`)
// console.log(file)
}
maxFieldsSize: 2 * 1024 * 1024 // 文件上传大小 单位B
}
})

View File

@ -1,3 +1,4 @@
// 响应压缩模块,自适应头部压缩方式
const compress = require('koa-compress')
const options = { threshold: 2048 }

View File

@ -1,10 +1,9 @@
const cors = require('@koa/cors')
const { domain } = require('../config')
// 跨域处理
const useCors = cors({
origin: ({ req }) => {
return domain || req.headers.origin
return req.headers.origin
},
credentials: true,
allowMethods: [ 'GET', 'HEAD', 'PUT', 'POST', 'DELETE', 'PATCH' ]

View File

@ -1,6 +1,7 @@
const ipFilter = require('./ipFilter') // IP过滤
const responseHandler = require('./response') // 统一返回格式, 错误捕获
const useAuth = require('./auth') // 鉴权
const useCors = require('./cors') // 处理跨域
// const useCors = require('./cors') // 处理跨域[暂时禁止]
const useLog = require('./log4') // 记录日志,需要等待路由处理完成,所以得放路由前
const useKoaBody = require('./body') // 处理body参数 【请求需先走该中间件】
const { useRoutes, useAllowedMethods } = require('./router') // 路由管理
@ -8,12 +9,12 @@ const useStatic = require('./static') // 静态目录
const compress = require('./compress') // br/gzip压缩
const history = require('./history') // vue-router的history模式
// 注意注册顺序
module.exports = [
ipFilter,
compress,
history,
useStatic, // staic先注册不然会被jwt拦截
useCors,
// useCors,
responseHandler,
useKoaBody, // 先处理bodylog和router都要用到
useLog, // 日志记录开始【该module使用到了fs.mkdir()等读写api 设置保存日志的目录需使用process.cwd()】

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

@ -1,29 +1,30 @@
const log4js = require('log4js')
const { outDir, flag } = require('../config').logConfig
const { outDir, recordLog } = require('../config').logConfig
log4js.configure({
appenders: {
// 控制台输出
out: {
console: {
type: 'stdout',
layout: {
type: 'colored'
type: 'pattern',
pattern: '%[%d{yyyy-MM-dd hh:mm:ss.SSS} [%p] -%] %m'
}
},
// 保存日志文件
cheese: {
type: 'file',
maxLogSize: 512*1024, // unit: bytes 1KB = 1024bytes
filename: `${ outDir }/receive.log`
maxLogSize: 10 * 1024 * 1024, // unit: bytes 1KB = 1024bytes
filename: `${ outDir }/receive.log`,
backups: 10,
compress: true,
keepFileExt: true
}
},
categories: {
default: {
appenders: [ 'out', 'cheese' ], // 配置
level: 'info' // 只输出info以上级别的日志
appenders: ['console', 'cheese'],
level: 'debug'
}
}
// pm2: true
})
const logger = log4js.getLogger()
@ -42,7 +43,7 @@ const useLog = () => {
}
await next() // 等待路由处理完成,再开始记录日志
// 是否记录日志
if (flag) {
if (recordLog) {
const { status, params } = ctx
data.status = status
data.params = params
@ -55,4 +56,7 @@ const useLog = () => {
}
}
module.exports = useLog()
module.exports = useLog()
// 可以先测试一下日志是否正常工作
logger.info('日志系统启动')

View File

@ -22,7 +22,8 @@ const responseHandler = async (ctx, next) => {
try {
await next() // 每个中间件都需等待next完成调用不然会返回404给前端!!!
} catch (err) {
console.log('中间件错误:', err)
console.dir(err)
consola.error('中间件错误:', err)
if (err.status)
ctx.res.fail({ status: err.status, msg: err.message }) // 自己主动抛出的错误 throwError
else

View File

@ -1,6 +1,14 @@
const koaStatic = require('koa-static')
const { staticDir } = require('../config')
const useStatic = koaStatic(staticDir)
const useStatic = koaStatic(staticDir, {
maxage: 1000 * 60 * 60 * 24 * 30,
gzip: true,
setHeaders: (res, path) => {
if (path && path.endsWith('.html')) {
res.setHeader('Cache-Control', 'max-age=0')
}
}
})
module.exports = useStatic

View File

@ -1,29 +1,45 @@
const { updateSSH, removeSSH, existSSH, getCommand } = require('../controller/ssh-info')
const { getHostList, saveHost, updateHost, removeHost, updateHostSort } = require('../controller/host-info')
const { login, getpublicKey, updatePwd, getLoginRecord } = require('../controller/user')
const { getSSHList, addSSH, updateSSH, removeSSH, getCommand, decryptPrivateKey } = require('../controller/ssh')
const { getHostList, addHost, updateHost, batchUpdateHost, removeHost, importHost } = require('../controller/host')
const { login, getpublicKey, updatePwd, getEasynodeVersion, getMFA2Status, getMFA2Code, enableMFA2, disableMFA2, getPlusInfo, getPlusDiscount, getPlusConf, updatePlusKey } = require('../controller/user')
const { getNotifyConfig, updateNotifyConfig, getNotifyList, updateNotifyList } = require('../controller/notify')
const { getGroupList, addGroupList, updateGroupList, removeGroup } = require('../controller/group')
const { getScriptList, getLocalScriptList, addScript, updateScriptList, removeScript, batchRemoveScript, importScript } = require('../controller/scripts')
const { getOnekeyRecord, removeOnekeyRecord } = require('../controller/onekey')
const { getLog } = require('../controller/log')
// 路由统一管理
const routes = [
const ssh = [
{
method: 'get',
path: '/get-ssh-list',
controller: getSSHList
},
{
method: 'post',
path: '/add-ssh',
controller: addSSH
},
{
method: 'post',
path: '/update-ssh',
controller: updateSSH
},
{
method: 'post',
path: '/remove-ssh',
method: 'delete',
path: '/remove-ssh/:id',
controller: removeSSH
},
{
method: 'post',
path: '/exist-ssh',
controller: existSSH
},
{
method: 'get',
path: '/command',
controller: getCommand
},
{
method: 'post',
path: '/decrypt-private-key',
controller: decryptPrivateKey
}
]
const host = [
{
method: 'get',
path: '/host-list',
@ -32,23 +48,30 @@ const routes = [
{
method: 'post',
path: '/host-save',
controller: saveHost
controller: addHost
},
{
method: 'put',
path: '/host-save',
controller: updateHost
},
{
method: 'put',
path: '/batch-update-host',
controller: batchUpdateHost
},
{
method: 'post',
path: '/host-remove',
controller: removeHost
},
{
method: 'put',
path: '/host-sort',
controller: updateHostSort
},
method: 'post',
path: '/import-host',
controller: importHost
}
]
const user = [
{
method: 'get',
path: '/get-pub-pem',
@ -66,9 +89,152 @@ const routes = [
},
{
method: 'get',
path: '/get-login-record',
controller: getLoginRecord
path: '/version',
controller: getEasynodeVersion
},
{
method: 'get',
path: '/mfa2-status',
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 = [
{
method: 'get',
path: '/notify-config',
controller: getNotifyConfig
},
{
method: 'put',
path: '/notify-config',
controller: updateNotifyConfig
},
{
method: 'get',
path: '/notify',
controller: getNotifyList
},
{
method: 'put',
path: '/notify',
controller: updateNotifyList
}
]
module.exports = routes
const group = [
{
method: 'get',
path: '/group',
controller: getGroupList
},
{
method: 'post',
path: '/group',
controller: addGroupList
},
{
method: 'delete',
path: '/group/:id',
controller: removeGroup
},
{
method: 'put',
path: '/group/:id',
controller: updateGroupList
}
]
const scripts = [
{
method: 'get',
path: '/script',
controller: getScriptList
},
{
method: 'get',
path: '/local-script',
controller: getLocalScriptList
},
{
method: 'post',
path: '/script',
controller: addScript
},
{
method: 'delete',
path: '/script/:id',
controller: removeScript
},
{
method: 'post',
path: '/batch-remove-script',
controller: batchRemoveScript
},
{
method: 'put',
path: '/script/:id',
controller: updateScriptList
},
{
method: 'post',
path: '/import-script',
controller: importScript
}
]
const onekey = [
{
method: 'get',
path: '/onekey',
controller: getOnekeyRecord
},
{
method: 'post',
path: '/onekey',
controller: removeOnekeyRecord
}
]
const log = [
{
method: 'get',
path: '/log',
controller: getLog
}
]
module.exports = [].concat(ssh, host, user, notify, group, scripts, onekey, log)

View File

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

View File

@ -1,44 +1,21 @@
const Koa = require('koa')
const compose = require('koa-compose') // 组合中间件,简化写法
const http = require('http')
const https = require('https')
const { clientPort } = require('./config')
const { domain, httpPort, httpsPort, certificate } = require('./config')
const { httpPort } = require('./config')
const middlewares = require('./middlewares')
const wsMonitorOsInfo = require('./socket/monitor')
const wsTerminal = require('./socket/terminal')
const wsHostStatus = require('./socket/host-status')
const wsSftp = require('./socket/sftp')
const wsClientInfo = require('./socket/clients')
const { throwError } = require('./utils')
const wsOnekey = require('./socket/onekey')
const { throwError } = require('./utils/tools')
const httpServer = () => {
// if(EXEC_ENV === 'production') return console.log('========生成环境不创建http服务========')
const app = new Koa()
const server = http.createServer(app.callback())
serverHandler(app, server)
// ws一直报跨域的错误参照官方文档使用createServer API创建服务
server.listen(httpPort, () => {
console.log(`Server(http) is running on: http://localhost:${ httpPort }`)
})
}
const httpsServer = () => {
if(!certificate) return console.log('未上传证书, 创建https服务失败')
const app = new Koa()
const server = https.createServer(certificate, app.callback())
serverHandler(app, server)
server.listen(httpsPort, (err) => {
if (err) return console.log('https server error: ', err)
console.log(`Server(https) is running: https://${ domain }:${ httpsPort }`)
})
}
const clientHttpServer = () => {
const app = new Koa()
const server = http.createServer(app.callback())
wsMonitorOsInfo(server) // 监控本机信息
server.listen(clientPort, () => {
console.log(`Client(http) is running on: http://localhost:${ clientPort }`)
consola.success(`Server(http) is running on: http://localhost:${ httpPort }`)
})
}
@ -46,7 +23,8 @@ const clientHttpServer = () => {
function serverHandler(app, server) {
app.proxy = true // 用于nginx反代时获取真实客户端ip
wsTerminal(server) // 终端
wsHostStatus(server) // 终端侧边栏host信息
wsSftp(server) // sftp
wsOnekey(server) // 一键指令
wsClientInfo(server) // 客户端信息
app.context.throwError = throwError // 常用方法挂载全局ctx上
app.use(compose(middlewares))
@ -61,7 +39,5 @@ function serverHandler(app, server) {
}
module.exports = {
httpServer,
httpsServer,
clientHttpServer
httpServer
}

View File

@ -1,47 +1,62 @@
const { Server: ServerIO } = require('socket.io')
const { io: ClientIO } = require('socket.io-client')
const { readHostList } = require('../utils')
const { clientPort } = require('../config')
const { verifyAuth } = require('../utils')
const { defaultClientPort } = require('../config')
const { verifyAuthSync } = require('../utils/verify-auth')
const { isAllowedIp } = require('../utils/tools')
const { HostListDB } = require('../utils/db-class')
const hostListDB = new HostListDB().getInstance()
let clientSockets = {}, clientsData = {}
let clientSockets = []
let clientsData = {}
function getClientsInfo(socketId) {
let hostList = readHostList()
async function getClientsInfo(clientSockets) {
let hostList = await hostListDB.findAsync({})
clientSockets.forEach((clientItem) => {
// 被删除的客户端断开连接
if (!hostList.some(item => item.host === clientItem.host)) clientItem.close && clientItem.close()
})
hostList
.map(({ host }) => {
let clientSocket = ClientIO(`http://${ host }:${ clientPort }`, {
.map(({ host, name, clientPort }) => {
// 已经建立io连接(无论是否连接成功)的host不再重复建立连接,因为存在多次(reconnectionAttempts)的重试机制
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',
forceNew: true,
timeout: 5000,
reconnectionDelay: 3000,
reconnectionAttempts: 100
reconnectionDelay: 5000,
reconnectionAttempts: 1000
})
// 将与客户端连接的socket实例保存起来web端断开时关闭这些连接
clientSockets[socketId].push(clientSocket)
clientSockets.push({ host, name, clientPort, clientSocket })
return {
host,
name,
clientPort,
clientSocket
}
})
.map(({ host, clientSocket }) => {
.forEach((item) => {
if (item.isIo) return // console.log('已经建立io连接的host不再重复建立连接', item.name)
const { host, name, clientPort, clientSocket } = item
// eslint-disable-next-line no-unused-vars
clientSocket
.on('connect', () => {
console.log('client connect success:', host)
consola.success('client connect success:', host, name)
clientSocket.on('client_data', (osData) => {
clientsData[host] = osData
clientsData[`${ host }:${ clientPort || defaultClientPort }`] = { connect: true, ...osData }
})
clientSocket.on('client_error', (error) => {
clientsData[host] = error
clientsData[`${ host }:${ clientPort || defaultClientPort }`] = { connect: true, error: `client_error: ${ error }` }
})
})
.on('connect_error', (error) => {
console.log('client connect fail:', host, error.message)
clientsData[host] = null
.on('connect_error', (error) => { // 连接失败
// consola.error('client connect fail:', host, name, error.message)
clientsData[`${ host }:${ clientPort || defaultClientPort }`] = { connect: false, error: `client_connect_error: ${ error }` }
})
.on('disconnect', () => {
console.log('client connect disconnect:', host)
clientsData[host] = null
.on('disconnect', (error) => { // 一方主动断开连接
// consola.info('client connect disconnect:', host, name)
clientsData[`${ host }:${ clientPort || defaultClientPort }`] = { connect: false, error: `client_disconnect: ${ error }` }
})
})
}
@ -56,40 +71,38 @@ module.exports = (httpServer) => {
serverIo.on('connection', (socket) => {
// 前者兼容nginx反代, 后者兼容nodejs自身服务
let clientIp = socket.handshake.headers['x-forwarded-for'] || socket.handshake.address
socket.on('init_clients_data', ({ token }) => {
// 校验登录态
const { code, msg } = verifyAuth(token, clientIp)
if(code !== 1) {
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 }) => {
const { code, msg } = await verifyAuthSync(token, requestIP)
if (code !== 1) {
socket.emit('token_verify_fail', msg || '鉴权失败')
socket.disconnect()
return
}
// 收集web端连接的id
clientSockets[socket.id] = []
console.log('client连接socketId: ', socket.id, 'clients-socket已连接数: ', Object.keys(clientSockets).length)
getClientsInfo(clientSockets)
// 获取客户端数据
getClientsInfo(socket.id)
socket.on('refresh_clients_data', async () => {
consola.info('refresh clients-socket')
getClientsInfo(clientSockets)
})
// 立即推送一次
socket.emit('clients_data', clientsData)
// 向web端推送数据
let timer = null
timer = setInterval(() => {
socket.emit('clients_data', clientsData)
}, 1000)
// 关闭连接
socket.on('disconnect', () => {
// 防止内存泄漏
if(timer) clearInterval(timer)
// 当web端与服务端断开连接时, 服务端与每个客户端的socket也应该断开连接
clientSockets[socket.id].forEach(socket => socket.close && socket.close())
delete clientSockets[socket.id]
console.log('断开socketId: ', socket.id, 'clients-socket剩余连接数: ', Object.keys(clientSockets).length)
if (timer) clearInterval(timer)
clientSockets.forEach(item => item.clientSocket.close && item.clientSocket.close())
clientSockets = []
clientsData = {}
consola.info('clients-socket 连接断开: ', socket.id)
})
})
})

View File

@ -1,74 +0,0 @@
const { Server: ServerIO } = require('socket.io')
const { io: ClientIO } = require('socket.io-client')
const { clientPort } = require('../config')
const { verifyAuth } = require('../utils')
let hostSockets = {}
function getHostInfo(serverSocket, host) {
let hostSocket = ClientIO(`http://${ host }:${ clientPort }`, {
path: '/client/os-info',
forceNew: true,
timeout: 5000,
reconnectionDelay: 3000,
reconnectionAttempts: 100
})
// 将与客户端连接的socket实例保存起来web端断开时关闭与客户端的连接
hostSockets[serverSocket.id] = hostSocket
hostSocket
.on('connect', () => {
console.log('客户端状态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) => {
console.log('客户端状态socket连接[失败]:', host, error.message)
serverSocket.emit('host_data', null)
})
.on('disconnect', () => {
console.log('客户端状态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', ({ token, host }) => {
// 校验登录态
const { code, msg } = verifyAuth(token, clientIp)
if(code !== 1) {
serverSocket.emit('token_verify_fail', msg || '鉴权失败')
serverSocket.disconnect()
return
}
// 获取客户端数据
getHostInfo(serverSocket, host)
console.log('host-socket连接socketId: ', serverSocket.id, 'host-socket已连接数: ', Object.keys(hostSockets).length)
// 关闭连接
serverSocket.on('disconnect', () => {
// 当web端与服务端断开连接时, 服务端与每个客户端的socket也应该断开连接
let socket = hostSockets[serverSocket.id]
socket.close && socket.close()
delete hostSockets[serverSocket.id]
console.log('host-socket剩余连接数: ', Object.keys(hostSockets).length)
})
})
})
}

View File

@ -1,70 +0,0 @@
const { Server } = require('socket.io')
const schedule = require('node-schedule')
const axios = require('axios')
let getOsData = require('../utils/os-data')
let serverSockets = {}, ipInfo = {}, osData = {}
async function getIpInfo() {
try {
let { data } = await axios.get('http://ip-api.com/json?lang=zh-CN')
console.log('getIpInfo Success: ', new Date())
ipInfo = data
} catch (error) {
console.log('getIpInfo Error: ', new Date(), error)
}
}
function ipSchedule() {
let rule1 = new schedule.RecurrenceRule()
rule1.second = [0, 10, 20, 30, 40, 50]
schedule.scheduleJob(rule1, () => {
let { query, country, city } = ipInfo || {}
if(query && country && city) return
console.log('Task: start getIpInfo', new Date())
getIpInfo()
})
// 每日凌晨两点整,刷新ip信息(兼容动态ip服务器)
let rule2 = new schedule.RecurrenceRule()
rule2.hour = 2
rule2.minute = 0
rule2.second = 0
schedule.scheduleJob(rule2, () => {
console.log('Task: refresh ip info', new Date())
getIpInfo()
})
}
ipSchedule()
module.exports = (httpServer) => {
const serverIo = new Server(httpServer, {
path: '/client/os-info',
cors: {
origin: '*'
}
})
serverIo.on('connection', (socket) => {
// 存储对应websocket连接的定时器
serverSockets[socket.id] = setInterval(async () => {
try {
osData = await getOsData()
socket && socket.emit('client_data', Object.assign(osData, { ipInfo }))
} catch (error) {
console.error('客户端错误:', error)
socket && socket.emit('client_error', { error })
}
}, 1000)
socket.on('disconnect', () => {
// 断开时清楚对应的websocket连接
if(serverSockets[socket.id]) clearInterval(serverSockets[socket.id])
delete serverSockets[socket.id]
socket.close && socket.close()
socket = null
// console.log('断开socketId: ', socket.id, '剩余链接数: ', Object.keys(serverSockets).length)
})
})
}

217
server/app/socket/onekey.js Normal file
View File

@ -0,0 +1,217 @@
const { Server } = require('socket.io')
const { Client: SSHClient } = require('ssh2')
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 = {
connecting: '连接中',
connectFail: '连接失败',
executing: '执行中',
execSuccess: '执行成功',
execFail: '执行失败',
execTimeout: '执行超时',
socketInterrupt: '执行中断'
}
let isExecuting = false
let execResult = []
let execClient = []
function disconnectAllExecClient() {
execClient.forEach((sshClient) => {
if (sshClient) {
sshClient.end()
sshClient.destroy()
sshClient = null
}
})
}
function execShell(socket, sshClient, curRes, resolve) {
const throttledDataHandler = shellThrottle(() => {
socket.emit('output', execResult)
// const memoryUsage = process.memoryUsage()
// const formattedMemoryUsage = {
// rss: (memoryUsage.rss / 1024 / 1024).toFixed(2) + ' MB', // Resident Set Size: total memory allocated for the process execution
// heapTotal: (memoryUsage.heapTotal / 1024 / 1024).toFixed(2) + ' MB', // Total size of the allocated heap
// heapUsed: (memoryUsage.heapUsed / 1024 / 1024).toFixed(2) + ' MB', // Actual memory used during the execution
// external: (memoryUsage.external / 1024 / 1024).toFixed(2) + ' MB', // Memory used by "external" components like V8 external memory
// arrayBuffers: (memoryUsage.arrayBuffers / 1024 / 1024).toFixed(2) + ' MB' // Memory allocated for ArrayBuffer and SharedArrayBuffer, including all Node.js Buffers
// }
// console.log(formattedMemoryUsage)
}, 500) // 防止内存爆破
sshClient.exec(curRes.command, function(err, stream) {
if (err) {
console.log(curRes.host, '命令执行失败:', err)
curRes.status = execStatusEnum.execFail
curRes.result += err.toString()
socket.emit('output', execResult)
return
}
stream
.on('close', async () => {
// shell关闭后再执行一次输出防止最后一次节流函数发生在延迟时间内导致终端的输出数据丢失
await throttledDataHandler.last() // 等待最后一次节流函数执行完成,再执行一次数据输出
// console.log('onekey终端执行完成, 关闭连接: ', curRes.host)
if (curRes.status === execStatusEnum.executing) {
curRes.status = execStatusEnum.execSuccess
}
socket.emit('output', execResult)
resolve(curRes)
sshClient.end()
})
.on('data', (data) => {
// console.log(curRes.host, '执行中: \n' + data)
curRes.status = execStatusEnum.executing
curRes.result += data.toString()
// socket.emit('output', execResult)
throttledDataHandler(data)
})
.stderr
.on('data', (data) => {
// console.log(curRes.host, '命令执行过程中产生错误: ' + data)
curRes.status = execStatusEnum.executing
curRes.result += data.toString()
// socket.emit('output', execResult)
throttledDataHandler(data)
})
})
}
module.exports = (httpServer) => {
const serverIo = new Server(httpServer, {
path: '/onekey',
cors: {
origin: '*'
}
})
serverIo.on('connection', (socket) => {
// 前者兼容nginx反代, 后者兼容nodejs自身服务
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 已连接')
if (isExecuting) {
socket.emit('create_fail', '正在执行中, 请稍后再试')
socket.disconnect()
return
}
isExecuting = true
socket.on('create', async ({ hostIds, token, command, timeout }) => {
const { code } = await verifyAuthSync(token, requestIP)
if (code !== 1) {
socket.emit('token_verify_fail')
socket.disconnect()
return
}
setTimeout(() => {
// 超时未执行完成,强制断开连接
const { connecting, executing } = execStatusEnum
execResult.forEach(item => {
// 连接中和执行中的状态设定为超时
if ([connecting, executing].includes(item.status)) {
item.status = execStatusEnum.execTimeout
}
})
let reason = `执行超时,已强制终止执行 - 超时时间${ timeout }`
sendNoticeAsync('onekey_complete', '批量指令执行超时', reason)
socket.emit('timeout', { reason, result: execResult })
socket.disconnect()
disconnectAllExecClient()
}, timeout * 1000)
console.log('hostIds:', hostIds)
// console.log('token:', token)
console.log('command:', command)
const hostList = await hostListDB.findAsync({})
const targetHostsInfo = hostList.filter(item => hostIds.some(id => item._id === id)) || {}
// console.log('targetHostsInfo:', targetHostsInfo)
if (!targetHostsInfo.length) return socket.emit('create_fail', `未找到【${ hostIds }】服务器信息`)
// 查找 hostInfo -> 并发执行
socket.emit('ready')
let execPromise = targetHostsInfo.map((hostInfo, index) => {
// eslint-disable-next-line no-async-promise-executor
return new Promise(async (resolve, reject) => {
setTimeout(() => reject('执行超时'), timeout * 1000)
let { authType, host, port, username } = hostInfo
let authInfo = { host, port, username }
let curRes = { command, host, port, name: hostInfo.name, result: '', status: execStatusEnum.connecting, date: Date.now() - (targetHostsInfo.length - index) } // , execStatusEnum
execResult.push(curRes)
try {
if (authType === 'credential') {
let credentialId = await AESDecryptAsync(hostInfo['credential'])
const sshRecordList = await credentialsDB.findAsync({})
const sshRecord = sshRecordList.find(item => item._id === credentialId)
authInfo.authType = sshRecord.authType
authInfo[authInfo.authType] = await AESDecryptAsync(sshRecord[authInfo.authType])
} else {
authInfo[authType] = await AESDecryptAsync(hostInfo[authType])
}
consola.info('准备连接终端执行一次性指令:', host)
consola.log('连接信息', { username, port, authType })
let sshClient = new SSHClient()
execClient.push(sshClient)
sshClient
.on('ready', () => {
consola.success('连接终端成功:', host)
// socket.emit('connect_success', `已连接到终端:${ host }`)
execShell(socket, sshClient, curRes, resolve)
})
.on('error', (err) => {
console.log(err)
consola.error('onekey终端连接失败:', err.level)
curRes.status = execStatusEnum.connectFail
curRes.result += err.message
socket.emit('output', execResult)
resolve(curRes)
})
.connect({
...authInfo
// debug: (info) => console.log(info)
})
} catch (err) {
consola.error('创建终端错误:', err.message)
curRes.status = execStatusEnum.connectFail
curRes.result += err.message
socket.emit('output', execResult)
resolve(curRes)
}
})
})
try {
await Promise.all(execPromise)
consola.success('onekey执行完成')
socket.emit('exec_complete')
sendNoticeAsync('onekey_complete', '批量指令执行完成', '请登录面板查看执行结果')
socket.disconnect()
} catch (error) {
consola.error('onekey执行失败', error)
}
})
socket.on('disconnect', async (reason) => {
consola.info('onekey终端连接断开:', reason)
disconnectAllExecClient()
const { execSuccess, connectFail, execFail, execTimeout } = execStatusEnum
execResult.forEach(item => {
// 非服务端手动断开连接且命令执行状态为非完成\失败\超时, 判定为客户端主动中断
if (reason !== 'server namespace disconnect' && ![execSuccess, execFail, execTimeout, connectFail].includes(item.status)) {
item.status = execStatusEnum.socketInterrupt
}
})
await onekeyDB.insertAsync(execResult)
isExecuting = false
execResult = []
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

290
server/app/socket/sftp.js Normal file
View File

@ -0,0 +1,290 @@
const rawPath = require('path')
const fs = require('fs-extra')
const SFTPClient = require('ssh2-sftp-client')
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) => {
return new Promise(resolve => {
const readStream = fs.createReadStream(path)
readStream.on('end', () => {
fs.unlinkSync(path) // 删除已写入切片
resolve()
})
readStream.pipe(writeStream)
})
}
function listenInput(sftpClient, socket) {
socket.on('open_dir', async (path, tips = true) => {
const exists = await sftpClient.exists(path)
if (!exists) return socket.emit('not_exists_dir', tips ? '目录不存在或当前不可访问' : '')
try {
let dirLs = await sftpClient.list(path)
socket.emit('dir_ls', dirLs, path)
} catch (error) {
consola.error('open_dir Error', error.message)
socket.emit('sftp_error', error.message)
}
})
socket.on('rm_dir', async (path) => {
const exists = await sftpClient.exists(path)
if (!exists) return socket.emit('not_exists_dir', '目录不存在或当前不可访问')
consola.info('rm_dir: ', path)
try {
let res = await sftpClient.rmdir(path, true) // 递归删除
socket.emit('rm_success', res)
} catch (error) {
consola.error('rm_dir Error', error.message)
socket.emit('sftp_error', error.message)
}
})
socket.on('rm_file', async (path) => {
const exists = await sftpClient.exists(path)
if (!exists) return socket.emit('not_exists_dir', '文件不存在或当前不可访问')
try {
let res = await sftpClient.delete(path)
socket.emit('rm_success', res)
} catch (error) {
consola.error('rm_file Error', error.message)
socket.emit('sftp_error', error.message)
}
})
// socket.on('down_dir', async (path) => {
// const exists = await sftpClient.exists(path)
// if(!exists) return socket.emit('not_exists_dir', '目录不存在或当前不可访问')
// let res = await sftpClient.downloadDir(path, sftpCacheDir)
// socket.emit('down_dir_success', res)
// })
// 下载
socket.on('down_file', async ({ path, name, size, target = 'down' }) => {
// target: down or preview
const exists = await sftpClient.exists(path)
if (!exists) return socket.emit('not_exists_dir', '文件不存在或当前不可访问')
try {
const localPath = rawPath.join(sftpCacheDir, name)
let timer = null
let res = await sftpClient.fastGet(path, localPath, {
step: step => {
if (timer) return
timer = setTimeout(() => {
const percent = Math.ceil((step / size) * 100) // 下载进度为服务器下载到服务端的进度,前端无需*2
console.log(`从服务器下载进度:${ percent }%`)
socket.emit('down_file_progress', percent)
timer = null
}, 1500)
}
})
consola.success('sftp下载成功: ', res)
let buffer = fs.readFileSync(localPath)
let data = { buffer, name }
switch (target) {
case 'down':
socket.emit('down_file_success', data)
break
case 'preview':
socket.emit('preview_file_success', data)
break
}
fs.unlinkSync(localPath) //删除文件
} catch (error) {
consola.error('down_file Error', error.message)
socket.emit('sftp_error', error.message)
}
})
// 上传
socket.on('up_file', async ({ targetPath, fullPath, name, file }) => {
// console.log({ targetPath, fullPath, name, file })
const exists = await sftpClient.exists(targetPath)
if (!exists) return socket.emit('not_exists_dir', '目录不存在或当前不可访问')
try {
const localPath = rawPath.join(sftpCacheDir, name)
fs.writeFileSync(localPath, file)
let res = await sftpClient.fastPut(localPath, fullPath)
consola.success('sftp上传成功: ', res)
socket.emit('up_file_success', res)
} catch (error) {
consola.error('up_file Error', error.message)
socket.emit('sftp_error', error.message)
}
})
// 上传目录先在目标sftp服务器创建目录
socket.on('create_remote_dir', async ({ targetDirPath, foldersName }) => {
let baseFolderPath = rawPath.posix.join(targetDirPath, foldersName[0].split('/')[0])
let baseFolderPathExists = await sftpClient.exists(baseFolderPath)
if (baseFolderPathExists) return socket.emit('create_remote_dir_exists', `远程目录已存在: ${ baseFolderPath }`)
consola.info('准备创建远程服务器目录:', foldersName)
for (const folderName of foldersName) {
const fullPath = rawPath.posix.join(targetDirPath, folderName)
const exists = await sftpClient.exists(fullPath)
if (exists) continue
await sftpClient.mkdir(fullPath, true)
socket.emit('create_remote_dir_progress', fullPath)
consola.info('创建目录:', fullPath)
}
socket.emit('create_remote_dir_success')
})
/** 分片上传 */
// 1. 创建本地缓存目录
let md5List = []
socket.on('create_cache_dir', async ({ targetDirPath, name }) => {
// console.log({ targetDirPath, name })
const exists = await sftpClient.exists(targetDirPath)
if (!exists) return socket.emit('not_exists_dir', '目录不存在或当前不可访问')
md5List = []
const localPath = rawPath.join(sftpCacheDir, name)
fs.emptyDirSync(localPath) // 不存在会创建,存在则清空
socket.emit('create_cache_success')
})
// 2. 上传分片到面板服务
socket.on('up_file_slice', async ({ name, sliceFile, fileIndex }) => {
// console.log('up_file_slice:', fileIndex, name)
try {
let md5 = `${ fileIndex }.${ CryptoJS.MD5(fileIndex+name).toString() }`
const md5LocalPath = rawPath.join(sftpCacheDir, name, md5)
md5List.push(md5LocalPath)
fs.writeFileSync(md5LocalPath, sliceFile)
socket.emit('up_file_slice_success', md5)
} catch (error) {
consola.error('up_file_slice Error', error.message)
socket.emit('up_file_slice_fail', error.message)
}
})
// 3. 合并分片上传到服务器
socket.on('up_file_slice_over', async ({ name, targetFilePath, range, size }) => {
const md5CacheDirPath = rawPath.join(sftpCacheDir, name)
const resultFilePath = rawPath.join(sftpCacheDir, name, name)
fs.ensureDirSync(md5CacheDirPath)
try {
console.log('md5List: ', md5List)
const arr = md5List.map((chunkFilePath, index) => {
return pipeStream(
chunkFilePath,
fs.createWriteStream(resultFilePath, { // 指定位置创建可写流
start: index * range,
end: (index + 1) * range
})
)
})
md5List = []
await Promise.all(arr)
let timer = null
let res = await sftpClient.fastPut(resultFilePath, targetFilePath, {
step: step => {
if (timer) return
timer = setTimeout(() => {
const percent = Math.ceil((step / size) * 100)
console.log(`上传服务器进度:${ percent }%`)
socket.emit('up_file_progress', percent)
timer = null
}, 200)
}
})
consola.success('sftp上传成功: ', res)
socket.emit('up_file_success', res)
} catch (error) {
consola.error('sftp上传失败: ', error.message)
socket.emit('up_file_fail', error.message)
} finally {
fs.remove(md5CacheDirPath)
.then(() => {
console.log('clean md5CacheDirPath:', md5CacheDirPath)
})
}
})
}
module.exports = (httpServer) => {
const serverIo = new Server(httpServer, {
path: '/sftp',
cors: {
origin: '*'
}
})
serverIo.on('connection', (socket) => {
// 前者兼容nginx反代, 后者兼容nodejs自身服务
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()
consola.success('terminal websocket 已连接')
socket.on('create', async ({ hostId, token }) => {
const { code } = await verifyAuthSync(token, requestIP)
consola.log('code:', code)
if (code !== 1) {
socket.emit('token_verify_fail')
socket.disconnect()
return
}
const targetHostInfo = await hostListDB.findOneAsync({ _id: hostId })
if (!targetHostInfo) throw new Error(`Host with ID ${ hostId } not found`)
let { authType, host, port, username } = targetHostInfo
if (!host) return socket.emit('create_fail', `查找id【${ hostId }】凭证信息失败`)
let authInfo = { host, port, username }
// 解密放到try里面防止报错【commonKey必须配对, 否则需要重新添加服务器密钥】
if (authType === 'credential') {
let credentialId = await AESDecryptAsync(targetHostInfo[authType])
const sshRecordList = await credentialsDB.findAsync({})
const sshRecord = sshRecordList.find(item => item._id === credentialId)
authInfo.authType = sshRecord.authType
authInfo[authInfo.authType] = await AESDecryptAsync(sshRecord[authInfo.authType])
} else {
authInfo[authType] = await AESDecryptAsync(targetHostInfo[authType])
}
consola.info('准备连接Sftp面板', host)
targetHostInfo[targetHostInfo.authType] = await AESDecryptAsync(targetHostInfo[targetHostInfo.authType])
consola.log('连接信息', { username, port, authType })
sftpClient
.connect(authInfo)
.then(() => {
consola.success('连接Sftp成功', host)
fs.ensureDirSync(sftpCacheDir)
return sftpClient.list('/')
})
.then((rootLs) => {
// 普通文件-、目录文件d、链接文件l
socket.emit('root_ls', rootLs) // 先返回根目录
listenInput(sftpClient, socket) // 监听前端请求
})
.catch((err) => {
consola.error('创建Sftp失败:', err.message)
socket.emit('create_fail', err.message)
})
})
socket.on('disconnect', async () => {
sftpClient.end()
.then(() => {
consola.info('sftp连接断开')
})
.catch((error) => {
consola.info('sftp断开连接失败:', error.message)
})
.finally(() => {
sftpClient = null
fs.emptyDir(sftpCacheDir)
.then(() => {
consola.success('clean sftpCacheDir: ', sftpCacheDir)
})
})
})
})
}

View File

@ -1,30 +1,108 @@
const path = require('path')
const { Server } = require('socket.io')
const { Client: Client } = require('ssh2')
const { readSSHRecord, verifyAuth, RSADecrypt, AESDecrypt } = require('../utils')
const { Client: SSHClient } = require('ssh2')
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, vps) {
vps.shell({ term: 'xterm-color' }, (err, stream) => {
if (err) return socket.emit('output', err.toString())
stream
.on('data', (data) => {
socket.emit('output', data.toString())
})
.on('close', () => {
console.log('关闭终端')
vps.end()
})
socket.on('input', key => {
if(vps._sock.writable === false) return console.log('终端连接已关闭')
stream.write(key)
})
socket.emit('connect_terminal')
async function getConnectionOptions(hostId) {
const hostInfo = await hostListDB.findOneAsync({ _id: hostId })
if (!hostInfo) throw new Error(`Host with ID ${ hostId } not found`)
let { authType, host, port, username, name } = hostInfo
let authInfo = { host, port, username }
try {
if (authType === 'credential') {
let credentialId = await AESDecryptAsync(hostInfo[authType])
const sshRecord = await credentialsDB.findOneAsync({ _id: credentialId })
authInfo.authType = sshRecord.authType
authInfo[authInfo.authType] = await AESDecryptAsync(sshRecord[authInfo.authType])
} else {
authInfo[authType] = await AESDecryptAsync(hostInfo[authType])
}
return { authInfo, name }
} catch (err) {
throw new Error(`解密认证信息失败: ${ err.message }`)
}
}
socket.on('resize', ({ rows, cols }) => {
stream.setWindow(rows, cols)
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())
stream
.on('data', (data) => {
socket.emit('output', data.toString())
})
.on('close', () => {
consola.info('交互终端已关闭')
targetSSHClient.end()
})
socket.emit('connect_shell_success') // 已连接终端web端可以执行指令了
})
})
}
async function createTerminal(hostId, socket, targetSSHClient) {
return new Promise(async (resolve) => {
const targetHostInfo = await hostListDB.findOneAsync({ _id: hostId })
if (!targetHostInfo) return socket.emit('create_fail', `查找hostId【${ hostId }】凭证信息失败`)
let { connectByJumpHosts = null } = (await decryptAndExecuteAsync(path.join(__dirname, 'plus.js'))) || {}
let { authType, host, port, username, name, jumpHosts } = targetHostInfo
try {
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)
}
})
}
module.exports = (httpServer) => {
const serverIo = new Server(httpServer, {
path: '/terminal',
@ -32,61 +110,54 @@ module.exports = (httpServer) => {
origin: '*'
}
})
serverIo.on('connection', (socket) => {
// 前者兼容nginx反代, 后者兼容nodejs自身服务
let clientIp = socket.handshake.headers['x-forwarded-for'] || socket.handshake.address
let vps = new Client()
console.log('terminal websocket 已连接')
socket.on('create', ({ host: ip, token }) => {
const { code } = verifyAuth(token, clientIp)
if(code !== 1) {
let connectionCount = 0
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) {
socket.emit('token_verify_fail')
socket.disconnect()
return
}
// console.log('code:', code)
const sshRecord = readSSHRecord()
let loginInfo = sshRecord.find(item => item.host === ip)
if(!sshRecord.some(item => item.host === ip)) return socket.emit('create_fail', `未找到【${ ip }】凭证`)
let { type, host, port, username, randomKey } = loginInfo
targetSSHClient = new SSHClient()
let stream = null
function listenerInput(key) {
if (targetSSHClient._sock.writable === false) return consola.info('终端连接已关闭,禁止输入')
stream && stream.write(key)
}
function resizeShell({ rows, cols }) {
stream && stream.setWindow(rows, cols)
}
socket.on('input', listenerInput)
socket.on('resize', resizeShell)
stream = await createTerminal(hostId, socket, targetSSHClient)
})
socket.on('get_ping', async (ip) => {
try {
// 解密放到try里面防止报错【公私钥必须配对, 否则需要重新添加服务器密钥】
randomKey = AESDecrypt(randomKey) // 先对称解密key
randomKey = RSADecrypt(randomKey) // 再非对称解密key
loginInfo[type] = AESDecrypt(loginInfo[type], randomKey) // 对称解密ssh密钥
console.log('准备连接服务器:', host)
vps
.on('ready', () => {
console.log('已连接到服务器:', host)
socket.emit('connect_success', `已连接到服务器:${ host }`)
createTerminal(socket, vps)
})
.on('error', (err) => {
console.log('连接失败:', err.level)
socket.emit('connect_fail', err.message)
})
.connect({
type: 'privateKey',
host,
port,
username,
[type]: loginInfo[type]
// debug: (info) => {
// console.log(info)
// }
})
} catch (err) {
console.log('创建失败:', err.message)
socket.emit('create_fail', err.message)
socket.emit('ping_data', await ping(ip, 2500))
} catch (error) {
socket.emit('ping_data', { success: false, msg: error.message })
}
})
socket.on('disconnect', (reason) => {
console.log('终端连接断开:', reason)
vps.end()
vps.destroy()
vps = null
connectionCount--
consola.info(`终端socket连接断开: ${ reason } - 当前连接数: ${ connectionCount }`)
})
})
}
module.exports.getConnectionOptions = getConnectionOptions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -1,15 +0,0 @@
<html lang="zh">
<head>
<meta charset="UTF-8" />
<link rel="icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=0,user-scalable=yes">
<title>EasyNode</title>
<script src="//at.alicdn.com/t/font_3309550_eg9tjmfmiku.js"></script>
<script type="module" crossorigin src="/assets/index.c20c6c58.js"></script>
<link rel="stylesheet" href="/assets/index.d8a03066.css">
</head>
<body>
<div id="app"></div>
</body>
</html>

View File

@ -0,0 +1,23 @@
<!DOCTYPE html
PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
</head>
<body style="margin: 0; padding: 0;text-align: center;">
<table border="0" cellpadding="0" cellspacing="0" width="100%">
<tr>
<td>
<h3 style="font-size: 20px;color: #5992D3;padding:0 0 0 40px;">
${ content }
</h3>
</td>
</tr>
</table>
</body>
</html>

View File

@ -0,0 +1,40 @@
module.exports = (content) => {
return `<!DOCTYPE html>
<html>
<head>
<style>
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>
<body>
<div class="container">
<p>${ content }</p>
<p class="footer">通知发送时间: ${ new Date() }</p>
</div>
</body>
</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

@ -0,0 +1,132 @@
const Datastore = require('@seald-io/nedb')
const {
credentialsDBPath,
hostListDBPath,
keyDBPath,
notifyDBPath,
notifyConfigDBPath,
groupConfDBPath,
scriptsDBPath,
onekeyDBPath,
logDBPath,
plusDBPath
} = require('../config')
module.exports.KeyDB = class KeyDB {
constructor() {
if (!KeyDB.instance) {
KeyDB.instance = new Datastore({ filename: keyDBPath, autoload: true })
// KeyDB.instance.setAutocompactionInterval(5000)
}
}
getInstance() {
return KeyDB.instance
}
}
module.exports.HostListDB = class HostListDB {
constructor() {
if (!HostListDB.instance) {
HostListDB.instance = new Datastore({ filename: hostListDBPath, autoload: true })
// HostListDB.instance.setAutocompactionInterval(5000)
}
}
getInstance() {
return HostListDB.instance
}
}
module.exports.CredentialsDB = class CredentialsDB {
constructor() {
if (!CredentialsDB.instance) {
CredentialsDB.instance = new Datastore({ filename: credentialsDBPath, autoload: true })
// CredentialsDB.instance.setAutocompactionInterval(5000)
}
}
getInstance() {
return CredentialsDB.instance
}
}
module.exports.NotifyDB = class NotifyDB {
constructor() {
if (!NotifyDB.instance) {
NotifyDB.instance = new Datastore({ filename: notifyDBPath, autoload: true })
// NotifyDB.instance.setAutocompactionInterval(5000)
}
}
getInstance() {
return NotifyDB.instance
}
}
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 {
constructor() {
if (!GroupDB.instance) {
GroupDB.instance = new Datastore({ filename: groupConfDBPath, autoload: true })
// GroupDB.instance.setAutocompactionInterval(5000)
}
}
getInstance() {
return GroupDB.instance
}
}
module.exports.ScriptsDB = class ScriptsDB {
constructor() {
if (!ScriptsDB.instance) {
ScriptsDB.instance = new Datastore({ filename: scriptsDBPath, autoload: true })
// ScriptsDB.instance.setAutocompactionInterval(5000)
}
}
getInstance() {
return ScriptsDB.instance
}
}
module.exports.OnekeyDB = class OnekeyDB {
constructor() {
if (!OnekeyDB.instance) {
OnekeyDB.instance = new Datastore({ filename: onekeyDBPath, autoload: true })
// OnekeyDB.instance.setAutocompactionInterval(5000)
}
}
getInstance() {
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

@ -0,0 +1,45 @@
const CryptoJS = require('crypto-js')
const rawCrypto = require('crypto')
const NodeRSA = require('node-rsa')
const { KeyDB } = require('./db-class')
const keyDB = new KeyDB().getInstance()
// rsa非对称 私钥解密
const RSADecryptAsync = async (ciphertext) => {
if (!ciphertext) return
let { privateKey } = await keyDB.findOneAsync({})
privateKey = await AESDecryptAsync(privateKey) // 先解密私钥
const rsakey = new NodeRSA(privateKey)
rsakey.setOptions({ encryptionScheme: 'pkcs1', environment: 'browser' }) // Must Set It When Frontend Use jsencrypt
const plaintext = rsakey.decrypt(ciphertext, 'utf8')
return plaintext
}
// aes对称 加密(default commonKey)
const AESEncryptAsync = async (text, key) => {
if (!text) return
let { commonKey } = await keyDB.findOneAsync({})
let ciphertext = CryptoJS.AES.encrypt(text, key || commonKey).toString()
return ciphertext
}
// aes对称 解密(default commonKey)
const AESDecryptAsync = async (ciphertext, key) => {
if (!ciphertext) return
let { commonKey } = await keyDB.findOneAsync({})
let bytes = CryptoJS.AES.decrypt(ciphertext, key || commonKey)
let originalText = bytes.toString(CryptoJS.enc.Utf8)
return originalText
}
// sha1 加密(不可逆)
const SHA1Encrypt = (clearText) => {
return rawCrypto.createHash('sha1').update(clearText).digest('hex')
}
module.exports = {
RSADecryptAsync,
AESEncryptAsync,
AESDecryptAsync,
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,189 +0,0 @@
const fs = require('fs')
const CryptoJS = require('crypto-js')
const rawCrypto = require('crypto')
const NodeRSA = require('node-rsa')
const jwt = require('jsonwebtoken')
const axios = require('axios')
const request = axios.create({ timeout: 3000 })
const { sshRecordPath, hostListPath, keyPath } = require('../config')
const readSSHRecord = () => {
let list
try {
list = JSON.parse(fs.readFileSync(sshRecordPath, 'utf8'))
} catch (error) {
console.log('读取ssh-record错误, 即将重置ssh列表: ', error)
writeSSHRecord([])
}
return list || []
}
const writeSSHRecord = (record = []) => {
fs.writeFileSync(sshRecordPath, JSON.stringify(record, null, 2))
}
const readHostList = () => {
let list
try {
list = JSON.parse(fs.readFileSync(hostListPath, 'utf8'))
} catch (error) {
console.log('读取host-list错误, 即将重置host列表: ', error)
writeHostList([])
}
return list || []
}
const writeHostList = (record = []) => {
fs.writeFileSync(hostListPath, JSON.stringify(record, null, 2))
}
const readKey = () => {
let keyObj = JSON.parse(fs.readFileSync(keyPath, 'utf8'))
return keyObj
}
const writeKey = (keyObj = {}) => {
fs.writeFileSync(keyPath, JSON.stringify(keyObj, null, 2))
}
// 为空时请求本地IP
const getNetIPInfo = async (ip = '') => {
try {
let date = getUTCDate(8)
let ipUrls = [`http://ip-api.com/json/${ ip }?lang=zh-CN`, `http://whois.pconline.com.cn/ipJson.jsp?json=true&ip=${ ip }`]
let result = await Promise.allSettled(ipUrls.map(url => request.get(url)))
let [ipApi, pconline] = result
if(ipApi.status === 'fulfilled') {
let { query: ip, country, regionName, city } = ipApi.value.data
// console.log({ ip, country, city: regionName + city })
return { ip, country, city: regionName + city, date }
}
if(pconline.status === 'fulfilled') {
let { ip, pro, city, addr } = pconline.value.data
// console.log({ ip, country: pro || addr, city })
return { ip, country: pro || addr, city, date }
}
throw Error('获取IP信息API出错,请排查或更新API')
} catch (error) {
console.error('getIpInfo Error: ', error)
return {
ip: '未知',
country: '未知',
city: '未知',
error
}
}
}
const throwError = ({ status = 500, msg = 'defalut error' } = {}) => {
const err = new Error(msg)
err.status = status // 主动抛错
throw err
}
const isIP = (ip = '') => {
const isIPv4 = /^((25[0-5]|2[0-4]\d|[01]?\d\d?)\.){3}(25[0-5]|2[0-4]\d|[01]?\d\d?)$/
const isIPv6 = /^([\da-fA-F]{1,4}:){7}[\da-fA-F]{1,4}|:((:[\dafAF]1,4)1,6|:)|:((:[\dafAF]1,4)1,6|:)|^[\da-fA-F]{1,4}:((:[\da-fA-F]{1,4}){1,5}|:)|([\dafAF]1,4:)2((:[\dafAF]1,4)1,4|:)|([\dafAF]1,4:)2((:[\dafAF]1,4)1,4|:)|^([\da-fA-F]{1,4}:){3}((:[\da-fA-F]{1,4}){1,3}|:)|([\dafAF]1,4:)4((:[\dafAF]1,4)1,2|:)|([\dafAF]1,4:)4((:[\dafAF]1,4)1,2|:)|^([\da-fA-F]{1,4}:){5}:([\da-fA-F]{1,4})?|([\dafAF]1,4:)6:|([\dafAF]1,4:)6:/
return isIPv4.test(ip) || isIPv6.test(ip)
}
const randomStr = (e) =>{
e = e || 16
let str = 'ABCDEFGHJKMNPQRSTWXYZabcdefhijkmnprstwxyz2345678',
a = str.length,
res = ''
for (let i = 0; i < e; i++) res += str.charAt(Math.floor(Math.random() * a))
return res
}
// 校验token与登录IP
const verifyAuth = (token, clientIp) =>{
token = AESDecrypt(token) // 先aes解密
const { commonKey } = readKey()
try {
const { exp } = jwt.verify(token, commonKey)
// console.log('校验token', new Date(), '---', new Date(exp * 1000))
if(Date.now() > (exp * 1000)) return { code: -1, msg: 'token expires' } // 过期
let lastLoginIp = global.loginRecord[0] ? global.loginRecord[0].ip : ''
console.log('校验客户端IP', clientIp)
console.log('最后登录的IP', lastLoginIp)
// 判断: (生产环境)clientIp与上次登录成功IP不一致
if(isProd() && (!lastLoginIp || !clientIp || !clientIp.includes(lastLoginIp))) {
return { code: -1, msg: '登录IP发生变化, 需重新登录' } // IP与上次登录访问的不一致
}
// console.log('token验证成功')
return { code: 1, msg: 'success' } // 验证成功
} catch (error) {
// console.log('token校验错误', error)
return { code: -2, msg: error } // token错误, 验证失败
}
}
const isProd = () => {
const EXEC_ENV = process.env.EXEC_ENV || 'production'
return EXEC_ENV === 'production'
}
// rsa非对称 私钥解密
const RSADecrypt = (ciphertext) => {
if(!ciphertext) return
let { privateKey } = readKey()
privateKey = AESDecrypt(privateKey) // 先解密私钥
const rsakey = new NodeRSA(privateKey)
rsakey.setOptions({ encryptionScheme: 'pkcs1' }) // Must Set It When Frontend Use jsencrypt
const plaintext = rsakey.decrypt(ciphertext, 'utf8')
return plaintext
}
// aes对称 加密(default commonKey)
const AESEncrypt = (text, key) => {
if(!text) return
let { commonKey } = readKey()
let ciphertext = CryptoJS.AES.encrypt(text, key || commonKey).toString()
return ciphertext
}
// aes对称 解密(default commonKey)
const AESDecrypt = (ciphertext, key) => {
if(!ciphertext) return
let { commonKey } = readKey()
let bytes = CryptoJS.AES.decrypt(ciphertext, key || commonKey)
let originalText = bytes.toString(CryptoJS.enc.Utf8)
return originalText
}
// sha1 加密(不可逆)
const SHA1Encrypt = (clearText) => {
return rawCrypto.createHash('sha1').update(clearText).digest('hex')
}
// 获取UTC-x时间
const getUTCDate = (num = 8) => {
let date = new Date()
let now_utc = Date.UTC(date.getUTCFullYear(), date.getUTCMonth(),
date.getUTCDate(), date.getUTCHours() + num,
date.getUTCMinutes(), date.getUTCSeconds())
return new Date(now_utc)
}
module.exports = {
readSSHRecord,
writeSSHRecord,
readHostList,
writeHostList,
getNetIPInfo,
throwError,
isIP,
readKey,
writeKey,
randomStr,
verifyAuth,
isProd,
RSADecrypt,
AESEncrypt,
AESDecrypt,
SHA1Encrypt,
getUTCDate
}

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=

327
server/app/utils/tools.js Normal file
View File

@ -0,0 +1,327 @@
const { exec } = require('child_process')
const os = require('os')
const net = require('net')
const iconv = require('iconv-lite')
const axios = require('axios')
const request = axios.create({ timeout: 3000 })
// 为空时请求本地IP
const getNetIPInfo = async (searchIp = '') => {
console.log('searchIp:', searchIp)
if (isLocalIP(searchIp)) {
return {
ip: searchIp,
country: '本地',
city: '局域网',
error: null
}
}
try {
let date = Date.now()
let ipUrls = [
// 45次/分钟&支持中文(无限制)
`http://ip-api.com/json/${ searchIp }?lang=zh-CN`,
// 10000次/月&支持中文(依赖IP计算调用次数)
`http://ipwho.is/${ searchIp }?lang=zh-CN`,
// 1500次/天(依赖密钥, 超出自行注册)
`https://api.ipdata.co/${ searchIp }?api-key=c6d4d04d5f11f2cd0839ee03c47c58621d74e361c945b5c1b4f668f3`,
// 50000/月(依赖密钥, 超出自行注册)
`https://ipinfo.io/${ searchIp }/json?token=41c48b54f6d78f`,
// 1000次/天(依赖密钥, 超出自行注册)
`https://api.ipgeolocation.io/ipgeo?apiKey=105fc2c7e8864ec08b98e1ad4e8cbc6d&ip=${ searchIp }`,
// 1000次/天(依赖IP计算调用次数)
`https://ipapi.co${ searchIp ? `/${ searchIp }` : '' }/json`,
// 国内IP138提供(无限制)
`https://sp1.baidu.com/8aQDcjqpAAV3otqbppnN2DJv/api.php?query=${ searchIp }&resource_id=5809`
]
let result = await Promise.allSettled(ipUrls.map(url => request.get(url)))
let [ipApi, ipwho, ipdata, ipinfo, ipgeolocation, ipApi01, ip138] = result
let searchResult = []
if (ipApi.status === 'fulfilled') {
let { query: ip, country, regionName, city } = ipApi.value?.data || {}
searchResult.push({ ip, country, city: `${ regionName } ${ city }`, date })
}
if (ipwho.status === 'fulfilled') {
let { ip, country, region: regionName, city } = ipwho.value?.data || {}
searchResult.push({ ip, country, city: `${ regionName } ${ city }`, date })
}
if (ipdata.status === 'fulfilled') {
let { ip, country_name: country, region: regionName, city } = ipdata.value?.data || {}
searchResult.push({ ip, country, city: `${ regionName } ${ city }`, date })
}
if (ipinfo.status === 'fulfilled') {
let { ip, country, region: regionName, city } = ipinfo.value?.data || {}
searchResult.push({ ip, country, city: `${ regionName } ${ city }`, date })
}
if (ipgeolocation.status === 'fulfilled') {
let { ip, country_name: country, state_prov: regionName, city } = ipgeolocation.value?.data || {}
searchResult.push({ ip, country, city: `${ regionName } ${ city }`, date })
}
if (ipApi01.status === 'fulfilled') {
let { ip, country_name: country, region: regionName, city } = ipApi01.value?.data || {}
searchResult.push({ ip, country, city: `${ regionName } ${ city }`, date })
}
if (ip138.status === 'fulfilled') {
let [res] = ip138.value?.data?.data || []
let { origip: ip, location: country, city = '', regionName = '' } = res || {}
searchResult.push({ ip, country, city: `${ regionName } ${ city }`, date })
}
console.log(searchResult)
let validInfo = searchResult.find(item => Boolean(item.country))
consola.info('查询IP信息', validInfo)
return validInfo || { ip: '获取IP信息API出错,请排查或更新API', country: '未知', city: '未知', date }
} catch (error) {
// consola.error('getIpInfo Error: ', error)
return {
ip: '未知',
country: '未知',
city: '未知',
error
}
}
}
const getLocalNetIP = async () => {
try {
let ipUrls = [
'http://whois.pconline.com.cn/ipJson.jsp?json=true',
'https://www.ip.cn/api/index?ip=&type=0',
'https://freeipapi.com/api/json'
]
let result = await Promise.allSettled(ipUrls.map(url => axios.get(url)))
let [pconline, ipCN, freeipapi] = result
if (pconline.status === 'fulfilled') {
let ip = pconline.value?.data?.ip
if (ip) return ip
}
if (ipCN.status === 'fulfilled') {
let ip = ipCN.value?.data?.ip
consola.log('ipCN:', ip)
if (ip) return ip
}
if (freeipapi.status === 'fulfilled') {
let ip = pconline.value?.data?.ipAddress
if (ip) return ip
}
return null
} catch (error) {
console.error('getIpInfo Error: ', error?.message || error)
return null
}
}
function isLocalIP(ip) {
// Check if IPv4 or IPv6 address
const isIPv4 = net.isIPv4(ip)
const isIPv6 = net.isIPv6(ip)
// Local IPv4 ranges
const localIPv4Ranges = [
{ start: '10.0.0.0', end: '10.255.255.255' },
{ start: '172.16.0.0', end: '172.31.255.255' },
{ start: '192.168.0.0', end: '192.168.255.255' },
{ start: '127.0.0.0', end: '127.255.255.255' } // Loopback
]
// Local IPv6 ranges
const localIPv6Ranges = [
'::1', // Loopback
'fc00::', // Unique local address
'fd00::' // Unique local address
]
function isInRange(ip, start, end) {
const ipNum = ipToNumber(ip)
return ipNum >= ipToNumber(start) && ipNum <= ipToNumber(end)
}
function ipToNumber(ip) {
return ip.split('.').reduce((acc, octet) => (acc << 8) + parseInt(octet, 10), 0)
}
if (isIPv4) {
for (const range of localIPv4Ranges) {
if (isInRange(ip, range.start, range.end)) {
return true
}
}
}
if (isIPv6) {
if (localIPv6Ranges.includes(ip)) {
return true
}
// Handle IPv4-mapped IPv6 addresses (e.g., ::ffff:192.168.1.1)
if (ip.startsWith('::ffff:')) {
const ipv4Part = ip.split('::ffff:')[1]
if (ipv4Part && net.isIPv4(ipv4Part)) {
for (const range of localIPv4Ranges) {
if (isInRange(ipv4Part, range.start, range.end)) {
return true
}
}
}
}
}
return false
}
const throwError = ({ status = 500, msg = 'defalut error' } = {}) => {
const err = new Error(msg)
err.status = status // 主动抛错
throw err
}
const isIP = (ip = '') => {
const isIPv4 = /^((25[0-5]|2[0-4]\d|[01]?\d\d?)\.){3}(25[0-5]|2[0-4]\d|[01]?\d\d?)$/
const isIPv6 = /^([\da-fA-F]{1,4}:){7}[\da-fA-F]{1,4}|:((:[\dafAF]1,4)1,6|:)|:((:[\dafAF]1,4)1,6|:)|^[\da-fA-F]{1,4}:((:[\da-fA-F]{1,4}){1,5}|:)|([\dafAF]1,4:)2((:[\dafAF]1,4)1,4|:)|([\dafAF]1,4:)2((:[\dafAF]1,4)1,4|:)|^([\da-fA-F]{1,4}:){3}((:[\da-fA-F]{1,4}){1,3}|:)|([\dafAF]1,4:)4((:[\dafAF]1,4)1,2|:)|([\dafAF]1,4:)4((:[\dafAF]1,4)1,2|:)|^([\da-fA-F]{1,4}:){5}:([\da-fA-F]{1,4})?|([\dafAF]1,4:)6:|([\dafAF]1,4:)6:/
return isIPv4.test(ip) || isIPv6.test(ip)
}
const randomStr = (len) => {
len = len || 16
let str = 'ABCDEFGHJKMNPQRSTWXYZabcdefhijkmnprstwxyz2345678',
a = str.length,
res = ''
for (let i = 0; i < len; i++) res += str.charAt(Math.floor(Math.random() * a))
return res
}
// 获取UTC-x时间
const getUTCDate = (num = 8) => {
let date = new Date()
let now_utc = Date.UTC(date.getUTCFullYear(), date.getUTCMonth(),
date.getUTCDate(), date.getUTCHours() + num,
date.getUTCMinutes(), date.getUTCSeconds())
return new Date(now_utc)
}
const formatTimestamp = (timestamp = Date.now(), format = 'time') => {
if (typeof (timestamp) !== 'number') return '--'
let date = new Date(timestamp)
let padZero = (num) => String(num).padStart(2, '0')
let year = date.getFullYear()
let mounth = padZero(date.getMonth() + 1)
let day = padZero(date.getDate())
let hours = padZero(date.getHours())
let minute = padZero(date.getMinutes())
let second = padZero(date.getSeconds())
let weekday = ['周日', '周一', '周二', '周三', '周四', '周五', '周六']
let week = weekday[date.getDay()]
switch (format) {
case 'date':
return `${ year }-${ mounth }-${ day }`
case 'week':
return `${ year }-${ mounth }-${ day } ${ week }`
case 'hour':
return `${ year }-${ mounth }-${ day } ${ hours }`
case 'time':
return `${ year }-${ mounth }-${ day } ${ hours }:${ minute }:${ second }`
default:
return `${ year }-${ mounth }-${ day } ${ hours }:${ minute }:${ second }`
}
}
function resolvePath(dir, path) {
return path.resolve(dir, path)
}
let shellThrottle = (fn, delay = 1000) => {
let timer = null
let args = null
function throttled() {
args = arguments
if (!timer) {
timer = setTimeout(() => {
fn(...args)
timer = null
}, delay)
}
}
function delayMs() {
return new Promise(resolve => setTimeout(resolve, delay))
}
throttled.last = async () => {
await delayMs()
fn(...args)
}
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 = {
getNetIPInfo,
getLocalNetIP,
throwError,
isIP,
randomStr,
getUTCDate,
formatTimestamp,
resolvePath,
shellThrottle,
isProd,
isAllowedIp,
ping
}

View File

@ -0,0 +1,29 @@
const { AESDecryptAsync } = require('./encrypt')
const jwt = require('jsonwebtoken')
const { KeyDB } = require('./db-class')
const keyDB = new KeyDB().getInstance()
const enumLoginCode = {
SUCCESS: 1,
EXPIRES: -1,
ERROR_TOKEN: -2
}
// 校验token
const verifyAuthSync = async (token, clientIp) => {
consola.info('verifyAuthSync IP', clientIp)
try {
token = await AESDecryptAsync(token) // 先aes解密
const { commonKey } = await keyDB.findOneAsync({})
const { exp } = jwt.verify(token, commonKey)
if (Date.now() > (exp * 1000)) return { code: -1, msg: 'token expires' } // 过期
return { code: enumLoginCode.SUCCESS, msg: 'success' } // 验证成功
} catch (error) {
return { code: enumLoginCode.ERROR_TOKEN, msg: error } // token错误, 验证失败
}
}
module.exports = {
verifyAuthSync
}

2
server/bin/www Normal file → Executable file
View File

@ -1,3 +1,3 @@
#!/usr/bin/env node
console.log('start time', new Date())
require('../app/main.js')
require('../index.js')

4
server/index.js Normal file
View File

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

View File

@ -1,57 +1,59 @@
{
"name": "easynode-server",
"version": "1.1.0",
"description": "easynode-server",
"bin": "./bin/www",
"pkg": {
"outputPath": "dist",
"scripts": "./*",
"assets": "./*"
},
"scripts": {
"local": "cross-env EXEC_ENV=local nodemon ./app/main.js",
"server": "cross-env EXEC_ENV=production nodemon ./app/main.js",
"start": "pm2 start ./app/main.js",
"pkgwin": "pkg . -t node16-win-x64",
"pkglinux:x86": "pkg . -t node16-linux-x64",
"pkglinux:arm": "pkg . -t node16-linux-arm64"
},
"keywords": [],
"author": "",
"license": "ISC",
"nodemonConfig": {
"ignore": [
"*.json"
]
},
"dependencies": {
"@koa/cors": "^3.1.0",
"axios": "^0.21.4",
"crypto-js": "^4.1.1",
"global": "^4.4.0",
"is-ip": "^4.0.0",
"jsonwebtoken": "^8.5.1",
"koa": "^2.13.1",
"koa-body": "^4.2.0",
"koa-compose": "^4.1.0",
"koa-compress": "^5.1.0",
"koa-jwt": "^4.0.3",
"koa-router": "^10.0.0",
"koa-sslify": "^5.0.0",
"koa-static": "^5.0.0",
"koa2-connect-history-api-fallback": "^0.1.3",
"log4js": "^6.4.4",
"node-os-utils": "^1.3.6",
"node-rsa": "^1.1.1",
"node-schedule": "^2.1.0",
"socket.io": "^4.4.1",
"socket.io-client": "^4.5.1",
"ssh2": "^1.10.0"
},
"devDependencies": {
"cross-env": "^7.0.3",
"eslint": "^7.32.0",
"nodemon": "^2.0.15",
"pkg": "5.6"
}
}
{
"name": "server",
"version": "3.0.3",
"description": "easynode-server",
"bin": "./bin/www",
"scripts": {
"local": "cross-env EXEC_ENV=local nodemon index.js",
"prod": "cross-env EXEC_ENV=production nodemon index.js",
"start": "node ./index.js",
"lint": "eslint . --ext .js,.vue",
"lint:fix": "eslint . --ext .js,.jsx,.cjs,.mjs --fix"
},
"keywords": [],
"author": "",
"license": "ISC",
"nodemonConfig": {
"ignore": [
"*.json"
]
},
"dependencies": {
"@koa/cors": "^5.0.0",
"@seald-io/nedb": "^4.0.4",
"axios": "^1.7.4",
"consola": "^3.2.3",
"cross-env": "^7.0.3",
"crypto-js": "^4.2.0",
"dotenv": "^16.4.5",
"fs-extra": "^11.2.0",
"global": "^4.4.0",
"iconv-lite": "^0.6.3",
"jsonwebtoken": "^9.0.2",
"koa": "^2.15.3",
"koa-body": "^6.0.1",
"koa-compose": "^4.1.0",
"koa-compress": "^5.1.1",
"koa-jwt": "^4.0.4",
"koa-router": "^12.0.1",
"koa-sslify": "^5.0.1",
"koa-static": "^5.0.0",
"koa2-connect-history-api-fallback": "^0.1.3",
"log4js": "^6.9.1",
"node-os-utils": "^1.3.7",
"node-rsa": "^1.1.1",
"node-schedule": "^2.1.1",
"node-telegram-bot-api": "^0.66.0",
"nodemailer": "^6.9.14",
"qrcode": "^1.5.4",
"socket.io": "^4.7.5",
"socket.io-client": "^4.7.5",
"speakeasy": "^2.0.0",
"ssh2": "^1.15.0",
"ssh2-sftp-client": "^10.0.3"
},
"devDependencies": {
"eslint": "^8.56.0",
"nodemon": "^3.1.4"
}
}

File diff suppressed because it is too large Load Diff

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