Compare commits
92 Commits
Author | SHA1 | Date | |
---|---|---|---|
|
4808f6e218 | ||
|
9bd1cca518 | ||
|
64d5db8c56 | ||
|
37e1b891d3 | ||
|
50ed2a8569 | ||
|
84b5f1beb6 | ||
|
5f0e6e9ecc | ||
|
0cbe43ecdd | ||
|
0bef9b53af | ||
|
cbc6fa02ac | ||
|
9df142ccde | ||
|
6252f481d5 | ||
|
aaf79fe60a | ||
|
d149e947bc | ||
|
59b9938809 | ||
|
cb866c6d26 | ||
|
079c62b838 | ||
|
c04989b951 | ||
|
7c15d311c1 | ||
|
29fd0a5bbf | ||
|
2c41928f65 | ||
|
1fdf8c6a09 | ||
|
678a1e4d04 | ||
|
f0b492da26 | ||
|
70bdaa5b69 | ||
|
cdd741b7fd | ||
|
90ee38ff44 | ||
|
9b71b28e46 | ||
|
98d44e8ab4 | ||
|
dafb2cc5c9 | ||
|
5437486eba | ||
|
7aefa410dc | ||
|
5724ede172 | ||
|
a72ab84cee | ||
|
6273a9498e | ||
|
c8898e6acb | ||
|
fe5e75878a | ||
|
e9a567c3fe | ||
|
846c19ceb3 | ||
|
fc42e1b29a | ||
|
6b5f882808 | ||
|
d8f0938a11 | ||
|
1a09a1276c | ||
|
53cc1628c2 | ||
|
70e867410f | ||
|
203750c133 | ||
|
94097a1c6d | ||
|
d184a8bdaa | ||
|
20917da5a7 | ||
|
808a785d5b | ||
|
0460af5c48 | ||
|
da36ed9d1b | ||
|
e784092c1a | ||
|
d54b682f7a | ||
|
09e2c39132 | ||
|
3e8a9ac74a | ||
|
0bc0284559 | ||
|
6b11e28b9b | ||
|
4c1d0fc291 | ||
|
7028205d9d | ||
|
27957c3fbb | ||
|
c61585fd4d | ||
|
aaddf08dd8 | ||
|
3de5537448 | ||
|
4c7a214c55 | ||
|
51b3c58673 | ||
|
7e45186d22 | ||
|
b1ded4991f | ||
|
e7a2cdc3e7 | ||
|
c2a717619b | ||
|
e4d26c46e2 | ||
|
0f191398db | ||
|
57c252dc99 | ||
|
c9f75e6b30 | ||
|
56cc7dc3d5 | ||
|
eb9ecb7ad5 | ||
|
510e660b17 | ||
|
284c7e9398 | ||
|
b3a6f19ddd | ||
|
124f3b32ec | ||
|
9f04c8adbb | ||
|
997761f2fc | ||
|
9ce2a3bf5f | ||
|
9f40aeddf5 | ||
|
e333fa5aa3 | ||
|
65bb8bb6c7 | ||
|
e0673293ef | ||
|
a6b2ed5d8f | ||
|
140be0ca53 | ||
|
fae1df601d | ||
|
8284e942a5 | ||
|
3266edd418 |
2
.github/workflows/client-builder.yml
vendored
@ -10,7 +10,7 @@ on:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-22.04
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout Repository
|
- name: Checkout Repository
|
||||||
uses: actions/checkout@v3
|
uses: actions/checkout@v3
|
||||||
|
4
.gitignore
vendored
@ -11,3 +11,7 @@ server/app/db/*
|
|||||||
plan.md
|
plan.md
|
||||||
.env
|
.env
|
||||||
.env.local
|
.env.local
|
||||||
|
.env-encrypt-key
|
||||||
|
*clear.js
|
||||||
|
local-script
|
||||||
|
版本发布.md
|
||||||
|
5
.vscode/settings.json
vendored
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"cSpell.ignoreWords": [
|
||||||
|
"Onekey"
|
||||||
|
]
|
||||||
|
}
|
99
CHANGELOG.md
@ -1,3 +1,101 @@
|
|||||||
|
## [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)
|
## [2.2.1](https://github.com/chaos-zhu/easynode/releases) (2024-08-18)
|
||||||
|
|
||||||
### Features
|
### Features
|
||||||
@ -6,6 +104,7 @@
|
|||||||
* 批量脚本下发执行结果通知重复的bug ✔
|
* 批量脚本下发执行结果通知重复的bug ✔
|
||||||
* 修复交换内存占比的bug ✔
|
* 修复交换内存占比的bug ✔
|
||||||
* 优化服务端代码引用 ✔
|
* 优化服务端代码引用 ✔
|
||||||
|
* 修复Code scanning提到的依赖风险 ✔
|
||||||
|
|
||||||
## [2.2.0](https://github.com/chaos-zhu/easynode/releases) (2024-08-17)
|
## [2.2.0](https://github.com/chaos-zhu/easynode/releases) (2024-08-17)
|
||||||
|
|
||||||
|
@ -4,10 +4,7 @@ COPY ./web .
|
|||||||
COPY yarn.lock .
|
COPY yarn.lock .
|
||||||
RUN yarn
|
RUN yarn
|
||||||
RUN yarn build
|
RUN yarn build
|
||||||
# RUN find ../server/app/static -type f ! -name '.gitkeep' -exec rm -f {} +
|
|
||||||
# RUN mv dist/* ../server/app/static
|
|
||||||
|
|
||||||
# 在这里加上builder_server
|
|
||||||
FROM node:20.16-alpine3.20 AS builder_server
|
FROM node:20.16-alpine3.20 AS builder_server
|
||||||
WORKDIR /easynode/server
|
WORKDIR /easynode/server
|
||||||
COPY ./server .
|
COPY ./server .
|
||||||
@ -16,6 +13,7 @@ COPY --from=builder_web /easynode/web/dist ./app/static
|
|||||||
RUN yarn
|
RUN yarn
|
||||||
|
|
||||||
FROM node:20.16-alpine3.20
|
FROM node:20.16-alpine3.20
|
||||||
|
RUN apk add --no-cache iputils
|
||||||
WORKDIR /easynode
|
WORKDIR /easynode
|
||||||
COPY --from=builder_server /easynode/server .
|
COPY --from=builder_server /easynode/server .
|
||||||
ENV HOST=0.0.0.0
|
ENV HOST=0.0.0.0
|
||||||
|
157
README.md
@ -1,95 +1,94 @@
|
|||||||
|
<div align="center">
|
||||||
|
|
||||||
# EasyNode
|
# EasyNode
|
||||||
|
|
||||||
> [!WARNING]
|
_✨ 一个多功能Linux服务器WEB终端面板(webSSH&webSFTP) ✨_
|
||||||
> 初次部署EasyNode,登录系统后务必记得修改默认账户密码 `admin/admin`!
|
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
> [!WARNING]
|
<p align="center">
|
||||||
> 强烈建议使用 **iptables** 限制IP访问,谨慎暴露面板服务到公网!
|
<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>
|
||||||
|
|
||||||
> [!NOTE]
|
<p align="center">
|
||||||
> webssh与监控服务都将以`该服务器作为中转`。中国大陆连接建议使用香港、新加坡、日本、韩国等地区的低延迟服务器来安装服务端面板
|
<a href="#功能">功能</a>
|
||||||
|
·
|
||||||
- [功能](#功能)
|
<a href="#面板展示">面板展示</a>
|
||||||
- [安装](#安装)
|
·
|
||||||
- [服务端安装](#服务端安装)
|
<a href="#项目部署">项目部署</a>
|
||||||
- [监控服务安装](#监控服务安装)
|
·
|
||||||
- [版本日志](#版本日志)
|
<a href="#监控服务安装">监控服务安装</a>
|
||||||
- [开发](#开发)
|
·
|
||||||
- [QA](#QA)
|
<a href="#安全与建议">安全与建议</a>
|
||||||
- [捐赠](#捐赠)
|
·
|
||||||
- [License](#license)
|
<a href="#常见问题">常见问题</a>
|
||||||
|
<!-- ·
|
||||||
|
<a href="#Plus功能">Plus版功能</a> -->
|
||||||
|
</p>
|
||||||
|
|
||||||
## 功能
|
## 功能
|
||||||
|
|
||||||
- [x] 功能完善的SSH终端&SFTP
|
+ [x] 功能完善的**SSH终端**&**SFTP**
|
||||||
- [x] 批量导入导出实例(Xshell&FinalShell&EasyNode)
|
+ [x] 批量导入、导出、编辑服务器配置、脚本等
|
||||||
- [x] 实例分组
|
+ [x] 脚本库
|
||||||
- [x] 凭据托管
|
+ [x] 实例分组
|
||||||
- [x] 邮件通知
|
+ [x] 凭据托管
|
||||||
- [x] 服务器状态推送
|
+ [x] 多渠道通知
|
||||||
- [x] 脚本库
|
+ [x] 批量下发指令
|
||||||
- [x] 批量指令
|
+ [x] 自定义终端主题
|
||||||
- [x] 终端主题背景图
|
|
||||||
|
|
||||||

|
## 面板展示
|
||||||
|
|
||||||
## 安装
|

|
||||||
|
|
||||||
### 服务端安装
|
## 项目部署
|
||||||
|
|
||||||
- 占用端口:8082 推荐使用docker镜像安装
|
- 默认账户密码 `admin/admin`
|
||||||
|
- web端口:8082
|
||||||
|
|
||||||
#### Docker部署
|
### docker镜像
|
||||||
|
|
||||||
```shell
|
```shell
|
||||||
docker run -d --net=host --name=easynode-server -v $PWD/easynode/db:/easynode/app/db chaoszhu/easynode
|
docker run -d -p 8082:8082 --restart=always -v /root/easynode/db:/easynode/app/db chaoszhu/easynode
|
||||||
# 容器支持使用-p 8082:8082映射端口, 但是无法记录登录IP
|
|
||||||
```
|
```
|
||||||
访问:http://yourip:8082
|
环境变量:
|
||||||
|
- `DEBUG`: 启动debug日志 0:关闭 1:开启, 默认关闭
|
||||||
|
- `ALLOWED_IPS`: 可以访问服务的IP白名单, 多个使用逗号分隔, 支持填写部分ip前缀, 例如: `-e ALLOWED_IPS=127.0.0.1,196.168`
|
||||||
|
|
||||||
#### 手动部署
|
## 监控服务安装
|
||||||
|
|
||||||
依赖Nodejs版本 > 20+
|
- 监控服务用于实时向服务端&web端推送**系统、公网IP、CPU、内存、硬盘、网卡**等基础信息
|
||||||
|
|
||||||
```shell
|
- 默认端口:**22022**
|
||||||
git clone https://github.com/chaos-zhu/easynode
|
|
||||||
cd easynode
|
|
||||||
yarn
|
|
||||||
cd web
|
|
||||||
yarn build
|
|
||||||
mv dist/* ../server/app/static
|
|
||||||
cd ../server
|
|
||||||
yarn start
|
|
||||||
# 后台运行需安装pm2
|
|
||||||
pm2 start index.js --name easynode-server
|
|
||||||
```
|
|
||||||
|
|
||||||
访问:http://yourip:8082
|
|
||||||
|
|
||||||
- 查看日志:`pm2 log easynode-server`
|
|
||||||
- 启动服务:`pm2 start easynode-server`
|
|
||||||
- 停止服务:`pm2 stop easynode-server`
|
|
||||||
- 删除服务:`pm2 delete easynode-server`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 监控服务安装
|
|
||||||
|
|
||||||
- 监控服务用于实时向服务端推送**系统、公网IP、CPU、内存、硬盘、网卡**等基础信息,不安装不影响使用面板,但是无法实时同步cpu占用、实时网速、硬盘容量等有用信息。
|
|
||||||
|
|
||||||
- 占用端口:**22022**
|
|
||||||
|
|
||||||
> 安装
|
> 安装
|
||||||
|
|
||||||
```shell
|
```shell
|
||||||
curl -o- https://mirror.ghproxy.com/https://raw.githubusercontent.com/chaos-zhu/easynode/main/client/easynode-client-install.sh | bash
|
# 使用默认端口22022安装
|
||||||
|
curl -o- https://git.221022.xyz/https://raw.githubusercontent.com/chaos-zhu/easynode/main/client/easynode-client-install.sh | bash
|
||||||
|
|
||||||
|
# 使用自定义端口安装, 例如54321
|
||||||
|
curl -o- https://git.221022.xyz/https://raw.githubusercontent.com/chaos-zhu/easynode/main/client/easynode-client-install.sh | bash -s -- 54321
|
||||||
```
|
```
|
||||||
|
|
||||||
> 卸载
|
> 卸载
|
||||||
|
|
||||||
```shell
|
```shell
|
||||||
curl -o- https://mirror.ghproxy.com/https://raw.githubusercontent.com/chaos-zhu/easynode/main/client/easynode-client-uninstall.sh | bash
|
curl -o- https://git.221022.xyz/https://raw.githubusercontent.com/chaos-zhu/easynode/main/client/easynode-client-uninstall.sh | bash
|
||||||
```
|
```
|
||||||
|
|
||||||
> 查看监控服务状态:`systemctl status easynode-client`
|
> 查看监控服务状态:`systemctl status easynode-client`
|
||||||
@ -100,27 +99,25 @@ curl -o- https://mirror.ghproxy.com/https://raw.githubusercontent.com/chaos-zhu/
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 版本日志
|
|
||||||
|
|
||||||
- [CHANGELOG](./CHANGELOG.md)
|
## 安全与建议
|
||||||
|
|
||||||
## 开发
|
首先声明,任何系统无法保障没有bug的存在,EasyNode也一样。
|
||||||
|
|
||||||
1. 拉取代码,环境 `nodejs``>=20`
|
面板提供MFA2功能,并且可配置访问此服务的IP白名单, 如需加强可以使用**iptables**进一步限制IP访问。
|
||||||
2. cd到项目根目录,`yarn install` 执行安装依赖
|
如果需要更高级别的安全性,建议面板服务不要暴露到公网。
|
||||||
3. `yarn dev`启动项目
|
|
||||||
4. web: `http://localhost:18090/`
|
|
||||||
|
|
||||||
## QA
|
webssh与监控服务都将以`该服务器作为中转`。中国大陆用户建议使用香港、新加坡、日本、韩国等地区的低延迟服务器来安装服务端面板
|
||||||
|
|
||||||
|
## 常见问题
|
||||||
|
|
||||||
- [QA](./Q%26A.md)
|
- [QA](./Q%26A.md)
|
||||||
|
|
||||||
## 捐赠
|
<!-- ## Plus版功能
|
||||||
|
|
||||||
如果您认为此项目帮到了您, 您可以请我喝杯阔乐~
|
- 跳板机功能,拯救被墙实例与龟速终端输入
|
||||||
|
- 本地socket断开自动重连,无需手动重新连接
|
||||||

|
- 批量修改实例配置(优化版)
|
||||||
|
- 脚本库批量导出导入
|
||||||
## License
|
- 凭据管理支持解密带密码保护的密钥
|
||||||
|
- 提出的功能需求享有更高的开发优先级 -->
|
||||||
[MIT](LICENSE). Copyright (c).
|
|
||||||
|
@ -1,3 +1,3 @@
|
|||||||
module.exports = {
|
module.exports = {
|
||||||
httpPort: 22022
|
defaultPort: 22022
|
||||||
}
|
}
|
||||||
|
@ -1,14 +1,15 @@
|
|||||||
const http = require('http')
|
const http = require('http')
|
||||||
const Koa = require('koa')
|
const Koa = require('koa')
|
||||||
const { httpPort } = require('./config')
|
const { defaultPort } = require('./config')
|
||||||
const wsOsInfo = require('./socket/monitor')
|
const wsOsInfo = require('./socket/monitor')
|
||||||
|
|
||||||
const httpServer = () => {
|
const httpServer = () => {
|
||||||
const app = new Koa()
|
const app = new Koa()
|
||||||
const server = http.createServer(app.callback())
|
const server = http.createServer(app.callback())
|
||||||
serverHandler(app, server)
|
serverHandler(app, server)
|
||||||
server.listen(httpPort, () => {
|
const port = process.env.clientPort || defaultPort
|
||||||
console.log(`Server(http) is running on port:${ httpPort }`)
|
server.listen(port, () => {
|
||||||
|
console.log(`Server(http) is running on port:${ port }`)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -5,13 +5,18 @@ if [ "$(id -u)" != "0" ] ; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
clientPort=${clientPort:-22022}
|
||||||
SERVER_NAME=easynode-client
|
SERVER_NAME=easynode-client
|
||||||
FILE_PATH=/root/local/easynode-client
|
FILE_PATH=/root/local/easynode-client
|
||||||
SERVICE_PATH=/etc/systemd/system
|
SERVICE_PATH=/etc/systemd/system
|
||||||
CLIENT_VERSION=client-2024-08-17 # 目前监控客户端版本发布需手动更改为最新版本号
|
CLIENT_VERSION=client-2024-10-13 # 目前监控客户端版本发布需手动更改为最新版本号
|
||||||
SERVER_PROXY="https://mirror.ghproxy.com/"
|
SERVER_PROXY="https://git.221022.xyz/"
|
||||||
|
|
||||||
echo "***********************开始安装EasyNode监控客户端端,当前版本号: ${CLIENT_VERSION}***********************"
|
if [ ! -z "$1" ]; then
|
||||||
|
clientPort=$1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "***********************开始安装EasyNode监控客户端端,当前版本号: ${CLIENT_VERSION}, 端口: ${clientPort}***********************"
|
||||||
|
|
||||||
systemctl status ${SERVER_NAME} > /dev/null 2>&1
|
systemctl status ${SERVER_NAME} > /dev/null 2>&1
|
||||||
if [ $? != 4 ]
|
if [ $? != 4 ]
|
||||||
@ -80,6 +85,8 @@ echo "***********************下载成功***********************"
|
|||||||
chmod +x ${FILE_PATH}/${SERVER_NAME}
|
chmod +x ${FILE_PATH}/${SERVER_NAME}
|
||||||
chmod 777 ${FILE_PATH}/${SERVER_NAME}.service
|
chmod 777 ${FILE_PATH}/${SERVER_NAME}.service
|
||||||
|
|
||||||
|
sed -i "s/clientPort=22022/clientPort=${clientPort}/g" ${FILE_PATH}/${SERVER_NAME}.service
|
||||||
|
|
||||||
# echo "***********************移动service&reload***********************"
|
# echo "***********************移动service&reload***********************"
|
||||||
mv ${FILE_PATH}/${SERVER_NAME}.service ${SERVICE_PATH}
|
mv ${FILE_PATH}/${SERVER_NAME}.service ${SERVICE_PATH}
|
||||||
|
|
||||||
@ -89,7 +96,6 @@ systemctl daemon-reload
|
|||||||
echo "***********************启动服务***********************"
|
echo "***********************启动服务***********************"
|
||||||
systemctl start ${SERVER_NAME}
|
systemctl start ${SERVER_NAME}
|
||||||
|
|
||||||
|
|
||||||
# echo "***********************设置开机启动***********************"
|
# echo "***********************设置开机启动***********************"
|
||||||
systemctl enable ${SERVER_NAME}
|
systemctl enable ${SERVER_NAME}
|
||||||
|
|
||||||
|
@ -1,7 +1,8 @@
|
|||||||
[Unit]
|
[Unit]
|
||||||
Description=easynode client server port_22022
|
Description=easynode client server
|
||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
|
Environment="clientPort=22022"
|
||||||
ExecStart=/root/local/easynode-client/easynode-client
|
ExecStart=/root/local/easynode-client/easynode-client
|
||||||
WorkingDirectory=/root/local/easynode-client
|
WorkingDirectory=/root/local/easynode-client
|
||||||
Restart=always
|
Restart=always
|
||||||
|
@ -22,7 +22,8 @@
|
|||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "concurrently \"yarn workspace web run dev\" \"yarn workspace server run local\"",
|
"dev": "concurrently \"yarn workspace web run dev\" \"yarn workspace server run local\"",
|
||||||
"clean": "rimraf web/node_modules server/node_modules client/node_modules node_modules"
|
"clean": "rimraf web/node_modules server/node_modules client/node_modules node_modules",
|
||||||
|
"encrypt": "node ./local-script/encrypt-file.js"
|
||||||
},
|
},
|
||||||
"bugs": {
|
"bugs": {
|
||||||
"url": "https://github.com/chaos-zhu/easynode/issues"
|
"url": "https://github.com/chaos-zhu/easynode/issues"
|
||||||
|
@ -1,2 +1,8 @@
|
|||||||
# 启动debug日志 0:关闭 1:开启
|
# 启动debug日志 0:关闭 1:开启
|
||||||
DEBUG=1
|
DEBUG=1
|
||||||
|
|
||||||
|
# 访问IP限制
|
||||||
|
allowedIPs=['127.0.0.1']
|
||||||
|
|
||||||
|
# 激活PLUS功能的授权码
|
||||||
|
PLUS_KEY=
|
||||||
|
@ -28,6 +28,9 @@ module.exports = {
|
|||||||
quotes: ['error', 'single'], // 引号:single单引 double双引
|
quotes: ['error', 'single'], // 引号:single单引 double双引
|
||||||
semi: ['error', 'never'], // 结尾分号:never禁止 always必须
|
semi: ['error', 'never'], // 结尾分号:never禁止 always必须
|
||||||
'comma-dangle': ['error', 'never'], // 对象拖尾逗号
|
'comma-dangle': ['error', 'never'], // 对象拖尾逗号
|
||||||
|
'space-before-blocks': ['error', 'always'],
|
||||||
|
'space-in-parens': ['error', 'never'],
|
||||||
|
'keyword-spacing': ['error', { 'before': true, 'after': true }],
|
||||||
'no-redeclare': ['error', { builtinGlobals: true }], // 禁止重复对象声明
|
'no-redeclare': ['error', { builtinGlobals: true }], // 禁止重复对象声明
|
||||||
'no-multi-assign': 0,
|
'no-multi-assign': 0,
|
||||||
'no-restricted-globals': 0,
|
'no-restricted-globals': 0,
|
||||||
|
@ -4,7 +4,7 @@ consola.info('debug日志:', process.env.DEBUG === '1' ? '开启' : '关闭')
|
|||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
httpPort: 8082,
|
httpPort: 8082,
|
||||||
clientPort: 22022, // 暂不支持更改
|
defaultClientPort: 22022,
|
||||||
uploadDir: path.join(process.cwd(),'app/db'),
|
uploadDir: path.join(process.cwd(),'app/db'),
|
||||||
staticDir: path.join(process.cwd(),'app/static'),
|
staticDir: path.join(process.cwd(),'app/static'),
|
||||||
sftpCacheDir: path.join(process.cwd(),'app/socket/sftp-cache'),
|
sftpCacheDir: path.join(process.cwd(),'app/socket/sftp-cache'),
|
||||||
@ -16,6 +16,8 @@ module.exports = {
|
|||||||
notifyDBPath: path.join(process.cwd(),'app/db/notify.db'),
|
notifyDBPath: path.join(process.cwd(),'app/db/notify.db'),
|
||||||
notifyConfigDBPath: path.join(process.cwd(),'app/db/notify-config.db'),
|
notifyConfigDBPath: path.join(process.cwd(),'app/db/notify-config.db'),
|
||||||
onekeyDBPath: path.join(process.cwd(),'app/db/onekey.db'),
|
onekeyDBPath: path.join(process.cwd(),'app/db/onekey.db'),
|
||||||
|
logDBPath: path.join(process.cwd(),'app/db/log.db'),
|
||||||
|
plusDBPath: path.join(process.cwd(),'app/db/plus.db'),
|
||||||
apiPrefix: '/api/v1',
|
apiPrefix: '/api/v1',
|
||||||
logConfig: {
|
logConfig: {
|
||||||
outDir: path.join(process.cwd(),'./app/logs'),
|
outDir: path.join(process.cwd(),'./app/logs'),
|
||||||
|
@ -1,12 +1,12 @@
|
|||||||
[
|
[
|
||||||
{
|
{
|
||||||
"name": "easynode监控服务安装",
|
"name": "easynode监控服务安装",
|
||||||
"command": "curl -o- https://mirror.ghproxy.com/https://raw.githubusercontent.com/chaos-zhu/easynode/main/client/easynode-client-install.sh | bash",
|
"command": "curl -o- https://git.221022.xyz/https://raw.githubusercontent.com/chaos-zhu/easynode/main/client/easynode-client-install.sh | bash",
|
||||||
"description": "easynode-监控服务-安装脚本"
|
"description": "easynode-监控服务-安装脚本"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "easynode监控服务卸载",
|
"name": "easynode监控服务卸载",
|
||||||
"command": "curl -o- https://mirror.ghproxy.com/https://raw.githubusercontent.com/chaos-zhu/easynode/main/client/easynode-client-uninstall.sh | bash",
|
"command": "curl -o- https://git.221022.xyz/https://raw.githubusercontent.com/chaos-zhu/easynode/main/client/easynode-client-uninstall.sh | bash",
|
||||||
"description": "easynode-监控服务-卸载脚本"
|
"description": "easynode-监控服务-卸载脚本"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
@ -1,10 +1,11 @@
|
|||||||
const { readGroupList, writeGroupList, readHostList, writeHostList } = require('../utils/storage')
|
const { HostListDB, GroupDB } = require('../utils/db-class')
|
||||||
|
|
||||||
|
const hostListDB = new HostListDB().getInstance()
|
||||||
|
const groupDB = new GroupDB().getInstance()
|
||||||
|
|
||||||
async function getGroupList({ res }) {
|
async function getGroupList({ res }) {
|
||||||
let data = await readGroupList()
|
let data = await groupDB.findAsync({})
|
||||||
data = data.map(item => {
|
data = data.map(item => ({ ...item, id: item._id }))
|
||||||
return { ...item, id: item._id }
|
|
||||||
})
|
|
||||||
data?.sort((a, b) => Number(b.index || 0) - Number(a.index || 0))
|
data?.sort((a, b) => Number(b.index || 0) - Number(a.index || 0))
|
||||||
res.success({ data })
|
res.success({ data })
|
||||||
}
|
}
|
||||||
@ -12,10 +13,8 @@ async function getGroupList({ res }) {
|
|||||||
const addGroupList = async ({ res, request }) => {
|
const addGroupList = async ({ res, request }) => {
|
||||||
let { body: { name, index } } = request
|
let { body: { name, index } } = request
|
||||||
if (!name) return res.fail({ data: false, msg: '参数错误' })
|
if (!name) return res.fail({ data: false, msg: '参数错误' })
|
||||||
let groupList = await readGroupList()
|
|
||||||
let group = { name, index }
|
let group = { name, index }
|
||||||
groupList.push(group)
|
await groupDB.insertAsync(group)
|
||||||
await writeGroupList(groupList)
|
|
||||||
res.success({ data: '添加成功' })
|
res.success({ data: '添加成功' })
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -23,34 +22,26 @@ const updateGroupList = async ({ res, request }) => {
|
|||||||
let { params: { id } } = request
|
let { params: { id } } = request
|
||||||
let { body: { name, index } } = request
|
let { body: { name, index } } = request
|
||||||
if (!id || !name) return res.fail({ data: false, msg: '参数错误' })
|
if (!id || !name) return res.fail({ data: false, msg: '参数错误' })
|
||||||
let groupList = await readGroupList()
|
let target = await groupDB.findOneAsync({ _id: id })
|
||||||
let idx = groupList.findIndex(item => item._id === id)
|
if (!target) return res.fail({ data: false, msg: `分组ID${ id }不存在` })
|
||||||
if (idx === -1) return res.fail({ data: false, msg: `分组ID${ id }不存在` })
|
await groupDB.updateAsync({ _id: id }, { name, index: Number(index) || 0 })
|
||||||
const { _id } = groupList[idx]
|
|
||||||
let group = { _id, name, index: Number(index) || 0 }
|
|
||||||
groupList.splice(idx, 1, group)
|
|
||||||
await writeGroupList(groupList)
|
|
||||||
res.success({ data: '修改成功' })
|
res.success({ data: '修改成功' })
|
||||||
}
|
}
|
||||||
|
|
||||||
const removeGroup = async ({ res, request }) => {
|
const removeGroup = async ({ res, request }) => {
|
||||||
let { params: { id } } = request
|
let { params: { id } } = request
|
||||||
if (id === 'default') return res.fail({ data: false, msg: '保留分组, 禁止删除' })
|
if (id === 'default') return res.fail({ data: false, msg: '保留分组, 禁止删除' })
|
||||||
let groupList = await readGroupList()
|
|
||||||
let idx = groupList.findIndex(item => item._id === id)
|
|
||||||
if (idx === -1) return res.fail({ msg: '分组不存在' })
|
|
||||||
|
|
||||||
// 移除分组将所有该分组下host分配到default中去
|
// 移除分组将所有该分组下host分配到default中去
|
||||||
let hostList = await readHostList()
|
let hostList = await hostListDB.findAsync({})
|
||||||
hostList = hostList?.map((item) => {
|
if (Array.isArray(hostList) && hostList.length > 0) {
|
||||||
if (item.group === groupList[idx]._id) item.group = 'default'
|
for (let item of hostList) {
|
||||||
return item
|
if (item.group === id) {
|
||||||
})
|
item.group = 'default'
|
||||||
await writeHostList(hostList)
|
await hostListDB.updateAsync({ _id: item._id }, item)
|
||||||
|
}
|
||||||
groupList.splice(idx, 1)
|
}
|
||||||
await writeGroupList(groupList)
|
}
|
||||||
|
await groupDB.removeAsync({ _id: id })
|
||||||
res.success({ data: '移除成功' })
|
res.success({ data: '移除成功' })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,16 +1,17 @@
|
|||||||
const { readHostList, writeHostList } = require('../utils/storage')
|
const path = require('path')
|
||||||
const { RSADecryptSync, AESEncryptSync, AESDecryptSync } = require('../utils/encrypt')
|
const decryptAndExecuteAsync = require('../utils/decrypt-file')
|
||||||
|
const { RSADecryptAsync, AESEncryptAsync, AESDecryptAsync } = require('../utils/encrypt')
|
||||||
|
const { HostListDB } = require('../utils/db-class')
|
||||||
|
const hostListDB = new HostListDB().getInstance()
|
||||||
|
|
||||||
async function getHostList({ res }) {
|
async function getHostList({ res }) {
|
||||||
// console.log('get-host-list')
|
let data = await hostListDB.findAsync({})
|
||||||
let data = await readHostList()
|
|
||||||
data?.sort((a, b) => Number(b.index || 0) - Number(a.index || 0))
|
data?.sort((a, b) => Number(b.index || 0) - Number(a.index || 0))
|
||||||
for (const item of data) {
|
for (const item of data) {
|
||||||
try {
|
try {
|
||||||
let { username, port, authType, _id: id, credential } = item
|
let { authType, _id: id, credential } = item
|
||||||
// console.log('解密凭证title: ', credential)
|
if (credential) credential = await AESDecryptAsync(credential)
|
||||||
if (credential) credential = await AESDecryptSync(credential)
|
const isConfig = Boolean(authType && item[authType])
|
||||||
const isConfig = Boolean(username && port && (item[authType]))
|
|
||||||
Object.assign(item, { id, isConfig, password: '', privateKey: '', credential })
|
Object.assign(item, { id, isConfig, password: '', privateKey: '', credential })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
consola.error('getHostList error: ', error.message)
|
consola.error('getHostList error: ', error.message)
|
||||||
@ -19,125 +20,66 @@ async function getHostList({ res }) {
|
|||||||
res.success({ data })
|
res.success({ data })
|
||||||
}
|
}
|
||||||
|
|
||||||
async function addHost({
|
async function addHost({ res, request }) {
|
||||||
res, request
|
let { body } = request
|
||||||
}) {
|
if (!body.name || !body.host) return res.fail({ msg: 'missing params: name or host' })
|
||||||
let {
|
let newRecord = { ...body }
|
||||||
body: {
|
const { authType, tempKey } = newRecord
|
||||||
name, host: newHost, index, expired, expiredNotify, group, consoleUrl, remark,
|
if (newRecord[authType] && tempKey) {
|
||||||
port, username, authType, password, privateKey, credential, command, tempKey
|
const clearTempKey = await RSADecryptAsync(tempKey)
|
||||||
|
const clearSSHKey = await AESDecryptAsync(newRecord[authType], clearTempKey)
|
||||||
|
newRecord[authType] = await AESEncryptAsync(clearSSHKey)
|
||||||
}
|
}
|
||||||
} = request
|
await hostListDB.insertAsync(newRecord)
|
||||||
// console.log(request)
|
|
||||||
if (!newHost || !name) return res.fail({ msg: 'missing params: name or host' })
|
|
||||||
let hostList = await readHostList()
|
|
||||||
if (hostList?.some(({ host }) => host === newHost)) return res.fail({ msg: `主机${ newHost }已存在` })
|
|
||||||
let record = {
|
|
||||||
name, host: newHost, index, expired, expiredNotify, group, consoleUrl, remark,
|
|
||||||
port, username, authType, password, privateKey, credential, command
|
|
||||||
}
|
|
||||||
if (record[authType]) {
|
|
||||||
const clearTempKey = await RSADecryptSync(tempKey)
|
|
||||||
console.log('clearTempKey:', clearTempKey)
|
|
||||||
const clearSSHKey = await AESDecryptSync(record[authType], clearTempKey)
|
|
||||||
console.log(`${ authType }原密文: `, clearSSHKey)
|
|
||||||
record[authType] = await AESEncryptSync(clearSSHKey)
|
|
||||||
// console.log(`${ authType }__commonKey加密存储: `, record[authType])
|
|
||||||
}
|
|
||||||
hostList.push(record)
|
|
||||||
await writeHostList(hostList)
|
|
||||||
res.success()
|
res.success()
|
||||||
}
|
}
|
||||||
|
|
||||||
async function updateHost({ res, request }) {
|
async function updateHost({ res, request }) {
|
||||||
let {
|
let {
|
||||||
body: {
|
body
|
||||||
hosts,
|
|
||||||
host: newHost, name: newName, index, oldHost, expired, expiredNotify, group, consoleUrl, remark,
|
|
||||||
port, username, authType, password, privateKey, credential, command, tempKey
|
|
||||||
}
|
|
||||||
} = request
|
} = request
|
||||||
let isBatch = Array.isArray(hosts)
|
if (typeof body !== 'object') return res.fail({ msg: '参数错误' })
|
||||||
if (isBatch) {
|
const updateFiled = { ...body }
|
||||||
if (!hosts.length) return res.fail({ msg: 'hosts为空' })
|
const { id, authType, tempKey } = updateFiled
|
||||||
let hostList = await readHostList()
|
if (authType && updateFiled[authType]) {
|
||||||
// console.log('批量修改实例')
|
const clearTempKey = await RSADecryptAsync(tempKey)
|
||||||
let newHostList = []
|
const clearSSHKey = await AESDecryptAsync(updateFiled[authType], clearTempKey)
|
||||||
for (let oldRecord of hostList) {
|
updateFiled[authType] = await AESEncryptAsync(clearSSHKey)
|
||||||
let record = hosts.find(item => item.host === oldRecord.host)
|
delete updateFiled.tempKey
|
||||||
if (!record) {
|
|
||||||
newHostList.push(oldRecord)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
let { authType } = record
|
|
||||||
// 如果存在原认证方式则保存下来
|
|
||||||
if (!record[authType] && oldRecord[authType]) {
|
|
||||||
record[authType] = oldRecord[authType]
|
|
||||||
} else {
|
} else {
|
||||||
const clearTempKey = await RSADecryptSync(record.tempKey)
|
delete updateFiled.authType
|
||||||
// console.log('批量解密tempKey:', clearTempKey)
|
delete updateFiled.password
|
||||||
const clearSSHKey = await AESDecryptSync(record[authType], clearTempKey)
|
delete updateFiled.privateKey
|
||||||
// console.log(`${ authType }原密文: `, clearSSHKey)
|
delete updateFiled.credential
|
||||||
record[authType] = await AESEncryptSync(clearSSHKey)
|
|
||||||
// console.log(`${ authType }__commonKey加密存储: `, record[authType])
|
|
||||||
}
|
}
|
||||||
newHostList.push(Object.assign(oldRecord, record))
|
console.log('updateFiled: ', updateFiled)
|
||||||
}
|
await hostListDB.updateAsync({ _id: id }, { $set: { ...updateFiled } })
|
||||||
await writeHostList(newHostList)
|
res.success({ msg: '修改成功' })
|
||||||
return res.success({ msg: '批量修改成功' })
|
|
||||||
}
|
|
||||||
if (!newHost || !newName || !oldHost) return res.fail({ msg: '参数错误' })
|
|
||||||
let hostList = await readHostList()
|
|
||||||
let record = {
|
|
||||||
name: newName, host: newHost, index, expired, expiredNotify, group, consoleUrl, remark,
|
|
||||||
port, username, authType, password, privateKey, credential, command
|
|
||||||
}
|
|
||||||
if (!hostList.some(({ host }) => host === oldHost)) return res.fail({ msg: `原实例[${ oldHost }]不存在,请尝试添加实例` })
|
|
||||||
|
|
||||||
let idx = hostList.findIndex(({ host }) => host === oldHost)
|
|
||||||
const oldRecord = hostList[idx]
|
|
||||||
// 如果存在原认证方式则保存下来
|
|
||||||
if (!record[authType] && oldRecord[authType]) {
|
|
||||||
record[authType] = oldRecord[authType]
|
|
||||||
} else {
|
|
||||||
const clearTempKey = await RSADecryptSync(tempKey)
|
|
||||||
// console.log('clearTempKey:', clearTempKey)
|
|
||||||
const clearSSHKey = await AESDecryptSync(record[authType], clearTempKey)
|
|
||||||
// console.log(`${ authType }原密文: `, clearSSHKey)
|
|
||||||
record[authType] = await AESEncryptSync(clearSSHKey)
|
|
||||||
// console.log(`${ authType }__commonKey加密存储: `, record[authType])
|
|
||||||
}
|
|
||||||
hostList.splice(idx, 1, record)
|
|
||||||
writeHostList(hostList)
|
|
||||||
res.success()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function removeHost({
|
async function batchUpdateHost({ res, request }) {
|
||||||
res, request
|
let { updateHosts } = (await decryptAndExecuteAsync(path.join(__dirname, 'plus.js'))) || {}
|
||||||
}) {
|
if (updateHosts) {
|
||||||
let { body: { host } } = request
|
await updateHosts({ res, request })
|
||||||
let hostList = await readHostList()
|
|
||||||
if (Array.isArray(host)) {
|
|
||||||
hostList = hostList.filter(item => !host.includes(item.host))
|
|
||||||
// if (hostList.length === 0) return res.fail({ msg: '没有可删除的实例' })
|
|
||||||
} else {
|
} else {
|
||||||
let hostIdx = hostList.findIndex(item => item.host === host)
|
return res.fail({ data: false, msg: 'Plus专属功能!' })
|
||||||
if (hostIdx === -1) return res.fail({ msg: `${ host }不存在` })
|
|
||||||
hostList.splice(hostIdx, 1)
|
|
||||||
}
|
}
|
||||||
writeHostList(hostList)
|
|
||||||
res.success({ data: '已移除' })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function importHost({
|
async function removeHost({ res, request }) {
|
||||||
res, request
|
let { body: { ids } } = request
|
||||||
}) {
|
if (!Array.isArray(ids)) return res.fail({ msg: '参数错误' })
|
||||||
|
const numRemoved = await hostListDB.removeAsync({ _id: { $in: ids } }, { multi: true })
|
||||||
|
res.success({ data: `已移除,数量: ${ numRemoved }` })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function importHost({ res, request }) {
|
||||||
let { body: { importHost, isEasyNodeJson = false } } = request
|
let { body: { importHost, isEasyNodeJson = false } } = request
|
||||||
if (!Array.isArray(importHost)) return res.fail({ msg: '参数错误' })
|
if (!Array.isArray(importHost)) return res.fail({ msg: '参数错误' })
|
||||||
let hostList = await readHostList()
|
let hostList = await hostListDB.findAsync({})
|
||||||
// 过滤已存在的host
|
// 考虑到批量导入可能会重复太多,先过滤已存在的host:port
|
||||||
let hostListSet = new Set(hostList.map(item => item.host))
|
let hostListSet = new Set(hostList.map(({ host, port }) => `${ host }:${ port }`))
|
||||||
let newHostList = importHost.filter(item => !hostListSet.has(item.host))
|
let newHostList = importHost.filter(({ host, port }) => !hostListSet.has(`${ host }:${ port }`))
|
||||||
let newHostListLen = newHostList.length
|
let newHostListLen = newHostList.length
|
||||||
if (newHostListLen === 0) return res.fail({ msg: '导入的实例已存在' })
|
if (newHostListLen === 0) return res.fail({ msg: '导入的实例已存在' })
|
||||||
|
|
||||||
@ -159,10 +101,8 @@ async function importHost({
|
|||||||
item.index = newHostListLen - index
|
item.index = newHostListLen - index
|
||||||
return Object.assign(item, { ...extraFiels })
|
return Object.assign(item, { ...extraFiels })
|
||||||
})
|
})
|
||||||
|
|
||||||
}
|
}
|
||||||
hostList.push(...newHostList)
|
await hostListDB.insertAsync(newHostList)
|
||||||
writeHostList(hostList)
|
|
||||||
res.success({ data: { len: newHostList.length } })
|
res.success({ data: { len: newHostList.length } })
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -171,5 +111,6 @@ module.exports = {
|
|||||||
addHost,
|
addHost,
|
||||||
updateHost,
|
updateHost,
|
||||||
removeHost,
|
removeHost,
|
||||||
importHost
|
importHost,
|
||||||
|
batchUpdateHost
|
||||||
}
|
}
|
||||||
|
17
server/app/controller/log.js
Normal 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
|
||||||
|
}
|
@ -1,9 +1,12 @@
|
|||||||
const { readNotifyConfig, writeNotifyConfig, readNotifyList, writeNotifyList } = require('../utils/storage')
|
const path = require('path')
|
||||||
|
const decryptAndExecuteAsync = require('../utils/decrypt-file')
|
||||||
const { sendServerChan, sendEmail } = require('../utils/notify')
|
const { sendServerChan, sendEmail } = require('../utils/notify')
|
||||||
// const commonTemp = require('../template/commonTemp')
|
const { NotifyConfigDB, NotifyDB } = require('../utils/db-class')
|
||||||
|
const notifyDB = new NotifyDB().getInstance()
|
||||||
|
const notifyConfigDB = new NotifyConfigDB().getInstance()
|
||||||
|
|
||||||
async function getNotifyConfig({ res }) {
|
async function getNotifyConfig({ res }) {
|
||||||
const data = await readNotifyConfig()
|
const data = await notifyConfigDB.findOneAsync({})
|
||||||
return res.success({ data })
|
return res.success({ data })
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -11,6 +14,7 @@ async function updateNotifyConfig({ res, request }) {
|
|||||||
let { body: { noticeConfig } } = request
|
let { body: { noticeConfig } } = request
|
||||||
let { type } = noticeConfig
|
let { type } = noticeConfig
|
||||||
try {
|
try {
|
||||||
|
// console.log('noticeConfig: ', noticeConfig[type])
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case 'sct':
|
case 'sct':
|
||||||
await sendServerChan(noticeConfig[type]['sendKey'], 'EasyNode通知测试', '这是一条测试通知')
|
await sendServerChan(noticeConfig[type]['sendKey'], 'EasyNode通知测试', '这是一条测试通知')
|
||||||
@ -18,8 +22,14 @@ async function updateNotifyConfig({ res, request }) {
|
|||||||
case 'email':
|
case 'email':
|
||||||
await sendEmail(noticeConfig[type], 'EasyNode通知测试', '这是一条测试通知')
|
await sendEmail(noticeConfig[type], 'EasyNode通知测试', '这是一条测试通知')
|
||||||
break
|
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 writeNotifyConfig(noticeConfig)
|
await notifyConfigDB.update({}, { $set: noticeConfig }, { upsert: true })
|
||||||
return res.success({ msg: '测试通过 | 保存成功' })
|
return res.success({ msg: '测试通过 | 保存成功' })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return res.fail({ msg: error.message })
|
return res.fail({ msg: error.message })
|
||||||
@ -27,18 +37,14 @@ async function updateNotifyConfig({ res, request }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function getNotifyList({ res }) {
|
async function getNotifyList({ res }) {
|
||||||
const data = await readNotifyList()
|
const data = await notifyDB.findAsync({})
|
||||||
res.success({ data })
|
res.success({ data })
|
||||||
}
|
}
|
||||||
|
|
||||||
async function updateNotifyList({ res, request }) {
|
async function updateNotifyList({ res, request }) {
|
||||||
let { body: { type, sw } } = request
|
let { body: { type, sw } } = request
|
||||||
if (!([true, false].includes(sw))) return res.fail({ msg: `Error type for sw:${ sw }, must be Boolean` })
|
if (!([true, false].includes(sw))) return res.fail({ msg: `Error type for sw:${ sw }, must be Boolean` })
|
||||||
const notifyList = await readNotifyList()
|
await notifyDB.updateAsync({ type }, { $set: { sw } })
|
||||||
let target = notifyList.find((item) => item.type === type)
|
|
||||||
if (!target) return res.fail({ msg: `更新失败, 不存在该通知类型:${ type }` })
|
|
||||||
target.sw = sw
|
|
||||||
await writeNotifyList(notifyList)
|
|
||||||
res.success()
|
res.success()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,7 +1,8 @@
|
|||||||
const { readOneKeyRecord, deleteOneKeyRecord } = require('../utils/storage')
|
const { OnekeyDB } = require('../utils/db-class')
|
||||||
|
const onekeyDB = new OnekeyDB().getInstance()
|
||||||
|
|
||||||
async function getOnekeyRecord({ res }) {
|
async function getOnekeyRecord({ res }) {
|
||||||
let data = await readOneKeyRecord()
|
let data = await onekeyDB.findAsync({})
|
||||||
data = data.map(item => {
|
data = data.map(item => {
|
||||||
return { ...item, id: item._id }
|
return { ...item, id: item._id }
|
||||||
})
|
})
|
||||||
@ -11,14 +12,11 @@ async function getOnekeyRecord({ res }) {
|
|||||||
|
|
||||||
const removeOnekeyRecord = async ({ res, request }) => {
|
const removeOnekeyRecord = async ({ res, request }) => {
|
||||||
let { body: { ids } } = request
|
let { body: { ids } } = request
|
||||||
let onekeyRecord = await readOneKeyRecord()
|
|
||||||
if (ids === 'ALL') {
|
if (ids === 'ALL') {
|
||||||
ids = onekeyRecord.map(item => item._id)
|
await onekeyDB.removeAsync({}, { multi: true })
|
||||||
await deleteOneKeyRecord(ids)
|
|
||||||
res.success({ data: '移除全部成功' })
|
res.success({ data: '移除全部成功' })
|
||||||
} else {
|
} else {
|
||||||
if (!onekeyRecord.some(item => ids.includes(item._id))) return res.fail({ msg: '批量指令记录ID不存在' })
|
await onekeyDB.removeAsync({ _id: { $in: ids } })
|
||||||
await deleteOneKeyRecord(ids)
|
|
||||||
res.success({ data: '移除成功' })
|
res.success({ data: '移除成功' })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
1
server/app/controller/plus.js
Normal 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
|
@ -1,13 +1,16 @@
|
|||||||
const localShellJson = require('../config/shell.json')
|
const path = require('path')
|
||||||
const { readScriptList, writeScriptList } = require('../utils/storage')
|
const decryptAndExecuteAsync = require('../utils/decrypt-file')
|
||||||
const { randomStr } = require('../utils/tools')
|
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) => {
|
let localShell = JSON.parse(JSON.stringify(localShellJson)).map((item) => {
|
||||||
return { ...item, id: randomStr(10), index: '--', description: item.description + '|内置脚本' }
|
return { ...item, id: randomStr(10), index: '--', description: item.description + '|内置脚本' }
|
||||||
})
|
})
|
||||||
|
|
||||||
async function getScriptList({ res }) {
|
async function getScriptList({ res }) {
|
||||||
let data = await readScriptList()
|
let data = await scriptsDB.findAsync({})
|
||||||
data = data.map(item => {
|
data = data.map(item => {
|
||||||
return { ...item, id: item._id }
|
return { ...item, id: item._id }
|
||||||
})
|
})
|
||||||
@ -24,10 +27,8 @@ const addScript = async ({ res, request }) => {
|
|||||||
let { body: { name, description, command, index } } = request
|
let { body: { name, description, command, index } } = request
|
||||||
if (!name || !command) return res.fail({ data: false, msg: '参数错误' })
|
if (!name || !command) return res.fail({ data: false, msg: '参数错误' })
|
||||||
index = Number(index) || 0
|
index = Number(index) || 0
|
||||||
let scriptsList = await readScriptList()
|
|
||||||
let record = { name, description, command, index }
|
let record = { name, description, command, index }
|
||||||
scriptsList.push(record)
|
await scriptsDB.insertAsync(record)
|
||||||
await writeScriptList(scriptsList)
|
|
||||||
res.success({ data: '添加成功' })
|
res.success({ data: '添加成功' })
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -35,30 +36,38 @@ const updateScriptList = async ({ res, request }) => {
|
|||||||
let { params: { id } } = request
|
let { params: { id } } = request
|
||||||
let { body: { name, description, command, index } } = request
|
let { body: { name, description, command, index } } = request
|
||||||
if (!name || !command) return res.fail({ data: false, msg: '参数错误' })
|
if (!name || !command) return res.fail({ data: false, msg: '参数错误' })
|
||||||
let scriptsList = await readScriptList()
|
await scriptsDB.updateAsync({ _id: id }, { name, description, command, index })
|
||||||
let idx = scriptsList.findIndex(item => item._id === id)
|
|
||||||
if (idx === -1) return res.fail({ data: false, msg: `脚本ID${ id }不存在` })
|
|
||||||
const { _id } = scriptsList[idx]
|
|
||||||
let record = Object.assign({ _id }, { name, description, command, index })
|
|
||||||
scriptsList.splice(idx, 1, record)
|
|
||||||
await writeScriptList(scriptsList)
|
|
||||||
res.success({ data: '修改成功' })
|
res.success({ data: '修改成功' })
|
||||||
}
|
}
|
||||||
|
|
||||||
const removeScript = async ({ res, request }) => {
|
const removeScript = async ({ res, request }) => {
|
||||||
let { params: { id } } = request
|
let { params: { id } } = request
|
||||||
let scriptsList = await readScriptList()
|
await scriptsDB.removeAsync({ _id: id })
|
||||||
let idx = scriptsList.findIndex(item => item._id === id)
|
|
||||||
if (idx === -1) return res.fail({ msg: '脚本ID不存在' })
|
|
||||||
scriptsList.splice(idx, 1)
|
|
||||||
await writeScriptList(scriptsList)
|
|
||||||
res.success({ data: '移除成功' })
|
res.success({ data: '移除成功' })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const batchRemoveScript = async ({ res, request }) => {
|
||||||
|
let { body: { ids } } = request
|
||||||
|
if (!Array.isArray(ids)) return res.fail({ msg: '参数错误' })
|
||||||
|
const numRemoved = await scriptsDB.removeAsync({ _id: { $in: ids } }, { multi: true })
|
||||||
|
res.success({ data: `批量移除成功,数量: ${ numRemoved }` })
|
||||||
|
}
|
||||||
|
|
||||||
|
const importScript = async ({ res, request }) => {
|
||||||
|
let { impScript } = (await decryptAndExecuteAsync(path.join(__dirname, 'plus.js'))) || {}
|
||||||
|
if (impScript) {
|
||||||
|
await impScript({ res, request })
|
||||||
|
} else {
|
||||||
|
return res.fail({ data: false, msg: 'Plus专属功能!' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
addScript,
|
addScript,
|
||||||
getScriptList,
|
getScriptList,
|
||||||
getLocalScriptList,
|
getLocalScriptList,
|
||||||
updateScriptList,
|
updateScriptList,
|
||||||
removeScript
|
removeScript,
|
||||||
|
batchRemoveScript,
|
||||||
|
importScript
|
||||||
}
|
}
|
||||||
|
@ -1,9 +1,12 @@
|
|||||||
const { readSSHRecord, writeSSHRecord, readHostList, writeHostList } = require('../utils/storage')
|
const path = require('path')
|
||||||
const { RSADecryptSync, AESEncryptSync, AESDecryptSync } = require('../utils/encrypt')
|
const { RSADecryptAsync, AESEncryptAsync, AESDecryptAsync } = require('../utils/encrypt')
|
||||||
|
const { HostListDB, CredentialsDB } = require('../utils/db-class')
|
||||||
|
const decryptAndExecuteAsync = require('../utils/decrypt-file')
|
||||||
|
const hostListDB = new HostListDB().getInstance()
|
||||||
|
const credentialsDB = new CredentialsDB().getInstance()
|
||||||
|
|
||||||
async function getSSHList({ res }) {
|
async function getSSHList({ res }) {
|
||||||
// console.log('get-host-list')
|
let data = await credentialsDB.findAsync({})
|
||||||
let data = await readSSHRecord()
|
|
||||||
data = data?.map(item => {
|
data = data?.map(item => {
|
||||||
const { name, authType, _id: id, date } = item
|
const { name, authType, _id: id, date } = item
|
||||||
return { id, name, authType, privateKey: '', password: '', date }
|
return { id, name, authType, privateKey: '', password: '', date }
|
||||||
@ -16,18 +19,16 @@ const addSSH = async ({ res, request }) => {
|
|||||||
let { body: { name, authType, password, privateKey, tempKey } } = request
|
let { body: { name, authType, password, privateKey, tempKey } } = request
|
||||||
let record = { name, authType, password, privateKey }
|
let record = { name, authType, password, privateKey }
|
||||||
if (!name || !record[authType]) return res.fail({ data: false, msg: '参数错误' })
|
if (!name || !record[authType]) return res.fail({ data: false, msg: '参数错误' })
|
||||||
let sshRecord = await readSSHRecord()
|
let count = await credentialsDB.countAsync({ name })
|
||||||
if (sshRecord.some(item => item.name === name)) return res.fail({ data: false, msg: '已存在同名凭证' })
|
if (count > 0) return res.fail({ data: false, msg: '已存在同名凭证' })
|
||||||
|
|
||||||
const clearTempKey = await RSADecryptSync(tempKey)
|
const clearTempKey = await RSADecryptAsync(tempKey)
|
||||||
console.log('clearTempKey:', clearTempKey)
|
console.log('clearTempKey:', clearTempKey)
|
||||||
const clearSSHKey = await AESDecryptSync(record[authType], clearTempKey)
|
const clearSSHKey = await AESDecryptAsync(record[authType], clearTempKey)
|
||||||
// console.log(`${ authType }原密文: `, clearSSHKey)
|
// console.log(`${ authType }原密文: `, clearSSHKey)
|
||||||
record[authType] = await AESEncryptSync(clearSSHKey)
|
record[authType] = await AESEncryptAsync(clearSSHKey)
|
||||||
// console.log(`${ authType }__commonKey加密存储: `, record[authType])
|
// console.log(`${ authType }__commonKey加密存储: `, record[authType])
|
||||||
|
await credentialsDB.insertAsync({ ...record, date: Date.now() })
|
||||||
sshRecord.push({ ...record, date: Date.now() })
|
|
||||||
await writeSSHRecord(sshRecord)
|
|
||||||
consola.info('添加凭证:', name)
|
consola.info('添加凭证:', name)
|
||||||
res.success({ data: '保存成功' })
|
res.success({ data: '保存成功' })
|
||||||
}
|
}
|
||||||
@ -36,58 +37,66 @@ const updateSSH = async ({ res, request }) => {
|
|||||||
let { body: { id, name, authType, password, privateKey, date, tempKey } } = request
|
let { body: { id, name, authType, password, privateKey, date, tempKey } } = request
|
||||||
let record = { name, authType, password, privateKey, date }
|
let record = { name, authType, password, privateKey, date }
|
||||||
if (!id || !name) return res.fail({ data: false, msg: '请输入凭据名称' })
|
if (!id || !name) return res.fail({ data: false, msg: '请输入凭据名称' })
|
||||||
let sshRecord = await readSSHRecord()
|
let oldRecord = await credentialsDB.findOneAsync({ _id: id })
|
||||||
let idx = sshRecord.findIndex(item => item._id === id)
|
if (!oldRecord) return res.fail({ data: false, msg: '凭证不存在' })
|
||||||
if (sshRecord.some(item => item.name === name && item.date !== date)) return res.fail({ data: false, msg: '已存在同名凭证' })
|
|
||||||
if(idx === -1) res.fail({ data: false, msg: '请输入凭据名称' })
|
|
||||||
const oldRecord = sshRecord[idx]
|
|
||||||
// 判断原记录是否存在当前更新记录的认证方式
|
// 判断原记录是否存在当前更新记录的认证方式
|
||||||
if (!oldRecord[authType] && !record[authType]) return res.fail({ data: false, msg: `请输入${ authType === 'password' ? '密码' : '密钥' }` })
|
if (!oldRecord[authType] && !record[authType]) return res.fail({ data: false, msg: `请输入${ authType === 'password' ? '密码' : '密钥' }` })
|
||||||
if (!record[authType] && oldRecord[authType]) {
|
if (!record[authType] && oldRecord[authType]) {
|
||||||
record[authType] = oldRecord[authType]
|
record[authType] = oldRecord[authType]
|
||||||
} else {
|
} else {
|
||||||
const clearTempKey = await RSADecryptSync(tempKey)
|
const clearTempKey = await RSADecryptAsync(tempKey)
|
||||||
console.log('clearTempKey:', clearTempKey)
|
console.log('clearTempKey:', clearTempKey)
|
||||||
const clearSSHKey = await AESDecryptSync(record[authType], clearTempKey)
|
const clearSSHKey = await AESDecryptAsync(record[authType], clearTempKey)
|
||||||
// console.log(`${ authType }原密文: `, clearSSHKey)
|
// console.log(`${ authType }原密文: `, clearSSHKey)
|
||||||
record[authType] = await AESEncryptSync(clearSSHKey)
|
record[authType] = await AESEncryptAsync(clearSSHKey)
|
||||||
// console.log(`${ authType }__commonKey加密存储: `, record[authType])
|
// console.log(`${ authType }__commonKey加密存储: `, record[authType])
|
||||||
}
|
}
|
||||||
record._id = sshRecord[idx]._id
|
await credentialsDB.updateAsync({ _id: id }, record)
|
||||||
sshRecord.splice(idx, 1, record)
|
|
||||||
await writeSSHRecord(sshRecord)
|
|
||||||
consola.info('修改凭证:', name)
|
consola.info('修改凭证:', name)
|
||||||
res.success({ data: '保存成功' })
|
res.success({ data: '保存成功' })
|
||||||
}
|
}
|
||||||
|
|
||||||
const removeSSH = async ({ res, request }) => {
|
const removeSSH = async ({ res, request }) => {
|
||||||
let { params: { id } } = request
|
let { params: { id } } = request
|
||||||
let sshRecord = await readSSHRecord()
|
let count = await credentialsDB.countAsync({ _id: id })
|
||||||
let idx = sshRecord.findIndex(item => item._id === id)
|
if (count === 0) return res.fail({ msg: '凭证不存在' })
|
||||||
if(idx === -1) return res.fail({ msg: '凭证不存在' })
|
|
||||||
sshRecord.splice(idx, 1)
|
|
||||||
// 将删除的凭证id从host中删除
|
// 将删除的凭证id从host中删除
|
||||||
let hostList = await readHostList()
|
let hostList = await hostListDB.findAsync({})
|
||||||
hostList = hostList.map(item => {
|
if (Array.isArray(hostList) && hostList.length > 0) {
|
||||||
if (item.credential === id) item.credential = ''
|
for (let host of hostList) {
|
||||||
return item
|
let { credential } = host
|
||||||
})
|
credential = await AESDecryptAsync(credential)
|
||||||
await writeHostList(hostList)
|
if (credential === id) {
|
||||||
|
host.credential = ''
|
||||||
|
await hostListDB.updateAsync({ _id: host._id }, host)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await hostListDB.compactDatafileAsync()
|
||||||
consola.info('移除凭证:', id)
|
consola.info('移除凭证:', id)
|
||||||
await writeSSHRecord(sshRecord)
|
await credentialsDB.removeAsync({ _id: id })
|
||||||
res.success({ data: '移除成功' })
|
res.success({ data: '移除成功' })
|
||||||
}
|
}
|
||||||
|
|
||||||
const getCommand = async ({ res, request }) => {
|
const getCommand = async ({ res, request }) => {
|
||||||
let { host } = request.query
|
let { hostId } = request.query
|
||||||
if(!host) return res.fail({ data: false, msg: '参数错误' })
|
if (!hostId) return res.fail({ data: false, msg: '参数错误' })
|
||||||
let hostInfo = await readHostList()
|
let hostInfo = await hostListDB.findAsync({})
|
||||||
let record = hostInfo?.find(item => item.host === host)
|
let record = hostInfo?.find(item => item._id === hostId)
|
||||||
consola.info('查询登录后执行的指令:', host)
|
consola.info('查询登录后执行的指令:', hostId)
|
||||||
if(!record) return res.fail({ data: false, msg: 'host not found' }) // host不存在
|
if (!record) return res.fail({ data: false, msg: 'host not found' })
|
||||||
const { command } = record
|
const { command } = record
|
||||||
if(!command) return res.success({ data: false }) // command不存在
|
if (!command) return res.success({ data: false })
|
||||||
res.success({ data: command }) // 存在
|
res.success({ data: command })
|
||||||
|
}
|
||||||
|
|
||||||
|
const decryptPrivateKey = async ({ res, request }) => {
|
||||||
|
let { dePrivateKey } = (await decryptAndExecuteAsync(path.join(__dirname, 'plus.js'))) || {}
|
||||||
|
if (dePrivateKey) {
|
||||||
|
await dePrivateKey({ res, request })
|
||||||
|
} else {
|
||||||
|
return res.fail({ data: false, msg: 'Plus专属功能,无法解密私钥!' })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
@ -95,5 +104,6 @@ module.exports = {
|
|||||||
addSSH,
|
addSSH,
|
||||||
updateSSH,
|
updateSSH,
|
||||||
removeSSH,
|
removeSSH,
|
||||||
getCommand
|
getCommand,
|
||||||
|
decryptPrivateKey
|
||||||
}
|
}
|
||||||
|
@ -1,12 +1,21 @@
|
|||||||
const jwt = require('jsonwebtoken')
|
const jwt = require('jsonwebtoken')
|
||||||
const axios = require('axios')
|
const axios = require('axios')
|
||||||
const { asyncSendNotice } = require('../utils/notify')
|
const speakeasy = require('speakeasy')
|
||||||
const { readKey, writeKey } = require('../utils/storage')
|
const QRCode = require('qrcode')
|
||||||
const { RSADecryptSync, AESEncryptSync, SHA1Encrypt } = require('../utils/encrypt')
|
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 { getNetIPInfo } = require('../utils/tools')
|
||||||
|
const { KeyDB, LogDB, PlusDB } = require('../utils/db-class')
|
||||||
|
|
||||||
|
const keyDB = new KeyDB().getInstance()
|
||||||
|
const logDB = new LogDB().getInstance()
|
||||||
|
const plusDB = new PlusDB().getInstance()
|
||||||
|
|
||||||
const getpublicKey = async ({ res }) => {
|
const getpublicKey = async ({ res }) => {
|
||||||
let { publicKey: data } = await readKey()
|
let { publicKey: data } = await keyDB.findOneAsync({})
|
||||||
if (!data) return res.fail({ msg: 'publicKey not found, Try to restart the server', status: 500 })
|
if (!data) return res.fail({ msg: 'publicKey not found, Try to restart the server', status: 500 })
|
||||||
res.success({ data })
|
res.success({ data })
|
||||||
}
|
}
|
||||||
@ -20,7 +29,7 @@ let loginCountDown = forbidTimer
|
|||||||
let forbidLogin = false
|
let forbidLogin = false
|
||||||
|
|
||||||
const login = async ({ res, request }) => {
|
const login = async ({ res, request }) => {
|
||||||
let { body: { loginName, ciphertext, jwtExpires }, ip: clientIp } = request
|
let { body: { loginName, ciphertext, jwtExpires, mfa2Token }, ip: clientIp } = request
|
||||||
if (!loginName && !ciphertext) return res.fail({ msg: '请求非法!' })
|
if (!loginName && !ciphertext) return res.fail({ msg: '请求非法!' })
|
||||||
if (forbidLogin) return res.fail({ msg: `禁止登录! 倒计时[${ loginCountDown }s]后尝试登录或重启面板服务` })
|
if (forbidLogin) return res.fail({ msg: `禁止登录! 倒计时[${ loginCountDown }s]后尝试登录或重启面板服务` })
|
||||||
loginErrCount++
|
loginErrCount++
|
||||||
@ -28,7 +37,7 @@ const login = async ({ res, request }) => {
|
|||||||
if (loginErrCount >= allowErrCount) {
|
if (loginErrCount >= allowErrCount) {
|
||||||
const { ip, country, city } = await getNetIPInfo(clientIp)
|
const { ip, country, city } = await getNetIPInfo(clientIp)
|
||||||
// 异步发送通知&禁止登录
|
// 异步发送通知&禁止登录
|
||||||
asyncSendNotice('err_login', '登录错误提醒', `错误登录次数: ${ loginErrTotal }\n地点:${ country + city }\nIP: ${ ip }`)
|
sendNoticeAsync('err_login', '登录错误提醒', `错误登录次数: ${ loginErrTotal }\n地点:${ country + city }\nIP: ${ ip }`)
|
||||||
forbidLogin = true
|
forbidLogin = true
|
||||||
loginErrCount = 0
|
loginErrCount = 0
|
||||||
|
|
||||||
@ -51,10 +60,13 @@ const login = async ({ res, request }) => {
|
|||||||
|
|
||||||
// 登录流程
|
// 登录流程
|
||||||
try {
|
try {
|
||||||
// console.log('ciphertext', ciphertext)
|
let loginPwd = await RSADecryptAsync(ciphertext)
|
||||||
let loginPwd = await RSADecryptSync(ciphertext)
|
let { user, pwd, enableMFA2, secret } = await keyDB.findOneAsync({})
|
||||||
// console.log('Decrypt解密password:', loginPwd)
|
if (enableMFA2) {
|
||||||
let { user, pwd } = await readKey()
|
const isValid = speakeasy.totp.verify({ secret, encoding: 'base32', token: mfa2Token, window: 1 })
|
||||||
|
console.log('MFA2 verfify:', isValid)
|
||||||
|
if (!isValid) return res.fail({ msg: '验证失败' })
|
||||||
|
}
|
||||||
if (loginName === user && loginPwd === 'admin' && pwd === 'admin') {
|
if (loginName === user && loginPwd === 'admin' && pwd === 'admin') {
|
||||||
const token = await beforeLoginHandler(clientIp, jwtExpires)
|
const token = await beforeLoginHandler(clientIp, jwtExpires)
|
||||||
return res.success({ data: { token, jwtExpires }, msg: '登录成功,请及时修改默认用户名和密码' })
|
return res.success({ data: { token, jwtExpires }, msg: '登录成功,请及时修改默认用户名和密码' })
|
||||||
@ -64,8 +76,8 @@ const login = async ({ res, request }) => {
|
|||||||
const token = await beforeLoginHandler(clientIp, jwtExpires)
|
const token = await beforeLoginHandler(clientIp, jwtExpires)
|
||||||
return res.success({ data: { token, jwtExpires }, msg: '登录成功' })
|
return res.success({ data: { token, jwtExpires }, msg: '登录成功' })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log('解密失败:', error)
|
console.log('登录失败:', error.message)
|
||||||
res.fail({ msg: '解密失败, 请查看服务端日志' })
|
res.fail({ msg: '登录失败, 请查看服务端日志' })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -74,50 +86,42 @@ const beforeLoginHandler = async (clientIp, jwtExpires) => {
|
|||||||
|
|
||||||
// consola.success('登录成功, 准备生成token', new Date())
|
// consola.success('登录成功, 准备生成token', new Date())
|
||||||
// 生产token
|
// 生产token
|
||||||
let { commonKey } = await readKey()
|
let { commonKey } = await keyDB.findOneAsync({})
|
||||||
let token = jwt.sign({ date: Date.now() }, commonKey, { expiresIn: jwtExpires }) // 生成token
|
let token = jwt.sign({ date: Date.now() }, commonKey, { expiresIn: jwtExpires }) // 生成token
|
||||||
token = await AESEncryptSync(token) // 对称加密token后再传输给前端
|
token = await AESEncryptAsync(token) // 对称加密token后再传输给前端
|
||||||
|
|
||||||
// 记录客户端登录IP(用于判断是否异地且只保留最近10条)
|
// 记录客户端登录IP(用于判断是否异地且只保留最近10<EFBFBD><EFBFBD>)
|
||||||
const clientIPInfo = await getNetIPInfo(clientIp)
|
const clientIPInfo = await getNetIPInfo(clientIp)
|
||||||
const { ip, country, city } = clientIPInfo || {}
|
const { ip, country, city } = clientIPInfo || {}
|
||||||
consola.info('登录成功:', new Date(), { ip, country, city })
|
consola.info('登录成功:', new Date(), { ip, country, city })
|
||||||
|
|
||||||
// 邮件登录通知
|
// 登录通知
|
||||||
asyncSendNotice('login', '登录提醒', `地点:${ country + city }\nIP: ${ ip }`)
|
sendNoticeAsync('login', '登录提醒', `地点:${ country + city }\nIP: ${ ip }`)
|
||||||
|
|
||||||
global.loginRecord.unshift(clientIPInfo)
|
await logDB.insertAsync({ ip, country, city, date: Date.now(), type: 'login' })
|
||||||
if (global.loginRecord.length > 10) global.loginRecord = global.loginRecord.slice(0, 10)
|
|
||||||
return token
|
return token
|
||||||
}
|
}
|
||||||
|
|
||||||
const updatePwd = async ({ res, request }) => {
|
const updatePwd = async ({ res, request }) => {
|
||||||
let { body: { oldLoginName, oldPwd, newLoginName, newPwd } } = request
|
let { body: { oldLoginName, oldPwd, newLoginName, newPwd } } = request
|
||||||
let rsaOldPwd = await RSADecryptSync(oldPwd)
|
let rsaOldPwd = await RSADecryptAsync(oldPwd)
|
||||||
oldPwd = rsaOldPwd === 'admin' ? 'admin' : SHA1Encrypt(rsaOldPwd)
|
oldPwd = rsaOldPwd === 'admin' ? 'admin' : SHA1Encrypt(rsaOldPwd)
|
||||||
let keyObj = await readKey()
|
let keyObj = await keyDB.findOneAsync({})
|
||||||
let { user, pwd } = keyObj
|
let { user, pwd } = keyObj
|
||||||
if (oldLoginName !== user || oldPwd !== pwd) return res.fail({ data: false, msg: '原用户名或密码校验失败' })
|
if (oldLoginName !== user || oldPwd !== pwd) return res.fail({ data: false, msg: '原用户名或密码校验失败' })
|
||||||
// 旧密钥校验通过,加密保存新密码
|
// 旧密钥校验通过,加密保存新密码
|
||||||
newPwd = await RSADecryptSync(newPwd) === 'admin' ? 'admin' : SHA1Encrypt(await RSADecryptSync(newPwd))
|
newPwd = await RSADecryptAsync(newPwd) === 'admin' ? 'admin' : SHA1Encrypt(await RSADecryptAsync(newPwd))
|
||||||
keyObj.user = newLoginName
|
keyObj.user = newLoginName
|
||||||
keyObj.pwd = newPwd
|
keyObj.pwd = newPwd
|
||||||
await writeKey(keyObj)
|
await keyDB.updateAsync({}, keyObj)
|
||||||
|
sendNoticeAsync('updatePwd', '用户密码修改提醒', `原用户名:${ user }\n更新用户名: ${ newLoginName }`)
|
||||||
asyncSendNotice('updatePwd', '用户密码修改提醒', `原用户名:${ user }\n更新用户名: ${ newLoginName }`)
|
|
||||||
|
|
||||||
res.success({ data: true, msg: 'success' })
|
res.success({ data: true, msg: 'success' })
|
||||||
}
|
}
|
||||||
|
|
||||||
const getLoginRecord = async ({ res }) => {
|
|
||||||
res.success({ data: global.loginRecord, msg: 'success' })
|
|
||||||
}
|
|
||||||
|
|
||||||
const getEasynodeVersion = async ({ res }) => {
|
const getEasynodeVersion = async ({ res }) => {
|
||||||
try {
|
try {
|
||||||
// const { data } = await axios.get('https://api.github.com/repos/chaos-zhu/easynode/releases/latest')
|
// const { data } = await axios.get('https://api.github.com/repos/chaos-zhu/easynode/releases/latest')
|
||||||
const { data } = await axios.get('https://get-easynode-latest-version.chaoszhu.workers.dev/version')
|
const { data } = await axios.get('https://get-easynode-latest-version.chaoszhu.workers.dev/version')
|
||||||
console.log(data)
|
|
||||||
res.success({ data, msg: 'success' })
|
res.success({ data, msg: 'success' })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
consola.error('Failed to fetch Easynode latest version:', error)
|
consola.error('Failed to fetch Easynode latest version:', error)
|
||||||
@ -125,10 +129,98 @@ const getEasynodeVersion = async ({ res }) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let tempSecret = null
|
||||||
|
const getMFA2Status = async ({ res }) => {
|
||||||
|
const { enableMFA2 = false } = await keyDB.findOneAsync({})
|
||||||
|
res.success({ data: enableMFA2, msg: 'success' })
|
||||||
|
}
|
||||||
|
const getMFA2Code = async ({ res }) => {
|
||||||
|
const { user } = await keyDB.findOneAsync({})
|
||||||
|
let { otpauth_url, base32 } = speakeasy.generateSecret({ name: `EasyNode-${ user }`, length: 20 })
|
||||||
|
tempSecret = base32
|
||||||
|
const qrImage = await QRCode.toDataURL(otpauth_url)
|
||||||
|
const data = { qrImage, secret: tempSecret }
|
||||||
|
res.success({ data, msg: 'success' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const enableMFA2 = async ({ res, request }) => {
|
||||||
|
const { body: { token } } = request
|
||||||
|
if (!token) return res.fail({ data: false, msg: '参数错误' })
|
||||||
|
try {
|
||||||
|
// const isValid = authenticator.verify({ token, secret: tempSecret })
|
||||||
|
const isValid = speakeasy.totp.verify({ secret: tempSecret, encoding: 'base32', token, window: 1 })
|
||||||
|
if (!isValid) return res.fail({ msg: '验证失败' })
|
||||||
|
const keyConfig = await keyDB.findOneAsync({})
|
||||||
|
keyConfig.enableMFA2 = true
|
||||||
|
keyConfig.secret = tempSecret
|
||||||
|
tempSecret = null
|
||||||
|
await keyDB.updateAsync({}, keyConfig)
|
||||||
|
res.success({ msg: '验证成功' })
|
||||||
|
} catch (error) {
|
||||||
|
res.fail({ msg: `验证失败: ${ error.message }` })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const disableMFA2 = async ({ res }) => {
|
||||||
|
const keyConfig = await keyDB.findOneAsync({})
|
||||||
|
keyConfig.enableMFA2 = false
|
||||||
|
keyConfig.secret = null
|
||||||
|
await keyDB.updateAsync({}, keyConfig)
|
||||||
|
res.success({ msg: 'success' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const getPlusInfo = async ({ res }) => {
|
||||||
|
let data = await plusDB.findOneAsync({})
|
||||||
|
delete data?._id
|
||||||
|
delete data?.decryptKey
|
||||||
|
res.success({ data, msg: 'success' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const getPlusDiscount = async ({ res } = {}) => {
|
||||||
|
if (process.env.EXEC_ENV === 'local') return res.success({ discount: false })
|
||||||
|
const servers = [plusServer1, plusServer2]
|
||||||
|
for (const server of servers) {
|
||||||
|
try {
|
||||||
|
const url = `${ server }/api/announcement/public?version=${ version }`
|
||||||
|
const response = await fetch(url)
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP error! status: ${ response.status }`)
|
||||||
|
}
|
||||||
|
const data = await response.json()
|
||||||
|
return res.success({ data, msg: 'success' })
|
||||||
|
} catch (error) {
|
||||||
|
if (server === servers[servers.length - 1]) {
|
||||||
|
consola.error('All servers failed:', error.message)
|
||||||
|
return res.success({ discount: false })
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getPlusConf = async ({ res }) => {
|
||||||
|
const { key } = await plusDB.findOneAsync({}) || {}
|
||||||
|
res.success({ data: key || '', msg: 'success' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatePlusKey = async ({ res, request }) => {
|
||||||
|
const { body: { key } } = request
|
||||||
|
const { success, msg } = await getLicenseInfo(key)
|
||||||
|
if (!success) return res.fail({ msg })
|
||||||
|
res.success({ msg: 'success' })
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
login,
|
login,
|
||||||
getpublicKey,
|
getpublicKey,
|
||||||
updatePwd,
|
updatePwd,
|
||||||
getLoginRecord,
|
getEasynodeVersion,
|
||||||
getEasynodeVersion
|
getMFA2Status,
|
||||||
|
getMFA2Code,
|
||||||
|
enableMFA2,
|
||||||
|
disableMFA2,
|
||||||
|
getPlusInfo,
|
||||||
|
getPlusDiscount,
|
||||||
|
getPlusConf,
|
||||||
|
updatePlusKey
|
||||||
}
|
}
|
||||||
|
102
server/app/db.js
@ -1,58 +1,46 @@
|
|||||||
const { writeKey, writeGroupList, writeNotifyList, writeNotifyConfig } = require('./utils/storage')
|
const NodeRSA = require('node-rsa')
|
||||||
|
const { randomStr } = require('./utils/tools')
|
||||||
|
const { AESEncryptAsync } = require('./utils/encrypt')
|
||||||
const { KeyDB, GroupDB, NotifyDB, NotifyConfigDB } = require('./utils/db-class')
|
const { KeyDB, GroupDB, NotifyDB, NotifyConfigDB } = require('./utils/db-class')
|
||||||
|
|
||||||
function initKeyDB() {
|
async function initKeyDB() {
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const keyDB = new KeyDB().getInstance()
|
const keyDB = new KeyDB().getInstance()
|
||||||
keyDB.count({}, async (err, count) => {
|
let count = await keyDB.countAsync({})
|
||||||
if (err) {
|
if (count !== 0) return consola.info('公私钥已存在[重新生成会导致已保存的ssh密钥信息失效]')
|
||||||
consola.log('初始化keyDB错误:', err)
|
let newConfig = {
|
||||||
reject(err)
|
|
||||||
} else {
|
|
||||||
if (count === 0) {
|
|
||||||
consola.log('初始化keyDB✔')
|
|
||||||
const defaultData = {
|
|
||||||
user: 'admin',
|
user: 'admin',
|
||||||
pwd: 'admin',
|
pwd: 'admin',
|
||||||
commonKey: '',
|
commonKey: randomStr(16),
|
||||||
publicKey: '',
|
publicKey: '',
|
||||||
privateKey: ''
|
privateKey: ''
|
||||||
}
|
}
|
||||||
await writeKey(defaultData)
|
await keyDB.insertAsync(newConfig)
|
||||||
}
|
let key = new NodeRSA({ b: 1024 })
|
||||||
}
|
key.setOptions({ encryptionScheme: 'pkcs1', environment: 'browser' })
|
||||||
resolve()
|
let privateKey = key.exportKey('pkcs1-private-pem')
|
||||||
})
|
let publicKey = key.exportKey('pkcs8-public-pem')
|
||||||
})
|
newConfig.privateKey = await AESEncryptAsync(privateKey, newConfig.commonKey) // 加密私钥
|
||||||
|
newConfig.publicKey = publicKey // 公开公钥
|
||||||
|
await keyDB.updateAsync({}, { $set: newConfig }, { upsert: true })
|
||||||
|
consola.info('Task: 已生成新的非对称加密公私钥')
|
||||||
}
|
}
|
||||||
|
|
||||||
function initGroupDB() {
|
async function initGroupDB() {
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const groupDB = new GroupDB().getInstance()
|
const groupDB = new GroupDB().getInstance()
|
||||||
groupDB.count({}, async (err, count) => {
|
let count = await groupDB.countAsync({})
|
||||||
if (err) {
|
|
||||||
consola.log('初始化groupDB错误:', err)
|
|
||||||
reject(err)
|
|
||||||
} else {
|
|
||||||
if (count === 0) {
|
if (count === 0) {
|
||||||
consola.log('初始化groupDB✔')
|
consola.log('初始化groupDB✔')
|
||||||
const defaultData = [{ '_id': 'default', 'name': '默认分组', 'index': 0 }]
|
const defaultData = [{ '_id': 'default', 'name': '默认分组', 'index': 0 }]
|
||||||
await writeGroupList(defaultData)
|
return groupDB.insertAsync(defaultData)
|
||||||
}
|
}
|
||||||
}
|
return Promise.resolve()
|
||||||
resolve()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function initNotifyDB() {
|
async function initNotifyDB() {
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const notifyDB = new NotifyDB().getInstance()
|
const notifyDB = new NotifyDB().getInstance()
|
||||||
notifyDB.find({}, async (err, notifyList) => {
|
let count = await notifyDB.countAsync({})
|
||||||
if (err) {
|
if (count !== 0) return
|
||||||
consola.log('初始化notifyDB错误:', err)
|
consola.log('初始化notifyDB✔')
|
||||||
reject(err)
|
|
||||||
} else {
|
|
||||||
let defaultData = [{
|
let defaultData = [{
|
||||||
'type': 'login',
|
'type': 'login',
|
||||||
'desc': '登录面板提醒',
|
'desc': '登录面板提醒',
|
||||||
@ -78,32 +66,12 @@ function initNotifyDB() {
|
|||||||
'desc': '服务器到期提醒',
|
'desc': '服务器到期提醒',
|
||||||
'sw': false
|
'sw': false
|
||||||
}]
|
}]
|
||||||
if (notifyList.length === 0) {
|
return notifyDB.insertAsync(defaultData)
|
||||||
consola.log('初始化notifyDB✔')
|
|
||||||
} else {
|
|
||||||
consola.log('同步notifyDB✔')
|
|
||||||
defaultData = defaultData.map(defaultItem => {
|
|
||||||
let item = notifyList.find(notify => notify.type === defaultItem.type)
|
|
||||||
defaultItem.sw = item ? item.sw : false
|
|
||||||
return item
|
|
||||||
})
|
|
||||||
}
|
|
||||||
await writeNotifyList(defaultData)
|
|
||||||
}
|
|
||||||
resolve()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function initNotifyConfigDB() {
|
async function initNotifyConfigDB() {
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const notifyConfigDB = new NotifyConfigDB().getInstance()
|
const notifyConfigDB = new NotifyConfigDB().getInstance()
|
||||||
notifyConfigDB.count({}, async (err, count) => {
|
let notifyConfig = await notifyConfigDB.findOneAsync({})
|
||||||
if (err) {
|
|
||||||
consola.log('初始化NotifyConfigDB错误:', err)
|
|
||||||
reject(err)
|
|
||||||
} else {
|
|
||||||
if (count === 0) {
|
|
||||||
consola.log('初始化NotifyConfigDB✔')
|
consola.log('初始化NotifyConfigDB✔')
|
||||||
const defaultData = {
|
const defaultData = {
|
||||||
type: 'sct',
|
type: 'sct',
|
||||||
@ -114,14 +82,18 @@ function initNotifyConfigDB() {
|
|||||||
service: 'QQ',
|
service: 'QQ',
|
||||||
user: '',
|
user: '',
|
||||||
pass: ''
|
pass: ''
|
||||||
|
},
|
||||||
|
tg: {
|
||||||
|
token: '',
|
||||||
|
chatId: ''
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
await writeNotifyConfig(defaultData)
|
if (notifyConfig) {
|
||||||
|
await notifyConfigDB.removeAsync({ _id: notifyConfig._id })
|
||||||
|
delete notifyConfig._id
|
||||||
|
return notifyConfigDB.insertAsync(Object.assign({}, defaultData, notifyConfig))
|
||||||
}
|
}
|
||||||
}
|
return notifyConfigDB.insertAsync(defaultData)
|
||||||
resolve()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = async () => {
|
module.exports = async () => {
|
||||||
|
@ -1,50 +0,0 @@
|
|||||||
db目录,初始化后自动生成
|
|
||||||
|
|
||||||
**host.db**
|
|
||||||
|
|
||||||
> 存储服务器基本信息
|
|
||||||
|
|
||||||
**key.db**
|
|
||||||
|
|
||||||
> 用于加密的密钥相关
|
|
||||||
|
|
||||||
**credentials.db**
|
|
||||||
|
|
||||||
> ssh密钥记录(加密存储)
|
|
||||||
|
|
||||||
**email.db**
|
|
||||||
|
|
||||||
> 邮件配置
|
|
||||||
|
|
||||||
- port: 587 --> secure: false
|
|
||||||
```db
|
|
||||||
// Gmail调试不通过, 暂缓
|
|
||||||
{
|
|
||||||
"name": "Google邮箱",
|
|
||||||
"target": "google",
|
|
||||||
"host": "smtp.gmail.com",
|
|
||||||
"port": 465,
|
|
||||||
"secure": true,
|
|
||||||
"tls": {
|
|
||||||
"rejectUnauthorized": false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**notify.db**
|
|
||||||
|
|
||||||
> 通知配置
|
|
||||||
|
|
||||||
**group.db**
|
|
||||||
|
|
||||||
> 服务器分组配置
|
|
||||||
|
|
||||||
|
|
||||||
**scripts.db**
|
|
||||||
|
|
||||||
> 脚本库
|
|
||||||
|
|
||||||
|
|
||||||
**onekey.db**
|
|
||||||
|
|
||||||
> 批量指令记录
|
|
@ -1,35 +0,0 @@
|
|||||||
const NodeRSA = require('node-rsa')
|
|
||||||
const { readKey, writeKey } = require('./utils/storage')
|
|
||||||
const { randomStr } = require('./utils/tools')
|
|
||||||
const { AESEncryptSync } = require('./utils/encrypt')
|
|
||||||
|
|
||||||
// 初始化公私钥, 供登录、保存ssh密钥/密码等加解密
|
|
||||||
async function initRsa() {
|
|
||||||
let keyObj = await readKey()
|
|
||||||
if(keyObj.privateKey && keyObj.publicKey) return consola.info('公私钥已存在[重新生成会导致已保存的ssh密钥信息失效]')
|
|
||||||
let key = new NodeRSA({ b: 1024 })
|
|
||||||
key.setOptions({ encryptionScheme: 'pkcs1', environment: 'browser' })
|
|
||||||
let privateKey = key.exportKey('pkcs1-private-pem')
|
|
||||||
let publicKey = key.exportKey('pkcs8-public-pem')
|
|
||||||
keyObj.privateKey = await AESEncryptSync(privateKey) // 加密私钥
|
|
||||||
keyObj.publicKey = publicKey // 公开公钥
|
|
||||||
await writeKey(keyObj)
|
|
||||||
consola.info('Task: 已生成新的非对称加密公私钥')
|
|
||||||
}
|
|
||||||
|
|
||||||
// 随机的commonKey secret
|
|
||||||
async function randomJWTSecret() {
|
|
||||||
let keyObj = await readKey()
|
|
||||||
if(keyObj?.commonKey) return consola.info('commonKey密钥已存在')
|
|
||||||
|
|
||||||
keyObj.commonKey = randomStr(16)
|
|
||||||
await writeKey(keyObj)
|
|
||||||
consola.info('Task: 已生成新的随机commonKey密钥')
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = async () => {
|
|
||||||
await randomJWTSecret() // 全局密钥
|
|
||||||
await initRsa() // 全局公钥密钥
|
|
||||||
// 用于记录客户端登录IP的列表
|
|
||||||
global.loginRecord = []
|
|
||||||
}
|
|
@ -1,15 +1,13 @@
|
|||||||
const consola = require('consola')
|
|
||||||
global.consola = consola
|
|
||||||
const { httpServer } = require('./server')
|
const { httpServer } = require('./server')
|
||||||
const initDB = require('./db')
|
const initDB = require('./db')
|
||||||
const initEncryptConf = require('./init')
|
|
||||||
const scheduleJob = require('./schedule')
|
const scheduleJob = require('./schedule')
|
||||||
|
const getLicenseInfo = require('./utils/get-plus')
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
await initDB()
|
await initDB()
|
||||||
await initEncryptConf()
|
|
||||||
httpServer()
|
httpServer()
|
||||||
scheduleJob()
|
scheduleJob()
|
||||||
|
getLicenseInfo()
|
||||||
}
|
}
|
||||||
|
|
||||||
main()
|
main()
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
|
const ipFilter = require('./ipFilter') // IP过滤
|
||||||
const responseHandler = require('./response') // 统一返回格式, 错误捕获
|
const responseHandler = require('./response') // 统一返回格式, 错误捕获
|
||||||
const useAuth = require('./auth') // 鉴权
|
const useAuth = require('./auth') // 鉴权
|
||||||
// const useCors = require('./cors') // 处理跨域[暂时禁止]
|
// const useCors = require('./cors') // 处理跨域[暂时禁止]
|
||||||
@ -8,8 +9,8 @@ const useStatic = require('./static') // 静态目录
|
|||||||
const compress = require('./compress') // br/gzip压缩
|
const compress = require('./compress') // br/gzip压缩
|
||||||
const history = require('./history') // vue-router的history模式
|
const history = require('./history') // vue-router的history模式
|
||||||
|
|
||||||
// 注意注册顺序
|
|
||||||
module.exports = [
|
module.exports = [
|
||||||
|
ipFilter,
|
||||||
compress,
|
compress,
|
||||||
history,
|
history,
|
||||||
useStatic, // staic先注册,不然会被jwt拦截
|
useStatic, // staic先注册,不然会被jwt拦截
|
||||||
|
16
server/app/middlewares/ipFilter.js
Normal 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
|
@ -3,27 +3,28 @@ const { outDir, recordLog } = require('../config').logConfig
|
|||||||
|
|
||||||
log4js.configure({
|
log4js.configure({
|
||||||
appenders: {
|
appenders: {
|
||||||
// 控制台输出
|
console: {
|
||||||
out: {
|
|
||||||
type: 'stdout',
|
type: 'stdout',
|
||||||
layout: {
|
layout: {
|
||||||
type: 'colored'
|
type: 'pattern',
|
||||||
|
pattern: '%[%d{yyyy-MM-dd hh:mm:ss.SSS} [%p] -%] %m'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
// 保存日志文件
|
|
||||||
cheese: {
|
cheese: {
|
||||||
type: 'file',
|
type: 'file',
|
||||||
maxLogSize: 512*1024, // unit: bytes 1KB = 1024bytes
|
maxLogSize: 10 * 1024 * 1024, // unit: bytes 1KB = 1024bytes
|
||||||
filename: `${ outDir }/receive.log`
|
filename: `${ outDir }/receive.log`,
|
||||||
|
backups: 10,
|
||||||
|
compress: true,
|
||||||
|
keepFileExt: true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
categories: {
|
categories: {
|
||||||
default: {
|
default: {
|
||||||
appenders: [ 'out', 'cheese' ], // 配置
|
appenders: ['console', 'cheese'],
|
||||||
level: 'info' // 只输出info以上级别的日志
|
level: 'debug'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// pm2: true
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const logger = log4js.getLogger()
|
const logger = log4js.getLogger()
|
||||||
@ -56,3 +57,6 @@ const useLog = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
module.exports = useLog()
|
module.exports = useLog()
|
||||||
|
|
||||||
|
// 可以先测试一下日志是否正常工作
|
||||||
|
logger.info('日志系统启动')
|
@ -1,10 +1,11 @@
|
|||||||
const { getSSHList, addSSH, updateSSH, removeSSH, getCommand } = require('../controller/ssh')
|
const { getSSHList, addSSH, updateSSH, removeSSH, getCommand, decryptPrivateKey } = require('../controller/ssh')
|
||||||
const { getHostList, addHost, updateHost, removeHost, importHost } = require('../controller/host')
|
const { getHostList, addHost, updateHost, batchUpdateHost, removeHost, importHost } = require('../controller/host')
|
||||||
const { login, getpublicKey, updatePwd, getLoginRecord, getEasynodeVersion } = require('../controller/user')
|
const { login, getpublicKey, updatePwd, getEasynodeVersion, getMFA2Status, getMFA2Code, enableMFA2, disableMFA2, getPlusInfo, getPlusDiscount, getPlusConf, updatePlusKey } = require('../controller/user')
|
||||||
const { getNotifyConfig, updateNotifyConfig, getNotifyList, updateNotifyList } = require('../controller/notify')
|
const { getNotifyConfig, updateNotifyConfig, getNotifyList, updateNotifyList } = require('../controller/notify')
|
||||||
const { getGroupList, addGroupList, updateGroupList, removeGroup } = require('../controller/group')
|
const { getGroupList, addGroupList, updateGroupList, removeGroup } = require('../controller/group')
|
||||||
const { getScriptList, getLocalScriptList, addScript, updateScriptList, removeScript } = require('../controller/scripts')
|
const { getScriptList, getLocalScriptList, addScript, updateScriptList, removeScript, batchRemoveScript, importScript } = require('../controller/scripts')
|
||||||
const { getOnekeyRecord, removeOnekeyRecord } = require('../controller/onekey')
|
const { getOnekeyRecord, removeOnekeyRecord } = require('../controller/onekey')
|
||||||
|
const { getLog } = require('../controller/log')
|
||||||
|
|
||||||
const ssh = [
|
const ssh = [
|
||||||
{
|
{
|
||||||
@ -31,6 +32,11 @@ const ssh = [
|
|||||||
method: 'get',
|
method: 'get',
|
||||||
path: '/command',
|
path: '/command',
|
||||||
controller: getCommand
|
controller: getCommand
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'post',
|
||||||
|
path: '/decrypt-private-key',
|
||||||
|
controller: decryptPrivateKey
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
const host = [
|
const host = [
|
||||||
@ -49,6 +55,11 @@ const host = [
|
|||||||
path: '/host-save',
|
path: '/host-save',
|
||||||
controller: updateHost
|
controller: updateHost
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
method: 'put',
|
||||||
|
path: '/batch-update-host',
|
||||||
|
controller: batchUpdateHost
|
||||||
|
},
|
||||||
{
|
{
|
||||||
method: 'post',
|
method: 'post',
|
||||||
path: '/host-remove',
|
path: '/host-remove',
|
||||||
@ -78,13 +89,48 @@ const user = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
method: 'get',
|
method: 'get',
|
||||||
path: '/get-login-record',
|
path: '/version',
|
||||||
controller: getLoginRecord
|
controller: getEasynodeVersion
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
method: 'get',
|
method: 'get',
|
||||||
path: '/version',
|
path: '/mfa2-status',
|
||||||
controller: getEasynodeVersion
|
controller: getMFA2Status
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'post',
|
||||||
|
path: '/mfa2-code',
|
||||||
|
controller: getMFA2Code
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'post',
|
||||||
|
path: '/mfa2-enable',
|
||||||
|
controller: enableMFA2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'post',
|
||||||
|
path: '/mfa2-disable',
|
||||||
|
controller: disableMFA2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'get',
|
||||||
|
path: '/plus-info',
|
||||||
|
controller: getPlusInfo
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'get',
|
||||||
|
path: '/plus-discount',
|
||||||
|
controller: getPlusDiscount
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'get',
|
||||||
|
path: '/plus-conf',
|
||||||
|
controller: getPlusConf
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'post',
|
||||||
|
path: '/plus-conf',
|
||||||
|
controller: updatePlusKey
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
const notify = [
|
const notify = [
|
||||||
@ -154,10 +200,20 @@ const scripts = [
|
|||||||
path: '/script/:id',
|
path: '/script/:id',
|
||||||
controller: removeScript
|
controller: removeScript
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
method: 'post',
|
||||||
|
path: '/batch-remove-script',
|
||||||
|
controller: batchRemoveScript
|
||||||
|
},
|
||||||
{
|
{
|
||||||
method: 'put',
|
method: 'put',
|
||||||
path: '/script/:id',
|
path: '/script/:id',
|
||||||
controller: updateScriptList
|
controller: updateScriptList
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'post',
|
||||||
|
path: '/import-script',
|
||||||
|
controller: importScript
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
@ -173,4 +229,12 @@ const onekey = [
|
|||||||
controller: removeOnekeyRecord
|
controller: removeOnekeyRecord
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
module.exports = [].concat(ssh, host, user, notify, group, scripts, onekey)
|
|
||||||
|
const log = [
|
||||||
|
{
|
||||||
|
method: 'get',
|
||||||
|
path: '/log',
|
||||||
|
controller: getLog
|
||||||
|
}
|
||||||
|
]
|
||||||
|
module.exports = [].concat(ssh, host, user, notify, group, scripts, onekey, log)
|
||||||
|
@ -1,32 +0,0 @@
|
|||||||
const schedule = require('node-schedule')
|
|
||||||
const { asyncSendNotice } = require('../utils/notify')
|
|
||||||
const { readHostList } = require('../utils/storage')
|
|
||||||
const { formatTimestamp } = require('../utils/tools')
|
|
||||||
|
|
||||||
const expiredNotifyJob = async () => {
|
|
||||||
consola.info('=====开始检测服务器到期时间=====', new Date())
|
|
||||||
const hostList = await readHostList()
|
|
||||||
for (const item of hostList) {
|
|
||||||
if(!item.expiredNotify) continue
|
|
||||||
const { host, name, expired, consoleUrl } = item
|
|
||||||
const restDay = Number(((expired - Date.now()) / (1000 * 60 * 60 * 24)).toFixed(1))
|
|
||||||
console.log(Date.now(), restDay)
|
|
||||||
let title = '服务器到期提醒'
|
|
||||||
let content = `别名: ${ name }\nIP: ${ host }\n到期时间:${ formatTimestamp(expired, 'week') }\n控制台: ${ consoleUrl || '未填写' }`
|
|
||||||
if(0 <= restDay && restDay <= 1) {
|
|
||||||
let temp = '有服务器将在一天后到期,请关注\n'
|
|
||||||
asyncSendNotice('host_expired', title, temp + content)
|
|
||||||
}else if(3 <= restDay && restDay < 4) {
|
|
||||||
let temp = '有服务器将在三天后到期,请关注\n'
|
|
||||||
asyncSendNotice('host_expired', title, temp + content)
|
|
||||||
}else if(7 <= restDay && restDay < 8) {
|
|
||||||
let temp = '有服务器将在七天后到期,请关注\n'
|
|
||||||
asyncSendNotice('host_expired', title, temp + content)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = () => {
|
|
||||||
// 每天中午12点执行一次。
|
|
||||||
schedule.scheduleJob('0 0 12 1/1 * ?', expiredNotifyJob)
|
|
||||||
}
|
|
@ -1,5 +1,32 @@
|
|||||||
const expiredNotify = require('./expired-notify')
|
const schedule = require('node-schedule')
|
||||||
|
const { sendNoticeAsync } = require('../utils/notify')
|
||||||
|
const { formatTimestamp } = require('../utils/tools')
|
||||||
|
const { HostListDB } = require('../utils/db-class')
|
||||||
|
const hostListDB = new HostListDB().getInstance()
|
||||||
|
|
||||||
|
const expiredNotifyJob = async () => {
|
||||||
|
consola.info('=====开始检测服务器到期时间=====', new Date())
|
||||||
|
const hostList = await hostListDB.findAsync({})
|
||||||
|
for (const item of hostList) {
|
||||||
|
if (!item.expiredNotify) continue
|
||||||
|
const { host, name, expired, consoleUrl } = item
|
||||||
|
const restDay = Number(((expired - Date.now()) / (1000 * 60 * 60 * 24)).toFixed(1))
|
||||||
|
console.log(Date.now(), restDay)
|
||||||
|
let title = '服务器到期提醒'
|
||||||
|
let content = `别名: ${ name }\nIP: ${ host }\n到期时间:${ formatTimestamp(expired, 'week') }\n控制台: ${ consoleUrl || '未填写' }`
|
||||||
|
if (0 <= restDay && restDay <= 1) {
|
||||||
|
let temp = '有服务器将在一天后到期,请关注\n'
|
||||||
|
sendNoticeAsync('host_expired', title, temp + content)
|
||||||
|
} else if (3 <= restDay && restDay < 4) {
|
||||||
|
let temp = '有服务器将在三天后到期,请关注\n'
|
||||||
|
sendNoticeAsync('host_expired', title, temp + content)
|
||||||
|
} else if (7 <= restDay && restDay < 8) {
|
||||||
|
let temp = '有服务器将在七天后到期,请关注\n'
|
||||||
|
sendNoticeAsync('host_expired', title, temp + content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = () => {
|
module.exports = () => {
|
||||||
expiredNotify()
|
schedule.scheduleJob('0 0 12 1/1 * ?', expiredNotifyJob)
|
||||||
}
|
}
|
||||||
|
@ -5,7 +5,6 @@ const { httpPort } = require('./config')
|
|||||||
const middlewares = require('./middlewares')
|
const middlewares = require('./middlewares')
|
||||||
const wsTerminal = require('./socket/terminal')
|
const wsTerminal = require('./socket/terminal')
|
||||||
const wsSftp = require('./socket/sftp')
|
const wsSftp = require('./socket/sftp')
|
||||||
// const wsHostStatus = require('./socket/host-status')
|
|
||||||
const wsClientInfo = require('./socket/clients')
|
const wsClientInfo = require('./socket/clients')
|
||||||
const wsOnekey = require('./socket/onekey')
|
const wsOnekey = require('./socket/onekey')
|
||||||
const { throwError } = require('./utils/tools')
|
const { throwError } = require('./utils/tools')
|
||||||
@ -25,7 +24,6 @@ function serverHandler(app, server) {
|
|||||||
app.proxy = true // 用于nginx反代时获取真实客户端ip
|
app.proxy = true // 用于nginx反代时获取真实客户端ip
|
||||||
wsTerminal(server) // 终端
|
wsTerminal(server) // 终端
|
||||||
wsSftp(server) // sftp
|
wsSftp(server) // sftp
|
||||||
// wsHostStatus(server) // 终端侧边栏host信息(单个host)
|
|
||||||
wsOnekey(server) // 一键指令
|
wsOnekey(server) // 一键指令
|
||||||
wsClientInfo(server) // 客户端信息
|
wsClientInfo(server) // 客户端信息
|
||||||
app.context.throwError = throwError // 常用方法挂载全局ctx上
|
app.context.throwError = throwError // 常用方法挂载全局ctx上
|
||||||
|
@ -1,22 +1,26 @@
|
|||||||
const { Server: ServerIO } = require('socket.io')
|
const { Server: ServerIO } = require('socket.io')
|
||||||
const { io: ClientIO } = require('socket.io-client')
|
const { io: ClientIO } = require('socket.io-client')
|
||||||
const { readHostList } = require('../utils/storage')
|
const { defaultClientPort } = require('../config')
|
||||||
const { clientPort } = require('../config')
|
|
||||||
const { verifyAuthSync } = require('../utils/verify-auth')
|
const { verifyAuthSync } = require('../utils/verify-auth')
|
||||||
|
const { isAllowedIp } = require('../utils/tools')
|
||||||
|
const { HostListDB } = require('../utils/db-class')
|
||||||
|
const hostListDB = new HostListDB().getInstance()
|
||||||
|
|
||||||
let clientSockets = []
|
let clientSockets = []
|
||||||
let clientsData = {}
|
let clientsData = {}
|
||||||
|
|
||||||
async function getClientsInfo(clientSockets) {
|
async function getClientsInfo(clientSockets) {
|
||||||
let hostList = await readHostList()
|
let hostList = await hostListDB.findAsync({})
|
||||||
clientSockets.forEach((clientItem) => {
|
clientSockets.forEach((clientItem) => {
|
||||||
// 被删除的客户端断开连接
|
// 被删除的客户端断开连接
|
||||||
if (!hostList.some(item => item.host === clientItem.host)) clientItem.close && clientItem.close()
|
if (!hostList.some(item => item.host === clientItem.host)) clientItem.close && clientItem.close()
|
||||||
})
|
})
|
||||||
hostList
|
hostList
|
||||||
.map(({ host, name }) => {
|
.map(({ host, name, clientPort }) => {
|
||||||
if (clientSockets.some(item => item.host === host)) return { name, isIo: true } // 已经建立io连接(无论是否连接成功)的host不再重复建立连接,因为存在多次(reconnectionAttempts)的重试机制
|
// 已经建立io连接(无论是否连接成功)的host不再重复建立连接,因为存在多次(reconnectionAttempts)的重试机制
|
||||||
let clientSocket = ClientIO(`http://${ host }:${ clientPort }`, {
|
if (clientSockets.some(item => `${ item.host }:${ item.clientPort || defaultClientPort }` === `${ host }:${ clientPort || defaultClientPort }`)) return { name, isIo: true }
|
||||||
|
// console.log(name, 'clientPort:', clientPort)
|
||||||
|
let clientSocket = ClientIO(`http://${ host }:${ clientPort || defaultClientPort }`, {
|
||||||
path: '/client/os-info',
|
path: '/client/os-info',
|
||||||
forceNew: true,
|
forceNew: true,
|
||||||
timeout: 5000,
|
timeout: 5000,
|
||||||
@ -24,34 +28,35 @@ async function getClientsInfo(clientSockets) {
|
|||||||
reconnectionAttempts: 1000
|
reconnectionAttempts: 1000
|
||||||
})
|
})
|
||||||
// 将与客户端连接的socket实例保存起来,web端断开时关闭这些连接
|
// 将与客户端连接的socket实例保存起来,web端断开时关闭这些连接
|
||||||
clientSockets.push({ host, name, clientSocket })
|
clientSockets.push({ host, name, clientPort, clientSocket })
|
||||||
return {
|
return {
|
||||||
host,
|
host,
|
||||||
name,
|
name,
|
||||||
|
clientPort,
|
||||||
clientSocket
|
clientSocket
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.forEach((item) => {
|
.forEach((item) => {
|
||||||
if (item.isIo) return // console.log('已经建立io连接的host不再重复建立连接', item.name)
|
if (item.isIo) return // console.log('已经建立io连接的host不再重复建立连接', item.name)
|
||||||
const { host, name, clientSocket } = item
|
const { host, name, clientPort, clientSocket } = item
|
||||||
// clientsData[host] = { connect: false }
|
// eslint-disable-next-line no-unused-vars
|
||||||
clientSocket
|
clientSocket
|
||||||
.on('connect', () => {
|
.on('connect', () => {
|
||||||
consola.success('client connect success:', host, name)
|
consola.success('client connect success:', host, name)
|
||||||
clientSocket.on('client_data', (osData) => {
|
clientSocket.on('client_data', (osData) => {
|
||||||
clientsData[host] = { connect: true, ...osData }
|
clientsData[`${ host }:${ clientPort || defaultClientPort }`] = { connect: true, ...osData }
|
||||||
})
|
})
|
||||||
clientSocket.on('client_error', (error) => {
|
clientSocket.on('client_error', (error) => {
|
||||||
clientsData[host] = { connect: true, error: `client_error: ${ error }` }
|
clientsData[`${ host }:${ clientPort || defaultClientPort }`] = { connect: true, error: `client_error: ${ error }` }
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.on('connect_error', (error) => { // 连接失败
|
.on('connect_error', (error) => { // 连接失败
|
||||||
// consola.error('client connect fail:', host, name, error.message)
|
// consola.error('client connect fail:', host, name, error.message)
|
||||||
clientsData[host] = { connect: false, error: `client_connect_error: ${ error }` }
|
clientsData[`${ host }:${ clientPort || defaultClientPort }`] = { connect: false, error: `client_connect_error: ${ error }` }
|
||||||
})
|
})
|
||||||
.on('disconnect', (error) => { // 一方主动断开连接
|
.on('disconnect', (error) => { // 一方主动断开连接
|
||||||
// consola.info('client connect disconnect:', host, name)
|
// consola.info('client connect disconnect:', host, name)
|
||||||
clientsData[host] = { connect: false, error: `client_disconnect: ${ error }` }
|
clientsData[`${ host }:${ clientPort || defaultClientPort }`] = { connect: false, error: `client_disconnect: ${ error }` }
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@ -66,9 +71,14 @@ module.exports = (httpServer) => {
|
|||||||
|
|
||||||
serverIo.on('connection', (socket) => {
|
serverIo.on('connection', (socket) => {
|
||||||
// 前者兼容nginx反代, 后者兼容nodejs自身服务
|
// 前者兼容nginx反代, 后者兼容nodejs自身服务
|
||||||
let clientIp = socket.handshake.headers['x-forwarded-for'] || socket.handshake.address
|
let requestIP = socket.handshake.headers['x-forwarded-for'] || socket.handshake.address
|
||||||
|
if (!isAllowedIp(requestIP)) {
|
||||||
|
socket.emit('ip_forbidden', 'IP地址不在白名单中')
|
||||||
|
socket.disconnect()
|
||||||
|
return
|
||||||
|
}
|
||||||
socket.on('init_clients_data', async ({ token }) => {
|
socket.on('init_clients_data', async ({ token }) => {
|
||||||
const { code, msg } = await verifyAuthSync(token, clientIp)
|
const { code, msg } = await verifyAuthSync(token, requestIP)
|
||||||
if (code !== 1) {
|
if (code !== 1) {
|
||||||
socket.emit('token_verify_fail', msg || '鉴权失败')
|
socket.emit('token_verify_fail', msg || '鉴权失败')
|
||||||
socket.disconnect()
|
socket.disconnect()
|
||||||
|
@ -1,74 +0,0 @@
|
|||||||
const { Server: ServerIO } = require('socket.io')
|
|
||||||
const { io: ClientIO } = require('socket.io-client')
|
|
||||||
const { clientPort } = require('../config')
|
|
||||||
const { verifyAuthSync } = require('../utils/verify-auth')
|
|
||||||
|
|
||||||
let hostSockets = {}
|
|
||||||
|
|
||||||
function getHostInfo(serverSocket, host) {
|
|
||||||
let hostSocket = ClientIO(`http://${ host }:${ clientPort }`, {
|
|
||||||
path: '/client/os-info',
|
|
||||||
forceNew: false,
|
|
||||||
timeout: 5000,
|
|
||||||
reconnectionDelay: 3000,
|
|
||||||
reconnectionAttempts: 3
|
|
||||||
})
|
|
||||||
// 将与客户端连接的socket实例保存起来,web端断开时关闭与客户端的连接
|
|
||||||
hostSockets[serverSocket.id] = hostSocket
|
|
||||||
|
|
||||||
hostSocket
|
|
||||||
.on('connect', () => {
|
|
||||||
consola.success('host-status-socket连接成功:', host)
|
|
||||||
hostSocket.on('client_data', (data) => {
|
|
||||||
serverSocket.emit('host_data', data)
|
|
||||||
})
|
|
||||||
hostSocket.on('client_error', () => {
|
|
||||||
serverSocket.emit('host_data', null)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.on('connect_error', (error) => {
|
|
||||||
consola.error('host-status-socket连接[失败]:', host, error.message)
|
|
||||||
serverSocket.emit('host_data', null)
|
|
||||||
})
|
|
||||||
.on('disconnect', () => {
|
|
||||||
consola.info('host-status-socket连接[断开]:', host)
|
|
||||||
serverSocket.emit('host_data', null)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = (httpServer) => {
|
|
||||||
const serverIo = new ServerIO(httpServer, {
|
|
||||||
path: '/host-status',
|
|
||||||
cors: {
|
|
||||||
origin: '*' // 需配置跨域
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
serverIo.on('connection', (serverSocket) => {
|
|
||||||
// 前者兼容nginx反代, 后者兼容nodejs自身服务
|
|
||||||
let clientIp = serverSocket.handshake.headers['x-forwarded-for'] || serverSocket.handshake.address
|
|
||||||
serverSocket.on('init_host_data', async ({ token, host }) => {
|
|
||||||
// 校验登录态
|
|
||||||
const { code, msg } = await verifyAuthSync(token, clientIp)
|
|
||||||
if(code !== 1) {
|
|
||||||
serverSocket.emit('token_verify_fail', msg || '鉴权失败')
|
|
||||||
serverSocket.disconnect()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 获取客户端数据
|
|
||||||
getHostInfo(serverSocket, host)
|
|
||||||
|
|
||||||
consola.info('host-status-socket连接socketId: ', serverSocket.id, 'host-status-socket已连接数: ', Object.keys(hostSockets).length)
|
|
||||||
|
|
||||||
// 关闭连接
|
|
||||||
serverSocket.on('disconnect', () => {
|
|
||||||
// 当web端与服务端断开连接时, 服务端与每个客户端的socket也应该断开连接
|
|
||||||
let socket = hostSockets[serverSocket.id]
|
|
||||||
socket.close && socket.close()
|
|
||||||
delete hostSockets[serverSocket.id]
|
|
||||||
consola.info('host-status-socket剩余连接数: ', Object.keys(hostSockets).length)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
@ -1,10 +1,14 @@
|
|||||||
const { Server } = require('socket.io')
|
const { Server } = require('socket.io')
|
||||||
const { Client: SSHClient } = require('ssh2')
|
const { Client: SSHClient } = require('ssh2')
|
||||||
const { asyncSendNotice } = require('../utils/notify')
|
const { sendNoticeAsync } = require('../utils/notify')
|
||||||
const { readSSHRecord, readHostList, writeOneKeyRecord } = require('../utils/storage')
|
|
||||||
const { verifyAuthSync } = require('../utils/verify-auth')
|
const { verifyAuthSync } = require('../utils/verify-auth')
|
||||||
const { shellThrottle } = require('../utils/tools')
|
const { shellThrottle } = require('../utils/tools')
|
||||||
const { AESDecryptSync } = require('../utils/encrypt')
|
const { AESDecryptAsync } = require('../utils/encrypt')
|
||||||
|
const { isAllowedIp } = require('../utils/tools')
|
||||||
|
const { HostListDB, CredentialsDB, OnekeyDB } = require('../utils/db-class')
|
||||||
|
const hostListDB = new HostListDB().getInstance()
|
||||||
|
const credentialsDB = new CredentialsDB().getInstance()
|
||||||
|
const onekeyDB = new OnekeyDB().getInstance()
|
||||||
|
|
||||||
const execStatusEnum = {
|
const execStatusEnum = {
|
||||||
connecting: '连接中',
|
connecting: '连接中',
|
||||||
@ -90,7 +94,12 @@ module.exports = (httpServer) => {
|
|||||||
})
|
})
|
||||||
serverIo.on('connection', (socket) => {
|
serverIo.on('connection', (socket) => {
|
||||||
// 前者兼容nginx反代, 后者兼容nodejs自身服务
|
// 前者兼容nginx反代, 后者兼容nodejs自身服务
|
||||||
let clientIp = socket.handshake.headers['x-forwarded-for'] || socket.handshake.address
|
let requestIP = socket.handshake.headers['x-forwarded-for'] || socket.handshake.address
|
||||||
|
if (!isAllowedIp(requestIP)) {
|
||||||
|
socket.emit('ip_forbidden', 'IP地址不在白名单中')
|
||||||
|
socket.disconnect()
|
||||||
|
return
|
||||||
|
}
|
||||||
consola.success('onekey-terminal websocket 已连接')
|
consola.success('onekey-terminal websocket 已连接')
|
||||||
if (isExecuting) {
|
if (isExecuting) {
|
||||||
socket.emit('create_fail', '正在执行中, 请稍后再试')
|
socket.emit('create_fail', '正在执行中, 请稍后再试')
|
||||||
@ -98,8 +107,8 @@ module.exports = (httpServer) => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
isExecuting = true
|
isExecuting = true
|
||||||
socket.on('create', async ({ hosts, token, command, timeout }) => {
|
socket.on('create', async ({ hostIds, token, command, timeout }) => {
|
||||||
const { code } = await verifyAuthSync(token, clientIp)
|
const { code } = await verifyAuthSync(token, requestIP)
|
||||||
if (code !== 1) {
|
if (code !== 1) {
|
||||||
socket.emit('token_verify_fail')
|
socket.emit('token_verify_fail')
|
||||||
socket.disconnect()
|
socket.disconnect()
|
||||||
@ -115,18 +124,18 @@ module.exports = (httpServer) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
let reason = `执行超时,已强制终止执行 - 超时时间${ timeout }秒`
|
let reason = `执行超时,已强制终止执行 - 超时时间${ timeout }秒`
|
||||||
asyncSendNotice('onekey_complete', '批量指令执行超时', reason)
|
sendNoticeAsync('onekey_complete', '批量指令执行超时', reason)
|
||||||
socket.emit('timeout', { reason, result: execResult })
|
socket.emit('timeout', { reason, result: execResult })
|
||||||
socket.disconnect()
|
socket.disconnect()
|
||||||
disconnectAllExecClient()
|
disconnectAllExecClient()
|
||||||
}, timeout * 1000)
|
}, timeout * 1000)
|
||||||
console.log('hosts:', hosts)
|
console.log('hostIds:', hostIds)
|
||||||
// console.log('token:', token)
|
// console.log('token:', token)
|
||||||
console.log('command:', command)
|
console.log('command:', command)
|
||||||
const hostList = await readHostList()
|
const hostList = await hostListDB.findAsync({})
|
||||||
const targetHostsInfo = hostList.filter(item => hosts.some(ip => item.host === ip)) || {}
|
const targetHostsInfo = hostList.filter(item => hostIds.some(id => item._id === id)) || {}
|
||||||
// console.log('targetHostsInfo:', targetHostsInfo)
|
// console.log('targetHostsInfo:', targetHostsInfo)
|
||||||
if (!targetHostsInfo.length) return socket.emit('create_fail', `未找到【${ hosts }】服务器信息`)
|
if (!targetHostsInfo.length) return socket.emit('create_fail', `未找到【${ hostIds }】服务器信息`)
|
||||||
// 查找 hostInfo -> 并发执行
|
// 查找 hostInfo -> 并发执行
|
||||||
socket.emit('ready')
|
socket.emit('ready')
|
||||||
let execPromise = targetHostsInfo.map((hostInfo, index) => {
|
let execPromise = targetHostsInfo.map((hostInfo, index) => {
|
||||||
@ -135,17 +144,17 @@ module.exports = (httpServer) => {
|
|||||||
setTimeout(() => reject('执行超时'), timeout * 1000)
|
setTimeout(() => reject('执行超时'), timeout * 1000)
|
||||||
let { authType, host, port, username } = hostInfo
|
let { authType, host, port, username } = hostInfo
|
||||||
let authInfo = { host, port, username }
|
let authInfo = { host, port, username }
|
||||||
let curRes = { command, host, name: hostInfo.name, result: '', status: execStatusEnum.connecting, date: Date.now() - (targetHostsInfo.length - index) } // , execStatusEnum
|
let curRes = { command, host, port, name: hostInfo.name, result: '', status: execStatusEnum.connecting, date: Date.now() - (targetHostsInfo.length - index) } // , execStatusEnum
|
||||||
execResult.push(curRes)
|
execResult.push(curRes)
|
||||||
try {
|
try {
|
||||||
if (authType === 'credential') {
|
if (authType === 'credential') {
|
||||||
let credentialId = await AESDecryptSync(hostInfo['credential'])
|
let credentialId = await AESDecryptAsync(hostInfo['credential'])
|
||||||
const sshRecordList = await readSSHRecord()
|
const sshRecordList = await credentialsDB.findAsync({})
|
||||||
const sshRecord = sshRecordList.find(item => item._id === credentialId)
|
const sshRecord = sshRecordList.find(item => item._id === credentialId)
|
||||||
authInfo.authType = sshRecord.authType
|
authInfo.authType = sshRecord.authType
|
||||||
authInfo[authInfo.authType] = await AESDecryptSync(sshRecord[authInfo.authType])
|
authInfo[authInfo.authType] = await AESDecryptAsync(sshRecord[authInfo.authType])
|
||||||
} else {
|
} else {
|
||||||
authInfo[authType] = await AESDecryptSync(hostInfo[authType])
|
authInfo[authType] = await AESDecryptAsync(hostInfo[authType])
|
||||||
}
|
}
|
||||||
consola.info('准备连接终端执行一次性指令:', host)
|
consola.info('准备连接终端执行一次性指令:', host)
|
||||||
consola.log('连接信息', { username, port, authType })
|
consola.log('连接信息', { username, port, authType })
|
||||||
@ -182,7 +191,7 @@ module.exports = (httpServer) => {
|
|||||||
await Promise.all(execPromise)
|
await Promise.all(execPromise)
|
||||||
consola.success('onekey执行完成')
|
consola.success('onekey执行完成')
|
||||||
socket.emit('exec_complete')
|
socket.emit('exec_complete')
|
||||||
asyncSendNotice('onekey_complete', '批量指令执行完成', '请登录面板查看执行结果')
|
sendNoticeAsync('onekey_complete', '批量指令执行完成', '请登录面板查看执行结果')
|
||||||
socket.disconnect()
|
socket.disconnect()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
consola.error('onekey执行失败', error)
|
consola.error('onekey执行失败', error)
|
||||||
@ -199,7 +208,7 @@ module.exports = (httpServer) => {
|
|||||||
item.status = execStatusEnum.socketInterrupt
|
item.status = execStatusEnum.socketInterrupt
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
await writeOneKeyRecord(execResult)
|
await onekeyDB.insertAsync(execResult)
|
||||||
isExecuting = false
|
isExecuting = false
|
||||||
execResult = []
|
execResult = []
|
||||||
execClient = []
|
execClient = []
|
||||||
|
1
server/app/socket/plus.js
Normal 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
|
@ -5,8 +5,11 @@ const CryptoJS = require('crypto-js')
|
|||||||
const { Server } = require('socket.io')
|
const { Server } = require('socket.io')
|
||||||
const { sftpCacheDir } = require('../config')
|
const { sftpCacheDir } = require('../config')
|
||||||
const { verifyAuthSync } = require('../utils/verify-auth')
|
const { verifyAuthSync } = require('../utils/verify-auth')
|
||||||
const { AESDecryptSync } = require('../utils/encrypt')
|
const { AESDecryptAsync } = require('../utils/encrypt')
|
||||||
const { readSSHRecord, readHostList } = require('../utils/storage')
|
const { isAllowedIp } = require('../utils/tools')
|
||||||
|
const { HostListDB, CredentialsDB } = require('../utils/db-class')
|
||||||
|
const hostListDB = new HostListDB().getInstance()
|
||||||
|
const credentialsDB = new CredentialsDB().getInstance()
|
||||||
|
|
||||||
// 读取切片
|
// 读取切片
|
||||||
const pipeStream = (path, writeStream) => {
|
const pipeStream = (path, writeStream) => {
|
||||||
@ -35,6 +38,7 @@ function listenInput(sftpClient, socket) {
|
|||||||
socket.on('rm_dir', async (path) => {
|
socket.on('rm_dir', async (path) => {
|
||||||
const exists = await sftpClient.exists(path)
|
const exists = await sftpClient.exists(path)
|
||||||
if (!exists) return socket.emit('not_exists_dir', '目录不存在或当前不可访问')
|
if (!exists) return socket.emit('not_exists_dir', '目录不存在或当前不可访问')
|
||||||
|
consola.info('rm_dir: ', path)
|
||||||
try {
|
try {
|
||||||
let res = await sftpClient.rmdir(path, true) // 递归删除
|
let res = await sftpClient.rmdir(path, true) // 递归删除
|
||||||
socket.emit('rm_success', res)
|
socket.emit('rm_success', res)
|
||||||
@ -126,6 +130,7 @@ function listenInput(sftpClient, socket) {
|
|||||||
const exists = await sftpClient.exists(fullPath)
|
const exists = await sftpClient.exists(fullPath)
|
||||||
if (exists) continue
|
if (exists) continue
|
||||||
await sftpClient.mkdir(fullPath, true)
|
await sftpClient.mkdir(fullPath, true)
|
||||||
|
socket.emit('create_remote_dir_progress', fullPath)
|
||||||
consola.info('创建目录:', fullPath)
|
consola.info('创建目录:', fullPath)
|
||||||
}
|
}
|
||||||
socket.emit('create_remote_dir_success')
|
socket.emit('create_remote_dir_success')
|
||||||
@ -210,36 +215,41 @@ module.exports = (httpServer) => {
|
|||||||
})
|
})
|
||||||
serverIo.on('connection', (socket) => {
|
serverIo.on('connection', (socket) => {
|
||||||
// 前者兼容nginx反代, 后者兼容nodejs自身服务
|
// 前者兼容nginx反代, 后者兼容nodejs自身服务
|
||||||
let clientIp = socket.handshake.headers['x-forwarded-for'] || socket.handshake.address
|
let requestIP = socket.handshake.headers['x-forwarded-for'] || socket.handshake.address
|
||||||
|
if (!isAllowedIp(requestIP)) {
|
||||||
|
socket.emit('ip_forbidden', 'IP地址不在白名单中')
|
||||||
|
socket.disconnect()
|
||||||
|
return
|
||||||
|
}
|
||||||
let sftpClient = new SFTPClient()
|
let sftpClient = new SFTPClient()
|
||||||
consola.success('terminal websocket 已连接')
|
consola.success('terminal websocket 已连接')
|
||||||
|
|
||||||
socket.on('create', async ({ host: ip, token }) => {
|
socket.on('create', async ({ hostId, token }) => {
|
||||||
const { code } = await verifyAuthSync(token, clientIp)
|
const { code } = await verifyAuthSync(token, requestIP)
|
||||||
|
consola.log('code:', code)
|
||||||
if (code !== 1) {
|
if (code !== 1) {
|
||||||
socket.emit('token_verify_fail')
|
socket.emit('token_verify_fail')
|
||||||
socket.disconnect()
|
socket.disconnect()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
const targetHostInfo = await hostListDB.findOneAsync({ _id: hostId })
|
||||||
const hostList = await readHostList()
|
if (!targetHostInfo) throw new Error(`Host with ID ${ hostId } not found`)
|
||||||
const targetHostInfo = hostList.find(item => item.host === ip) || {}
|
|
||||||
let { authType, host, port, username } = targetHostInfo
|
let { authType, host, port, username } = targetHostInfo
|
||||||
if (!host) return socket.emit('create_fail', `查找【${ ip }】凭证信息失败`)
|
if (!host) return socket.emit('create_fail', `查找id【${ hostId }】凭证信息失败`)
|
||||||
let authInfo = { host, port, username }
|
let authInfo = { host, port, username }
|
||||||
|
|
||||||
// 解密放到try里面,防止报错【commonKey必须配对, 否则需要重新添加服务器密钥】
|
// 解密放到try里面,防止报错【commonKey必须配对, 否则需要重新添加服务器密钥】
|
||||||
if (authType === 'credential') {
|
if (authType === 'credential') {
|
||||||
let credentialId = await AESDecryptSync(targetHostInfo[authType])
|
let credentialId = await AESDecryptAsync(targetHostInfo[authType])
|
||||||
const sshRecordList = await readSSHRecord()
|
const sshRecordList = await credentialsDB.findAsync({})
|
||||||
const sshRecord = sshRecordList.find(item => item._id === credentialId)
|
const sshRecord = sshRecordList.find(item => item._id === credentialId)
|
||||||
authInfo.authType = sshRecord.authType
|
authInfo.authType = sshRecord.authType
|
||||||
authInfo[authInfo.authType] = await AESDecryptSync(sshRecord[authInfo.authType])
|
authInfo[authInfo.authType] = await AESDecryptAsync(sshRecord[authInfo.authType])
|
||||||
} else {
|
} else {
|
||||||
authInfo[authType] = await AESDecryptSync(targetHostInfo[authType])
|
authInfo[authType] = await AESDecryptAsync(targetHostInfo[authType])
|
||||||
}
|
}
|
||||||
consola.info('准备连接Sftp面板:', host)
|
consola.info('准备连接Sftp面板:', host)
|
||||||
targetHostInfo[targetHostInfo.authType] = await AESDecryptSync(targetHostInfo[targetHostInfo.authType])
|
targetHostInfo[targetHostInfo.authType] = await AESDecryptAsync(targetHostInfo[targetHostInfo.authType])
|
||||||
|
|
||||||
consola.log('连接信息', { username, port, authType })
|
consola.log('连接信息', { username, port, authType })
|
||||||
sftpClient
|
sftpClient
|
||||||
|
@ -1,105 +1,104 @@
|
|||||||
|
const path = require('path')
|
||||||
const { Server } = require('socket.io')
|
const { Server } = require('socket.io')
|
||||||
const { Client: SSHClient } = require('ssh2')
|
const { Client: SSHClient } = require('ssh2')
|
||||||
const { verifyAuthSync } = require('../utils/verify-auth')
|
const { verifyAuthSync } = require('../utils/verify-auth')
|
||||||
const { AESDecryptSync } = require('../utils/encrypt')
|
const { sendNoticeAsync } = require('../utils/notify')
|
||||||
const { readSSHRecord, readHostList } = require('../utils/storage')
|
const { isAllowedIp, ping } = require('../utils/tools')
|
||||||
const { asyncSendNotice } = require('../utils/notify')
|
const { AESDecryptAsync } = require('../utils/encrypt')
|
||||||
|
const { HostListDB, CredentialsDB } = require('../utils/db-class')
|
||||||
|
const decryptAndExecuteAsync = require('../utils/decrypt-file')
|
||||||
|
const hostListDB = new HostListDB().getInstance()
|
||||||
|
const credentialsDB = new CredentialsDB().getInstance()
|
||||||
|
|
||||||
function createInteractiveShell(socket, sshClient) {
|
async function getConnectionOptions(hostId) {
|
||||||
|
const hostInfo = await hostListDB.findOneAsync({ _id: hostId })
|
||||||
|
if (!hostInfo) throw new Error(`Host with ID ${ hostId } not found`)
|
||||||
|
let { authType, host, port, username, name } = hostInfo
|
||||||
|
let authInfo = { host, port, username }
|
||||||
|
try {
|
||||||
|
if (authType === 'credential') {
|
||||||
|
let credentialId = await AESDecryptAsync(hostInfo[authType])
|
||||||
|
const sshRecord = await credentialsDB.findOneAsync({ _id: credentialId })
|
||||||
|
authInfo.authType = sshRecord.authType
|
||||||
|
authInfo[authInfo.authType] = await AESDecryptAsync(sshRecord[authInfo.authType])
|
||||||
|
} else {
|
||||||
|
authInfo[authType] = await AESDecryptAsync(hostInfo[authType])
|
||||||
|
}
|
||||||
|
return { authInfo, name }
|
||||||
|
} catch (err) {
|
||||||
|
throw new Error(`解密认证信息失败: ${ err.message }`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createInteractiveShell(socket, targetSSHClient) {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
sshClient.shell({ term: 'xterm-color' }, (err, stream) => {
|
targetSSHClient.shell({ term: 'xterm-color' }, (err, stream) => {
|
||||||
resolve(stream)
|
resolve(stream)
|
||||||
if (err) return socket.emit('output', err.toString())
|
if (err) return socket.emit('output', err.toString())
|
||||||
// 终端输出
|
|
||||||
stream
|
stream
|
||||||
.on('data', (data) => {
|
.on('data', (data) => {
|
||||||
socket.emit('output', data.toString())
|
socket.emit('output', data.toString())
|
||||||
})
|
})
|
||||||
.on('close', () => {
|
.on('close', () => {
|
||||||
consola.info('交互终端已关闭')
|
consola.info('交互终端已关闭')
|
||||||
sshClient.end()
|
targetSSHClient.end()
|
||||||
})
|
})
|
||||||
socket.emit('connect_shell_success') // 已连接终端,web端可以执行指令了
|
socket.emit('connect_shell_success') // 已连接终端,web端可以执行指令了
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function execShell(sshClient, command = '', callback) {
|
async function createTerminal(hostId, socket, targetSSHClient) {
|
||||||
if (!command) return
|
return new Promise(async (resolve) => {
|
||||||
let result = ''
|
const targetHostInfo = await hostListDB.findOneAsync({ _id: hostId })
|
||||||
sshClient.exec(`source ~/.bashrc && ${ command }`, (err, stream) => {
|
if (!targetHostInfo) return socket.emit('create_fail', `查找hostId【${ hostId }】凭证信息失败`)
|
||||||
if (err) return callback(err.toString())
|
let { connectByJumpHosts = null } = (await decryptAndExecuteAsync(path.join(__dirname, 'plus.js'))) || {}
|
||||||
stream
|
let { authType, host, port, username, name, jumpHosts } = targetHostInfo
|
||||||
.on('data', (data) => {
|
try {
|
||||||
result += data.toString()
|
let { authInfo: targetConnectionOptions } = await getConnectionOptions(hostId)
|
||||||
})
|
let jumpHostResult = connectByJumpHosts && (await connectByJumpHosts(jumpHosts, targetConnectionOptions.host, targetConnectionOptions.port, socket))
|
||||||
.stderr
|
if (jumpHostResult) {
|
||||||
.on('data', (data) => {
|
targetConnectionOptions.sock = jumpHostResult.sock
|
||||||
result += data.toString()
|
|
||||||
})
|
|
||||||
.on('close', () => {
|
|
||||||
consola.info('一次性指令执行完成:', command)
|
|
||||||
callback(result)
|
|
||||||
})
|
|
||||||
.on('error', (error) => {
|
|
||||||
console.log('Error:', error.toString())
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createTerminal(ip, socket, sshClient) {
|
socket.emit('terminal_print_info', `准备连接目标终端: ${ name } - ${ host }`)
|
||||||
// eslint-disable-next-line no-async-promise-executor
|
socket.emit('terminal_print_info', `连接信息: ssh ${ username }@${ host } -p ${ port } -> ${ authType }`)
|
||||||
return new Promise(async (resolve) => {
|
|
||||||
const hostList = await readHostList()
|
consola.info('准备连接目标终端:', host)
|
||||||
const targetHostInfo = hostList.find(item => item.host === ip) || {}
|
|
||||||
let { authType, host, port, username, name } = targetHostInfo
|
|
||||||
if (!host) return socket.emit('create_fail', `查找【${ ip }】凭证信息失败`)
|
|
||||||
let authInfo = { host, port, username }
|
|
||||||
// 统一使用commonKey解密
|
|
||||||
try {
|
|
||||||
// 解密放到try里面,防止报错【commonKey必须配对, 否则需要重新添加服务器密钥】
|
|
||||||
if (authType === 'credential') {
|
|
||||||
let credentialId = await AESDecryptSync(targetHostInfo[authType])
|
|
||||||
const sshRecordList = await readSSHRecord()
|
|
||||||
const sshRecord = sshRecordList.find(item => item._id === credentialId)
|
|
||||||
authInfo.authType = sshRecord.authType
|
|
||||||
authInfo[authInfo.authType] = await AESDecryptSync(sshRecord[authInfo.authType])
|
|
||||||
} else {
|
|
||||||
authInfo[authType] = await AESDecryptSync(targetHostInfo[authType])
|
|
||||||
}
|
|
||||||
consola.info('准备连接终端:', host)
|
|
||||||
// targetHostInfo[targetHostInfo.authType] = await AESDecryptSync(targetHostInfo[targetHostInfo.authType])
|
|
||||||
consola.log('连接信息', { username, port, authType })
|
consola.log('连接信息', { username, port, authType })
|
||||||
sshClient
|
let closeNoticeFlag = false // 避免重复发送通知
|
||||||
|
targetSSHClient
|
||||||
.on('ready', async () => {
|
.on('ready', async () => {
|
||||||
asyncSendNotice('host_login', '终端登录', `别名: ${ name } \n IP:${ host } \n 端口:${ port } \n 状态: 登录成功`)
|
sendNoticeAsync('host_login', '终端登录', `别名: ${ name } \n IP:${ host } \n 端口:${ port } \n 状态: 登录成功`)
|
||||||
|
socket.emit('terminal_print_info', `终端连接成功: ${ name } - ${ host }`)
|
||||||
consola.success('终端连接成功:', host)
|
consola.success('终端连接成功:', host)
|
||||||
socket.emit('connect_terminal_success', `终端连接成功:${ host }`)
|
socket.emit('connect_terminal_success', `终端连接成功:${ host }`)
|
||||||
let stream = await createInteractiveShell(socket, sshClient)
|
let stream = await createInteractiveShell(socket, targetSSHClient)
|
||||||
resolve(stream)
|
resolve(stream)
|
||||||
// execShell(sshClient, 'history', (data) => {
|
|
||||||
// data = data.split('\n').filter(item => item)
|
|
||||||
// console.log(data)
|
|
||||||
// socket.emit('terminal_command_history', data)
|
|
||||||
// })
|
|
||||||
})
|
})
|
||||||
.on('close', () => {
|
.on('close', (err) => {
|
||||||
consola.info('终端连接断开close: ', host)
|
if (closeNoticeFlag) return closeNoticeFlag = false
|
||||||
socket.emit('connect_close')
|
const closeReason = err ? '发生错误导致连接断开' : '正常断开连接'
|
||||||
|
consola.info(`终端连接断开(${ closeReason }): ${ host }`)
|
||||||
|
socket.emit('connect_close', { reason: closeReason })
|
||||||
})
|
})
|
||||||
.on('error', (err) => {
|
.on('error', (err) => {
|
||||||
consola.log(err)
|
closeNoticeFlag = true
|
||||||
asyncSendNotice('host_login', '终端登录', `别名: ${ name } \n IP:${ host } \n 端口:${ port } \n 状态: 登录失败`)
|
sendNoticeAsync('host_login', '终端登录', `别名: ${ name } \n IP:${ host } \n 端口:${ port } \n 状态: 登录失败`)
|
||||||
consola.error('连接终端失败:', host, err.message)
|
consola.error('连接终端失败:', host, err.message)
|
||||||
socket.emit('connect_fail', err.message)
|
socket.emit('connect_terminal_fail', err.message)
|
||||||
|
})
|
||||||
|
.on('keyboard-interactive', function (name, instructions, instructionsLang, prompts, finish) {
|
||||||
|
finish([targetConnectionOptions[authType]])
|
||||||
})
|
})
|
||||||
.connect({
|
.connect({
|
||||||
...authInfo
|
tryKeyboard: true,
|
||||||
|
...targetConnectionOptions
|
||||||
// debug: (info) => console.log(info)
|
// debug: (info) => console.log(info)
|
||||||
})
|
})
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
consola.error('创建终端失败: ', host, err.message)
|
consola.error('创建终端失败: ', host, err.message)
|
||||||
socket.emit('create_fail', err.message)
|
socket.emit('create_terminal_fail', err.message)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@ -108,62 +107,57 @@ module.exports = (httpServer) => {
|
|||||||
const serverIo = new Server(httpServer, {
|
const serverIo = new Server(httpServer, {
|
||||||
path: '/terminal',
|
path: '/terminal',
|
||||||
cors: {
|
cors: {
|
||||||
origin: '*' // 'http://localhost:8080'
|
origin: '*'
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
let connectionCount = 0
|
||||||
|
|
||||||
serverIo.on('connection', (socket) => {
|
serverIo.on('connection', (socket) => {
|
||||||
// 前者兼容nginx反代, 后者兼容nodejs自身服务
|
connectionCount++
|
||||||
let clientIp = socket.handshake.headers['x-forwarded-for'] || socket.handshake.address
|
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 已连接')
|
consola.success('terminal websocket 已连接')
|
||||||
let sshClient = null
|
let targetSSHClient = null
|
||||||
socket.on('create', async ({ host: ip, token }) => {
|
socket.on('create', async ({ hostId, token }) => {
|
||||||
const { code } = await verifyAuthSync(token, clientIp)
|
const { code } = await verifyAuthSync(token, requestIP)
|
||||||
if (code !== 1) {
|
if (code !== 1) {
|
||||||
socket.emit('token_verify_fail')
|
socket.emit('token_verify_fail')
|
||||||
socket.disconnect()
|
socket.disconnect()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
sshClient = new SSHClient()
|
targetSSHClient = new SSHClient()
|
||||||
|
|
||||||
// 尝试手动断开调试,再次连接后终端输出内容为4份相同的输出,导致异常
|
|
||||||
// setTimeout(() => {
|
|
||||||
// sshClient.end()
|
|
||||||
// }, 3000)
|
|
||||||
let stream = null
|
let stream = null
|
||||||
|
|
||||||
function listenerInput(key) {
|
function listenerInput(key) {
|
||||||
if (sshClient._sock.writable === false) return consola.info('终端连接已关闭,禁止输入')
|
if (targetSSHClient._sock.writable === false) return consola.info('终端连接已关闭,禁止输入')
|
||||||
stream && stream.write(key)
|
stream && stream.write(key)
|
||||||
}
|
}
|
||||||
function resizeShell({ rows, cols }) {
|
function resizeShell({ rows, cols }) {
|
||||||
// consola.info('更改tty终端行&列: ', { rows, cols })
|
|
||||||
stream && stream.setWindow(rows, cols)
|
stream && stream.setWindow(rows, cols)
|
||||||
}
|
}
|
||||||
socket.on('input', listenerInput)
|
socket.on('input', listenerInput)
|
||||||
socket.on('resize', resizeShell)
|
socket.on('resize', resizeShell)
|
||||||
// 重连
|
stream = await createTerminal(hostId, socket, targetSSHClient)
|
||||||
socket.on('reconnect_terminal', async () => {
|
|
||||||
consola.info('重连终端: ', ip)
|
|
||||||
socket.off('input', listenerInput) // 取消监听,重新注册监听,操作新的stream
|
|
||||||
socket.off('resize', resizeShell)
|
|
||||||
sshClient?.end()
|
|
||||||
sshClient?.destroy()
|
|
||||||
sshClient = null
|
|
||||||
stream = null
|
|
||||||
setTimeout(async () => {
|
|
||||||
// 初始化新的SSH客户端对象
|
|
||||||
sshClient = new SSHClient()
|
|
||||||
stream = await createTerminal(ip, socket, sshClient)
|
|
||||||
socket.emit('reconnect_terminal_success')
|
|
||||||
socket.on('input', listenerInput)
|
|
||||||
socket.on('resize', resizeShell)
|
|
||||||
}, 3000)
|
|
||||||
})
|
})
|
||||||
stream = await createTerminal(ip, socket, sshClient)
|
|
||||||
|
socket.on('get_ping', async (ip) => {
|
||||||
|
try {
|
||||||
|
socket.emit('ping_data', await ping(ip, 2500))
|
||||||
|
} catch (error) {
|
||||||
|
socket.emit('ping_data', { success: false, msg: error.message })
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
socket.on('disconnect', (reason) => {
|
socket.on('disconnect', (reason) => {
|
||||||
consola.info('终端socket连接断开:', reason)
|
connectionCount--
|
||||||
|
consola.info(`终端socket连接断开: ${ reason } - 当前连接数: ${ connectionCount }`)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
module.exports.getConnectionOptions = getConnectionOptions
|
||||||
|
43
server/app/template/ipForbidden.html
Normal 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>
|
@ -7,13 +7,16 @@ const {
|
|||||||
notifyConfigDBPath,
|
notifyConfigDBPath,
|
||||||
groupConfDBPath,
|
groupConfDBPath,
|
||||||
scriptsDBPath,
|
scriptsDBPath,
|
||||||
onekeyDBPath
|
onekeyDBPath,
|
||||||
|
logDBPath,
|
||||||
|
plusDBPath
|
||||||
} = require('../config')
|
} = require('../config')
|
||||||
|
|
||||||
module.exports.KeyDB = class KeyDB {
|
module.exports.KeyDB = class KeyDB {
|
||||||
constructor() {
|
constructor() {
|
||||||
if (!KeyDB.instance) {
|
if (!KeyDB.instance) {
|
||||||
KeyDB.instance = new Datastore({ filename: keyDBPath, autoload: true })
|
KeyDB.instance = new Datastore({ filename: keyDBPath, autoload: true })
|
||||||
|
// KeyDB.instance.setAutocompactionInterval(5000)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
getInstance() {
|
getInstance() {
|
||||||
@ -25,6 +28,7 @@ module.exports.HostListDB = class HostListDB {
|
|||||||
constructor() {
|
constructor() {
|
||||||
if (!HostListDB.instance) {
|
if (!HostListDB.instance) {
|
||||||
HostListDB.instance = new Datastore({ filename: hostListDBPath, autoload: true })
|
HostListDB.instance = new Datastore({ filename: hostListDBPath, autoload: true })
|
||||||
|
// HostListDB.instance.setAutocompactionInterval(5000)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
getInstance() {
|
getInstance() {
|
||||||
@ -32,14 +36,15 @@ module.exports.HostListDB = class HostListDB {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports.SshRecordDB = class SshRecordDB {
|
module.exports.CredentialsDB = class CredentialsDB {
|
||||||
constructor() {
|
constructor() {
|
||||||
if (!SshRecordDB.instance) {
|
if (!CredentialsDB.instance) {
|
||||||
SshRecordDB.instance = new Datastore({ filename: credentialsDBPath, autoload: true })
|
CredentialsDB.instance = new Datastore({ filename: credentialsDBPath, autoload: true })
|
||||||
|
// CredentialsDB.instance.setAutocompactionInterval(5000)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
getInstance() {
|
getInstance() {
|
||||||
return SshRecordDB.instance
|
return CredentialsDB.instance
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -47,6 +52,7 @@ module.exports.NotifyDB = class NotifyDB {
|
|||||||
constructor() {
|
constructor() {
|
||||||
if (!NotifyDB.instance) {
|
if (!NotifyDB.instance) {
|
||||||
NotifyDB.instance = new Datastore({ filename: notifyDBPath, autoload: true })
|
NotifyDB.instance = new Datastore({ filename: notifyDBPath, autoload: true })
|
||||||
|
// NotifyDB.instance.setAutocompactionInterval(5000)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
getInstance() {
|
getInstance() {
|
||||||
@ -58,6 +64,7 @@ module.exports.NotifyConfigDB = class NotifyConfigDB {
|
|||||||
constructor() {
|
constructor() {
|
||||||
if (!NotifyConfigDB.instance) {
|
if (!NotifyConfigDB.instance) {
|
||||||
NotifyConfigDB.instance = new Datastore({ filename: notifyConfigDBPath, autoload: true })
|
NotifyConfigDB.instance = new Datastore({ filename: notifyConfigDBPath, autoload: true })
|
||||||
|
// NotifyConfigDB.instance.setAutocompactionInterval(5000)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
getInstance() {
|
getInstance() {
|
||||||
@ -69,6 +76,7 @@ module.exports.GroupDB = class GroupDB {
|
|||||||
constructor() {
|
constructor() {
|
||||||
if (!GroupDB.instance) {
|
if (!GroupDB.instance) {
|
||||||
GroupDB.instance = new Datastore({ filename: groupConfDBPath, autoload: true })
|
GroupDB.instance = new Datastore({ filename: groupConfDBPath, autoload: true })
|
||||||
|
// GroupDB.instance.setAutocompactionInterval(5000)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
getInstance() {
|
getInstance() {
|
||||||
@ -80,6 +88,7 @@ module.exports.ScriptsDB = class ScriptsDB {
|
|||||||
constructor() {
|
constructor() {
|
||||||
if (!ScriptsDB.instance) {
|
if (!ScriptsDB.instance) {
|
||||||
ScriptsDB.instance = new Datastore({ filename: scriptsDBPath, autoload: true })
|
ScriptsDB.instance = new Datastore({ filename: scriptsDBPath, autoload: true })
|
||||||
|
// ScriptsDB.instance.setAutocompactionInterval(5000)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
getInstance() {
|
getInstance() {
|
||||||
@ -91,9 +100,33 @@ module.exports.OnekeyDB = class OnekeyDB {
|
|||||||
constructor() {
|
constructor() {
|
||||||
if (!OnekeyDB.instance) {
|
if (!OnekeyDB.instance) {
|
||||||
OnekeyDB.instance = new Datastore({ filename: onekeyDBPath, autoload: true })
|
OnekeyDB.instance = new Datastore({ filename: onekeyDBPath, autoload: true })
|
||||||
|
// OnekeyDB.instance.setAutocompactionInterval(5000)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
getInstance() {
|
getInstance() {
|
||||||
return OnekeyDB.instance
|
return OnekeyDB.instance
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
module.exports.LogDB = class LogDB {
|
||||||
|
constructor() {
|
||||||
|
if (!LogDB.instance) {
|
||||||
|
LogDB.instance = new Datastore({ filename: logDBPath, autoload: true })
|
||||||
|
// LogDB.instance.setAutocompactionInterval(5000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
getInstance() {
|
||||||
|
return LogDB.instance
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports.PlusDB = class PlusDB {
|
||||||
|
constructor() {
|
||||||
|
if (!PlusDB.instance) {
|
||||||
|
PlusDB.instance = new Datastore({ filename: plusDBPath, autoload: true })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
getInstance() {
|
||||||
|
return PlusDB.instance
|
||||||
|
}
|
||||||
|
}
|
53
server/app/utils/decrypt-file.js
Normal 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
|
@ -1,13 +1,14 @@
|
|||||||
const CryptoJS = require('crypto-js')
|
const CryptoJS = require('crypto-js')
|
||||||
const rawCrypto = require('crypto')
|
const rawCrypto = require('crypto')
|
||||||
const NodeRSA = require('node-rsa')
|
const NodeRSA = require('node-rsa')
|
||||||
const { readKey } = require('./storage.js')
|
const { KeyDB } = require('./db-class')
|
||||||
|
const keyDB = new KeyDB().getInstance()
|
||||||
|
|
||||||
// rsa非对称 私钥解密
|
// rsa非对称 私钥解密
|
||||||
const RSADecryptSync = async (ciphertext) => {
|
const RSADecryptAsync = async (ciphertext) => {
|
||||||
if (!ciphertext) return
|
if (!ciphertext) return
|
||||||
let { privateKey } = await readKey()
|
let { privateKey } = await keyDB.findOneAsync({})
|
||||||
privateKey = await AESDecryptSync(privateKey) // 先解密私钥
|
privateKey = await AESDecryptAsync(privateKey) // 先解密私钥
|
||||||
const rsakey = new NodeRSA(privateKey)
|
const rsakey = new NodeRSA(privateKey)
|
||||||
rsakey.setOptions({ encryptionScheme: 'pkcs1', environment: 'browser' }) // Must Set It When Frontend Use jsencrypt
|
rsakey.setOptions({ encryptionScheme: 'pkcs1', environment: 'browser' }) // Must Set It When Frontend Use jsencrypt
|
||||||
const plaintext = rsakey.decrypt(ciphertext, 'utf8')
|
const plaintext = rsakey.decrypt(ciphertext, 'utf8')
|
||||||
@ -15,17 +16,17 @@ const RSADecryptSync = async (ciphertext) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// aes对称 加密(default commonKey)
|
// aes对称 加密(default commonKey)
|
||||||
const AESEncryptSync = async (text, key) => {
|
const AESEncryptAsync = async (text, key) => {
|
||||||
if (!text) return
|
if (!text) return
|
||||||
let { commonKey } = await readKey()
|
let { commonKey } = await keyDB.findOneAsync({})
|
||||||
let ciphertext = CryptoJS.AES.encrypt(text, key || commonKey).toString()
|
let ciphertext = CryptoJS.AES.encrypt(text, key || commonKey).toString()
|
||||||
return ciphertext
|
return ciphertext
|
||||||
}
|
}
|
||||||
|
|
||||||
// aes对称 解密(default commonKey)
|
// aes对称 解密(default commonKey)
|
||||||
const AESDecryptSync = async (ciphertext, key) => {
|
const AESDecryptAsync = async (ciphertext, key) => {
|
||||||
if (!ciphertext) return
|
if (!ciphertext) return
|
||||||
let { commonKey } = await readKey()
|
let { commonKey } = await keyDB.findOneAsync({})
|
||||||
let bytes = CryptoJS.AES.decrypt(ciphertext, key || commonKey)
|
let bytes = CryptoJS.AES.decrypt(ciphertext, key || commonKey)
|
||||||
let originalText = bytes.toString(CryptoJS.enc.Utf8)
|
let originalText = bytes.toString(CryptoJS.enc.Utf8)
|
||||||
return originalText
|
return originalText
|
||||||
@ -37,8 +38,8 @@ const SHA1Encrypt = (clearText) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
RSADecryptSync,
|
RSADecryptAsync,
|
||||||
AESEncryptSync,
|
AESEncryptAsync,
|
||||||
AESDecryptSync,
|
AESDecryptAsync,
|
||||||
SHA1Encrypt
|
SHA1Encrypt
|
||||||
}
|
}
|
93
server/app/utils/get-plus.js
Normal 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
|
@ -1,10 +1,13 @@
|
|||||||
|
const path = require('path')
|
||||||
|
const decryptAndExecuteAsync = require('./decrypt-file')
|
||||||
const nodemailer = require('nodemailer')
|
const nodemailer = require('nodemailer')
|
||||||
const axios = require('axios')
|
const axios = require('axios')
|
||||||
const { getNotifySwByType, readNotifyConfig } = require('../utils/storage')
|
|
||||||
const commonTemp = require('../template/commonTemp')
|
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) {
|
function sendServerChan(sendKey, title, content) {
|
||||||
if (!sendKey) return consola.error('发送server酱通知失败, sendKey 为空')
|
|
||||||
return new Promise((async (resolve, reject) => {
|
return new Promise((async (resolve, reject) => {
|
||||||
try {
|
try {
|
||||||
consola.info('server酱通知预发送: ', title)
|
consola.info('server酱通知预发送: ', title)
|
||||||
@ -26,7 +29,6 @@ function sendServerChan(sendKey, title, content) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function sendEmail({ service, user, pass }, title, content) {
|
function sendEmail({ service, user, pass }, title, content) {
|
||||||
if (!service || !user || !pass) return consola.info('发送通知失败, 邮箱配置信息不完整: ', { service, user, pass })
|
|
||||||
return new Promise((async (resolve, reject) => {
|
return new Promise((async (resolve, reject) => {
|
||||||
try {
|
try {
|
||||||
consola.info('邮箱通知预发送: ', title)
|
consola.info('邮箱通知预发送: ', title)
|
||||||
@ -54,12 +56,13 @@ function sendEmail({ service, user, pass }, title, content) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 异步发送通知
|
// 异步发送通知
|
||||||
async function asyncSendNotice(noticeAction, title, content) {
|
async function sendNoticeAsync(noticeAction, title, content) {
|
||||||
try {
|
try {
|
||||||
let sw = await getNotifySwByType(noticeAction) // 获取对应动作的通知开关
|
let notifyList = await notifyDB.findAsync({})
|
||||||
console.log(noticeAction, sw)
|
let { sw } = notifyList.find((item) => item.type === noticeAction) // 获取对应动作的通知开关
|
||||||
if (!sw) return
|
// console.log('notify swtich: ', noticeAction, sw)
|
||||||
let notifyConfig = await readNotifyConfig()
|
if (!sw) return consola.info('通知开关关闭, 不发送通知: ', noticeAction)
|
||||||
|
let notifyConfig = await notifyConfigDB.findOneAsync({})
|
||||||
let { type } = notifyConfig
|
let { type } = notifyConfig
|
||||||
if (!type) return consola.error('通知类型不存在: ', type)
|
if (!type) return consola.error('通知类型不存在: ', type)
|
||||||
title = `EasyNode-${ title }`
|
title = `EasyNode-${ title }`
|
||||||
@ -75,7 +78,15 @@ async function asyncSendNotice(noticeAction, title, content) {
|
|||||||
if (!service || !user || !pass) return consola.info('未发送邮件通知通知, 未配置邮箱: ', { service, user, pass })
|
if (!service || !user || !pass) return consola.info('未发送邮件通知通知, 未配置邮箱: ', { service, user, pass })
|
||||||
await sendEmail({ service, user, pass }, title, content)
|
await sendEmail({ service, user, pass }, title, content)
|
||||||
break
|
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:
|
default:
|
||||||
|
consola.info('未配置通知类型: ', type)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -84,7 +95,7 @@ async function asyncSendNotice(noticeAction, title, content) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
asyncSendNotice,
|
sendNoticeAsync,
|
||||||
sendServerChan,
|
sendServerChan,
|
||||||
sendEmail
|
sendEmail
|
||||||
}
|
}
|
4
server/app/utils/plus-server.js
Normal 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
@ -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=
|
@ -1,308 +0,0 @@
|
|||||||
const { KeyDB, HostListDB, SshRecordDB, NotifyDB, NotifyConfigDB, ScriptsDB, GroupDB, OnekeyDB } = require('./db-class')
|
|
||||||
|
|
||||||
const readKey = async () => {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const keyDB = new KeyDB().getInstance()
|
|
||||||
keyDB.findOne({}, (err, doc) => {
|
|
||||||
if (err) {
|
|
||||||
reject(err)
|
|
||||||
} else {
|
|
||||||
resolve(doc)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const writeKey = async (keyObj = {}) => {
|
|
||||||
const keyDB = new KeyDB().getInstance()
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
keyDB.update({}, { $set: keyObj }, { upsert: true }, (err, numReplaced) => {
|
|
||||||
if (err) {
|
|
||||||
reject(err)
|
|
||||||
} else {
|
|
||||||
keyDB.compactDatafile()
|
|
||||||
resolve(numReplaced)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const readSSHRecord = async () => {
|
|
||||||
const sshRecordDB = new SshRecordDB().getInstance()
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
sshRecordDB.find({}, (err, docs) => {
|
|
||||||
if (err) {
|
|
||||||
consola.error('读取ssh-record-db错误: ', err)
|
|
||||||
reject(err)
|
|
||||||
} else {
|
|
||||||
resolve(docs)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const writeSSHRecord = async (record = []) => {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const sshRecordDB = new SshRecordDB().getInstance()
|
|
||||||
sshRecordDB.remove({}, { multi: true }, (err) => {
|
|
||||||
if (err) {
|
|
||||||
consola.error('清空SSHRecord出错:', err)
|
|
||||||
reject(err)
|
|
||||||
} else {
|
|
||||||
sshRecordDB.compactDatafile()
|
|
||||||
sshRecordDB.insert(record, (err, newDocs) => {
|
|
||||||
if (err) {
|
|
||||||
consola.error('写入新的ssh记录出错:', err)
|
|
||||||
reject(err)
|
|
||||||
} else {
|
|
||||||
sshRecordDB.compactDatafile()
|
|
||||||
resolve(newDocs)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const readHostList = async () => {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const hostListDB = new HostListDB().getInstance()
|
|
||||||
hostListDB.find({}, (err, docs) => {
|
|
||||||
if (err) {
|
|
||||||
consola.error('读取host-list-db错误:', err)
|
|
||||||
reject(err)
|
|
||||||
} else {
|
|
||||||
resolve(docs)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const writeHostList = async (record = []) => {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const hostListDB = new HostListDB().getInstance()
|
|
||||||
hostListDB.remove({}, { multi: true }, (err) => {
|
|
||||||
if (err) {
|
|
||||||
consola.error('清空HostList出错:', err)
|
|
||||||
reject(err)
|
|
||||||
} else {
|
|
||||||
hostListDB.compactDatafile()
|
|
||||||
// 插入新的数据列表
|
|
||||||
hostListDB.insert(record, (err, newDocs) => {
|
|
||||||
if (err) {
|
|
||||||
consola.error('写入新的HostList出错:', err)
|
|
||||||
reject(err)
|
|
||||||
} else {
|
|
||||||
hostListDB.compactDatafile()
|
|
||||||
resolve(newDocs)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const readNotifyConfig = async () => {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const notifyConfigDB = new NotifyConfigDB().getInstance()
|
|
||||||
notifyConfigDB.findOne({}, (err, doc) => {
|
|
||||||
if (err) {
|
|
||||||
reject(err)
|
|
||||||
} else {
|
|
||||||
resolve(doc)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const writeNotifyConfig = async (keyObj = {}) => {
|
|
||||||
const notifyConfigDB = new NotifyConfigDB().getInstance()
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
notifyConfigDB.update({}, { $set: keyObj }, { upsert: true }, (err, numReplaced) => {
|
|
||||||
if (err) {
|
|
||||||
reject(err)
|
|
||||||
} else {
|
|
||||||
notifyConfigDB.compactDatafile()
|
|
||||||
resolve(numReplaced)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const getNotifySwByType = async (type) => {
|
|
||||||
if (!type) throw Error('missing params: type')
|
|
||||||
try {
|
|
||||||
let notifyList = await readNotifyList()
|
|
||||||
let { sw } = notifyList.find((item) => item.type === type)
|
|
||||||
return sw
|
|
||||||
} catch (error) {
|
|
||||||
consola.error(`通知类型[${ type }]不存在`)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const readNotifyList = async () => {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const notifyDB = new NotifyDB().getInstance()
|
|
||||||
notifyDB.find({}, (err, docs) => {
|
|
||||||
if (err) {
|
|
||||||
consola.error('读取notify list错误: ', err)
|
|
||||||
reject(err)
|
|
||||||
} else {
|
|
||||||
resolve(docs)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const writeNotifyList = async (notifyList) => {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const notifyDB = new NotifyDB().getInstance()
|
|
||||||
notifyDB.remove({}, { multi: true }, (err) => {
|
|
||||||
if (err) {
|
|
||||||
consola.error('清空notify list出错:', err)
|
|
||||||
reject(err)
|
|
||||||
} else {
|
|
||||||
notifyDB.compactDatafile()
|
|
||||||
notifyDB.insert(notifyList, (err, newDocs) => {
|
|
||||||
if (err) {
|
|
||||||
consola.error('写入新的notify list出错:', err)
|
|
||||||
reject(err)
|
|
||||||
} else {
|
|
||||||
notifyDB.compactDatafile()
|
|
||||||
resolve(newDocs)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const readGroupList = async () => {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const groupDB = new GroupDB().getInstance()
|
|
||||||
groupDB.find({}, (err, docs) => {
|
|
||||||
if (err) {
|
|
||||||
consola.error('读取group list错误: ', err)
|
|
||||||
reject(err)
|
|
||||||
} else {
|
|
||||||
resolve(docs)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const writeGroupList = async (list = []) => {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const groupDB = new GroupDB().getInstance()
|
|
||||||
groupDB.remove({}, { multi: true }, (err) => {
|
|
||||||
if (err) {
|
|
||||||
consola.error('清空group list出错:', err)
|
|
||||||
reject(err)
|
|
||||||
} else {
|
|
||||||
groupDB.compactDatafile()
|
|
||||||
groupDB.insert(list, (err, newDocs) => {
|
|
||||||
if (err) {
|
|
||||||
consola.error('写入新的group list出错:', err)
|
|
||||||
reject(err)
|
|
||||||
} else {
|
|
||||||
groupDB.compactDatafile()
|
|
||||||
resolve(newDocs)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const readScriptList = async () => {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const scriptsDB = new ScriptsDB().getInstance()
|
|
||||||
scriptsDB.find({}, (err, docs) => {
|
|
||||||
if (err) {
|
|
||||||
consola.error('读取scripts list错误: ', err)
|
|
||||||
reject(err)
|
|
||||||
} else {
|
|
||||||
resolve(docs)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const writeScriptList = async (list = []) => {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const scriptsDB = new ScriptsDB().getInstance()
|
|
||||||
scriptsDB.remove({}, { multi: true }, (err) => {
|
|
||||||
if (err) {
|
|
||||||
consola.error('清空scripts list出错:', err)
|
|
||||||
reject(err)
|
|
||||||
} else {
|
|
||||||
scriptsDB.compactDatafile()
|
|
||||||
scriptsDB.insert(list, (err, newDocs) => {
|
|
||||||
if (err) {
|
|
||||||
consola.error('写入新的group list出错:', err)
|
|
||||||
reject(err)
|
|
||||||
} else {
|
|
||||||
scriptsDB.compactDatafile()
|
|
||||||
resolve(newDocs)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const readOneKeyRecord = async () => {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const onekeyDB = new OnekeyDB().getInstance()
|
|
||||||
onekeyDB.find({}, (err, docs) => {
|
|
||||||
if (err) {
|
|
||||||
consola.error('读取onekey record错误: ', err)
|
|
||||||
reject(err)
|
|
||||||
} else {
|
|
||||||
onekeyDB.compactDatafile()
|
|
||||||
resolve(docs)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const writeOneKeyRecord = async (records =[]) => {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const onekeyDB = new OnekeyDB().getInstance()
|
|
||||||
onekeyDB.insert(records, (err, newDocs) => {
|
|
||||||
if (err) {
|
|
||||||
consola.error('写入新的onekey记录出错:', err)
|
|
||||||
reject(err)
|
|
||||||
} else {
|
|
||||||
onekeyDB.compactDatafile()
|
|
||||||
resolve(newDocs)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
const deleteOneKeyRecord = async (ids =[]) => {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const onekeyDB = new OnekeyDB().getInstance()
|
|
||||||
onekeyDB.remove({ _id: { $in: ids } }, { multi: true }, function (err, numRemoved) {
|
|
||||||
if (err) {
|
|
||||||
consola.error('Error deleting onekey record(s):', err)
|
|
||||||
reject(err)
|
|
||||||
} else {
|
|
||||||
onekeyDB.compactDatafile()
|
|
||||||
resolve(numRemoved)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
readSSHRecord, writeSSHRecord,
|
|
||||||
readHostList, writeHostList,
|
|
||||||
readKey, writeKey,
|
|
||||||
readNotifyList, writeNotifyList,
|
|
||||||
readNotifyConfig, writeNotifyConfig, getNotifySwByType,
|
|
||||||
readGroupList, writeGroupList,
|
|
||||||
readScriptList, writeScriptList,
|
|
||||||
readOneKeyRecord, writeOneKeyRecord, deleteOneKeyRecord
|
|
||||||
}
|
|
@ -1,4 +1,7 @@
|
|||||||
|
const { exec } = require('child_process')
|
||||||
|
const os = require('os')
|
||||||
const net = require('net')
|
const net = require('net')
|
||||||
|
const iconv = require('iconv-lite')
|
||||||
const axios = require('axios')
|
const axios = require('axios')
|
||||||
const request = axios.create({ timeout: 3000 })
|
const request = axios.create({ timeout: 3000 })
|
||||||
|
|
||||||
@ -86,6 +89,35 @@ const getNetIPInfo = async (searchIp = '') => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getLocalNetIP = async () => {
|
||||||
|
try {
|
||||||
|
let ipUrls = [
|
||||||
|
'http://whois.pconline.com.cn/ipJson.jsp?json=true',
|
||||||
|
'https://www.ip.cn/api/index?ip=&type=0',
|
||||||
|
'https://freeipapi.com/api/json'
|
||||||
|
]
|
||||||
|
let result = await Promise.allSettled(ipUrls.map(url => axios.get(url)))
|
||||||
|
let [pconline, ipCN, freeipapi] = result
|
||||||
|
if (pconline.status === 'fulfilled') {
|
||||||
|
let ip = pconline.value?.data?.ip
|
||||||
|
if (ip) return ip
|
||||||
|
}
|
||||||
|
if (ipCN.status === 'fulfilled') {
|
||||||
|
let ip = ipCN.value?.data?.ip
|
||||||
|
consola.log('ipCN:', ip)
|
||||||
|
if (ip) return ip
|
||||||
|
}
|
||||||
|
if (freeipapi.status === 'fulfilled') {
|
||||||
|
let ip = pconline.value?.data?.ipAddress
|
||||||
|
if (ip) return ip
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
} catch (error) {
|
||||||
|
console.error('getIpInfo Error: ', error?.message || error)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function isLocalIP(ip) {
|
function isLocalIP(ip) {
|
||||||
// Check if IPv4 or IPv6 address
|
// Check if IPv4 or IPv6 address
|
||||||
const isIPv4 = net.isIPv4(ip)
|
const isIPv4 = net.isIPv4(ip)
|
||||||
@ -231,8 +263,57 @@ const isProd = () => {
|
|||||||
return EXEC_ENV === 'production'
|
return EXEC_ENV === 'production'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let allowedIPs = process.env.ALLOWED_IPS ? process.env.ALLOWED_IPS.split(',') : ''
|
||||||
|
if (allowedIPs) consola.warn('allowedIPs:', allowedIPs)
|
||||||
|
const isAllowedIp = (requestIP) => {
|
||||||
|
if (allowedIPs.length === 0) return true
|
||||||
|
let flag = allowedIPs.some(item => requestIP.includes(item))
|
||||||
|
if (!flag) consola.warn('requestIP:', requestIP, '不在允许的IP列表中')
|
||||||
|
return flag
|
||||||
|
}
|
||||||
|
|
||||||
|
const ping = (ip, timeout = 5000) => {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
setTimeout(() => {
|
||||||
|
resolve({ success: false, msg: 'ping timeout!' })
|
||||||
|
}, timeout)
|
||||||
|
let isWin = os.platform() === 'win32'
|
||||||
|
const command = isWin ? `ping -n 1 ${ ip }` : `ping -c 1 ${ ip }`
|
||||||
|
const options = isWin ? { encoding: 'buffer' } : {}
|
||||||
|
|
||||||
|
exec(command, options, (error, stdout) => {
|
||||||
|
if (error) {
|
||||||
|
resolve({ success: false, msg: 'ping error!' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let output
|
||||||
|
if (isWin) {
|
||||||
|
output = iconv.decode(stdout, 'cp936')
|
||||||
|
} else {
|
||||||
|
output = stdout.toString()
|
||||||
|
}
|
||||||
|
// console.log('output:', output)
|
||||||
|
let match
|
||||||
|
if (isWin) {
|
||||||
|
match = output.match(/平均 = (\d+)ms/)
|
||||||
|
if (!match) {
|
||||||
|
match = output.match(/Average = (\d+)ms/)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
match = output.match(/rtt min\/avg\/max\/mdev = [\d.]+\/([\d.]+)\/[\d.]+\/[\d.]+/)
|
||||||
|
}
|
||||||
|
if (match) {
|
||||||
|
resolve({ success: true, time: parseFloat(match[1]) })
|
||||||
|
} else {
|
||||||
|
resolve({ success: false, msg: 'Could not find time in ping output!' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
getNetIPInfo,
|
getNetIPInfo,
|
||||||
|
getLocalNetIP,
|
||||||
throwError,
|
throwError,
|
||||||
isIP,
|
isIP,
|
||||||
randomStr,
|
randomStr,
|
||||||
@ -240,5 +321,7 @@ module.exports = {
|
|||||||
formatTimestamp,
|
formatTimestamp,
|
||||||
resolvePath,
|
resolvePath,
|
||||||
shellThrottle,
|
shellThrottle,
|
||||||
isProd
|
isProd,
|
||||||
|
isAllowedIp,
|
||||||
|
ping
|
||||||
}
|
}
|
@ -1,7 +1,8 @@
|
|||||||
|
|
||||||
const { AESDecryptSync } = require('./encrypt')
|
const { AESDecryptAsync } = require('./encrypt')
|
||||||
const { readKey } = require('./storage')
|
|
||||||
const jwt = require('jsonwebtoken')
|
const jwt = require('jsonwebtoken')
|
||||||
|
const { KeyDB } = require('./db-class')
|
||||||
|
const keyDB = new KeyDB().getInstance()
|
||||||
|
|
||||||
const enumLoginCode = {
|
const enumLoginCode = {
|
||||||
SUCCESS: 1,
|
SUCCESS: 1,
|
||||||
@ -9,12 +10,12 @@ const enumLoginCode = {
|
|||||||
ERROR_TOKEN: -2
|
ERROR_TOKEN: -2
|
||||||
}
|
}
|
||||||
|
|
||||||
// 校验token与登录IP
|
// 校验token
|
||||||
const verifyAuthSync = async (token, clientIp) => {
|
const verifyAuthSync = async (token, clientIp) => {
|
||||||
consola.info('verifyAuthSync IP:', clientIp)
|
consola.info('verifyAuthSync IP:', clientIp)
|
||||||
try {
|
try {
|
||||||
token = await AESDecryptSync(token) // 先aes解密
|
token = await AESDecryptAsync(token) // 先aes解密
|
||||||
const { commonKey } = await readKey()
|
const { commonKey } = await keyDB.findOneAsync({})
|
||||||
const { exp } = jwt.verify(token, commonKey)
|
const { exp } = jwt.verify(token, commonKey)
|
||||||
if (Date.now() > (exp * 1000)) return { code: -1, msg: 'token expires' } // 过期
|
if (Date.now() > (exp * 1000)) return { code: -1, msg: 'token expires' } // 过期
|
||||||
return { code: enumLoginCode.SUCCESS, msg: 'success' } // 验证成功
|
return { code: enumLoginCode.SUCCESS, msg: 'success' } // 验证成功
|
||||||
|
@ -1,2 +1,4 @@
|
|||||||
|
const consola = require('consola')
|
||||||
|
global.consola = consola
|
||||||
require('dotenv').config()
|
require('dotenv').config()
|
||||||
require('./app/main.js')
|
require('./app/main.js')
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "server",
|
"name": "server",
|
||||||
"version": "1.0.0",
|
"version": "3.0.3",
|
||||||
"description": "easynode-server",
|
"description": "easynode-server",
|
||||||
"bin": "./bin/www",
|
"bin": "./bin/www",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@ -21,13 +21,14 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@koa/cors": "^5.0.0",
|
"@koa/cors": "^5.0.0",
|
||||||
"@seald-io/nedb": "^4.0.4",
|
"@seald-io/nedb": "^4.0.4",
|
||||||
"axios": "^1.7.2",
|
"axios": "^1.7.4",
|
||||||
"consola": "^3.2.3",
|
"consola": "^3.2.3",
|
||||||
"cross-env": "^7.0.3",
|
"cross-env": "^7.0.3",
|
||||||
"crypto-js": "^4.2.0",
|
"crypto-js": "^4.2.0",
|
||||||
"dotenv": "^16.4.5",
|
"dotenv": "^16.4.5",
|
||||||
"fs-extra": "^11.2.0",
|
"fs-extra": "^11.2.0",
|
||||||
"global": "^4.4.0",
|
"global": "^4.4.0",
|
||||||
|
"iconv-lite": "^0.6.3",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
"koa": "^2.15.3",
|
"koa": "^2.15.3",
|
||||||
"koa-body": "^6.0.1",
|
"koa-body": "^6.0.1",
|
||||||
@ -42,9 +43,12 @@
|
|||||||
"node-os-utils": "^1.3.7",
|
"node-os-utils": "^1.3.7",
|
||||||
"node-rsa": "^1.1.1",
|
"node-rsa": "^1.1.1",
|
||||||
"node-schedule": "^2.1.1",
|
"node-schedule": "^2.1.1",
|
||||||
|
"node-telegram-bot-api": "^0.66.0",
|
||||||
"nodemailer": "^6.9.14",
|
"nodemailer": "^6.9.14",
|
||||||
|
"qrcode": "^1.5.4",
|
||||||
"socket.io": "^4.7.5",
|
"socket.io": "^4.7.5",
|
||||||
"socket.io-client": "^4.7.5",
|
"socket.io-client": "^4.7.5",
|
||||||
|
"speakeasy": "^2.0.0",
|
||||||
"ssh2": "^1.15.0",
|
"ssh2": "^1.15.0",
|
||||||
"ssh2-sftp-client": "^10.0.3"
|
"ssh2-sftp-client": "^10.0.3"
|
||||||
},
|
},
|
||||||
|
@ -34,8 +34,10 @@ module.exports = {
|
|||||||
'vue/singleline-html-element-content-newline': 0,
|
'vue/singleline-html-element-content-newline': 0,
|
||||||
|
|
||||||
// js
|
// js
|
||||||
|
'space-before-blocks': ['error', 'always',],
|
||||||
|
'space-in-parens': ['error', 'never',],
|
||||||
|
'keyword-spacing': ['error', { 'before': true, 'after': true },],
|
||||||
'no-async-promise-executor': 0,
|
'no-async-promise-executor': 0,
|
||||||
'comma-dangle': 0,
|
|
||||||
'import/no-extraneous-dependencies': 0,
|
'import/no-extraneous-dependencies': 0,
|
||||||
'no-console': 'off',
|
'no-console': 'off',
|
||||||
'no-debugger': process.env.NODE_ENV === 'production' ? 'error' : 'off',
|
'no-debugger': process.env.NODE_ENV === 'production' ? 'error' : 'off',
|
||||||
|
@ -1,8 +1,11 @@
|
|||||||
<html lang="zh">
|
<html lang="zh">
|
||||||
|
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" href="/favicon.ico" />
|
<link rel="icon" href="/favicon.ico" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=0,user-scalable=yes">
|
<meta name="viewport"
|
||||||
|
content="width=device-width,initial-scale=1.0,minimum-scale=1.0,maximum-scale=1.0,user-scalable=0">
|
||||||
|
<!-- <meta name="viewport" content="width=device-width, initial-scale=0,user-scalable=yes"> -->
|
||||||
<title>EasyNode</title>
|
<title>EasyNode</title>
|
||||||
<script src="//at.alicdn.com/t/c/font_3309550_x7zmcgwaxf.js"></script>
|
<script src="//at.alicdn.com/t/c/font_3309550_x7zmcgwaxf.js"></script>
|
||||||
</head>
|
</head>
|
||||||
@ -10,6 +13,8 @@
|
|||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
<script type="module" src="/src/main.js"></script>
|
<script type="module" src="/src/main.js"></script>
|
||||||
|
<!-- <script src="//cdn.jsdelivr.net/npm/eruda"></script>
|
||||||
|
<script>eruda.init();</script> -->
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "web",
|
"name": "web",
|
||||||
"version": "2.2.1",
|
"version": "3.0.3",
|
||||||
"description": "easynode-web",
|
"description": "easynode-web",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@ -29,10 +29,11 @@
|
|||||||
"@xterm/addon-search": "^0.15.0",
|
"@xterm/addon-search": "^0.15.0",
|
||||||
"@xterm/addon-web-links": "^0.11.0",
|
"@xterm/addon-web-links": "^0.11.0",
|
||||||
"@xterm/xterm": "^5.5.0",
|
"@xterm/xterm": "^5.5.0",
|
||||||
"axios": "^1.7.2",
|
"axios": "^1.7.4",
|
||||||
"codemirror": "^6.0.1",
|
"codemirror": "^6.0.1",
|
||||||
"crypto-js": "^4.2.0",
|
"crypto-js": "^4.2.0",
|
||||||
"csv-parse": "^5.5.6",
|
"csv-parse": "^5.5.6",
|
||||||
|
"dayjs": "^1.11.13",
|
||||||
"element-plus": "^2.7.6",
|
"element-plus": "^2.7.6",
|
||||||
"jsencrypt": "^3.3.2",
|
"jsencrypt": "^3.3.2",
|
||||||
"pinia": "^2.1.7",
|
"pinia": "^2.1.7",
|
||||||
@ -45,12 +46,13 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@vitejs/plugin-vue": "^5.0.5",
|
"@vitejs/plugin-vue": "^5.0.5",
|
||||||
"@vitejs/plugin-vue-jsx": "^4.0.0",
|
"@vitejs/plugin-vue-jsx": "^4.0.0",
|
||||||
|
"code-inspector-plugin": "^0.17.2",
|
||||||
"eslint": "^8.56.0",
|
"eslint": "^8.56.0",
|
||||||
"eslint-plugin-vue": "^9.27.0",
|
"eslint-plugin-vue": "^9.27.0",
|
||||||
"sass": "^1.77.7",
|
"sass": "^1.77.7",
|
||||||
"unplugin-auto-import": "^0.17.6",
|
"unplugin-auto-import": "^0.17.6",
|
||||||
"unplugin-vue-components": "^0.27.2",
|
"unplugin-vue-components": "^0.27.2",
|
||||||
"vite": "^5.3.3",
|
"vite": "^5.3.6",
|
||||||
"vite-plugin-compression": "^0.5.1",
|
"vite-plugin-compression": "^0.5.1",
|
||||||
"vue-eslint-parser": "^9.4.3"
|
"vue-eslint-parser": "^9.4.3"
|
||||||
}
|
}
|
||||||
|
@ -19,11 +19,17 @@ export default {
|
|||||||
removeSSH(id) {
|
removeSSH(id) {
|
||||||
return axios({ url: `/remove-ssh/${ id }`, method: 'delete' })
|
return axios({ url: `/remove-ssh/${ id }`, method: 'delete' })
|
||||||
},
|
},
|
||||||
// existSSH(host) {
|
getPlusInfo() {
|
||||||
// return axios({ url: '/exist-ssh', method: 'post', data: { host } })
|
return axios({ url: '/plus-info', method: 'get' })
|
||||||
// },
|
},
|
||||||
getCommand(host) {
|
getPlusDiscount() {
|
||||||
return axios({ url: '/command', method: 'get', params: { host } })
|
return axios({ url: '/plus-discount', method: 'get' })
|
||||||
|
},
|
||||||
|
getCommand(hostId) {
|
||||||
|
return axios({ url: '/command', method: 'get', params: { hostId } })
|
||||||
|
},
|
||||||
|
decryptPrivateKey(data) {
|
||||||
|
return axios({ url: '/decrypt-private-key', method: 'post', data })
|
||||||
},
|
},
|
||||||
getHostList() {
|
getHostList() {
|
||||||
return axios({ url: '/host-list', method: 'get' })
|
return axios({ url: '/host-list', method: 'get' })
|
||||||
@ -34,6 +40,9 @@ export default {
|
|||||||
updateHost(data) {
|
updateHost(data) {
|
||||||
return axios({ url: '/host-save', method: 'put', data })
|
return axios({ url: '/host-save', method: 'put', data })
|
||||||
},
|
},
|
||||||
|
batchUpdateHost(data) {
|
||||||
|
return axios({ url: '/batch-update-host', method: 'put', data })
|
||||||
|
},
|
||||||
removeHost(data) {
|
removeHost(data) {
|
||||||
return axios({ url: '/host-remove', method: 'post', data })
|
return axios({ url: '/host-remove', method: 'post', data })
|
||||||
},
|
},
|
||||||
@ -47,29 +56,23 @@ export default {
|
|||||||
return axios({ url: '/login', method: 'post', data })
|
return axios({ url: '/login', method: 'post', data })
|
||||||
},
|
},
|
||||||
getLoginRecord() {
|
getLoginRecord() {
|
||||||
return axios({ url: '/get-login-record', method: 'get' })
|
return axios({ url: '/log', method: 'get' })
|
||||||
},
|
},
|
||||||
updatePwd(data) {
|
updatePwd(data) {
|
||||||
return axios({ url: '/pwd', method: 'put', data })
|
return axios({ url: '/pwd', method: 'put', data })
|
||||||
},
|
},
|
||||||
// updateHostSort(data) {
|
getMFA2QR() {
|
||||||
// return axios({ url: '/host-sort', method: 'put', data })
|
return axios({ url: '/mfa2-code', method: 'post' })
|
||||||
// },
|
},
|
||||||
// getUserEmailList() {
|
getMFA2Status() {
|
||||||
// return axios({ url: '/user-email', method: 'get' })
|
return axios({ url: '/mfa2-status', method: 'get' })
|
||||||
// },
|
},
|
||||||
// getSupportEmailList() {
|
enableMFA2(data) {
|
||||||
// return axios({ url: '/support-email', method: 'get' })
|
return axios({ url: '/mfa2-enable', method: 'post', data })
|
||||||
// },
|
},
|
||||||
// updateUserEmailList(data) {
|
disableMFA2() {
|
||||||
// return axios({ url: '/user-email', method: 'post', data })
|
return axios({ url: '/mfa2-disable', method: 'post' })
|
||||||
// },
|
},
|
||||||
// deleteUserEmail(email) {
|
|
||||||
// return axios({ url: `/user-email/${ email }`, method: 'delete' })
|
|
||||||
// },
|
|
||||||
// pushTestEmail(data) {
|
|
||||||
// return axios({ url: '/push-email', method: 'post', data })
|
|
||||||
// },
|
|
||||||
getNotifyConfig() {
|
getNotifyConfig() {
|
||||||
return axios({ url: '/notify-config', method: 'get' })
|
return axios({ url: '/notify-config', method: 'get' })
|
||||||
},
|
},
|
||||||
@ -94,8 +97,11 @@ export default {
|
|||||||
deleteGroup(id) {
|
deleteGroup(id) {
|
||||||
return axios({ url: `/group/${ id }`, method: 'delete' })
|
return axios({ url: `/group/${ id }`, method: 'delete' })
|
||||||
},
|
},
|
||||||
getScriptList() {
|
getScriptList(params = {}) {
|
||||||
return axios({ url: '/script', method: 'get' })
|
return axios({ url: '/script', method: 'get', params })
|
||||||
|
},
|
||||||
|
importScript(data) {
|
||||||
|
return axios({ url: '/import-script', method: 'post', data })
|
||||||
},
|
},
|
||||||
getLocalScriptList() {
|
getLocalScriptList() {
|
||||||
return axios({ url: '/local-script', method: 'get' })
|
return axios({ url: '/local-script', method: 'get' })
|
||||||
@ -109,6 +115,9 @@ export default {
|
|||||||
deleteScript(id) {
|
deleteScript(id) {
|
||||||
return axios({ url: `/script/${ id }`, method: 'delete' })
|
return axios({ url: `/script/${ id }`, method: 'delete' })
|
||||||
},
|
},
|
||||||
|
batchRemoveScript(data) {
|
||||||
|
return axios({ url: '/batch-remove-script', method: 'post', data })
|
||||||
|
},
|
||||||
getOnekeyRecord() {
|
getOnekeyRecord() {
|
||||||
return axios({ url: '/onekey', method: 'get' })
|
return axios({ url: '/onekey', method: 'get' })
|
||||||
},
|
},
|
||||||
@ -117,5 +126,11 @@ export default {
|
|||||||
},
|
},
|
||||||
getEasynodeVersion() {
|
getEasynodeVersion() {
|
||||||
return axios({ url: '/version', method: 'get' })
|
return axios({ url: '/version', method: 'get' })
|
||||||
|
},
|
||||||
|
getPlusConf() {
|
||||||
|
return axios({ url: '/plus-conf', method: 'get' })
|
||||||
|
},
|
||||||
|
updatePlusKey(data) {
|
||||||
|
return axios({ url: '/plus-conf', method: 'post', data })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -5,8 +5,8 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, getCurrentInstance } from 'vue'
|
|
||||||
import zhCn from 'element-plus/es/locale/lang/zh-cn'
|
import zhCn from 'element-plus/es/locale/lang/zh-cn'
|
||||||
|
import { ref, getCurrentInstance } from 'vue'
|
||||||
const { proxy: { $store } } = getCurrentInstance()
|
const { proxy: { $store } } = getCurrentInstance()
|
||||||
|
|
||||||
const locale = ref(zhCn)
|
const locale = ref(zhCn)
|
||||||
|
BIN
web/src/assets/discount.png
Normal file
After Width: | Height: | Size: 4.3 KiB |
BIN
web/src/assets/plus.png
Normal file
After Width: | Height: | Size: 9.9 KiB |
@ -53,6 +53,11 @@ html.dark {
|
|||||||
background-color: #454242 !important;
|
background-color: #454242 !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
section {
|
||||||
|
.left {
|
||||||
|
border: none !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.info_box {
|
.info_box {
|
||||||
border-right: 1px solid #454242;
|
border-right: 1px solid #454242;
|
||||||
@ -64,94 +69,20 @@ html.dark {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// scroll-bar
|
// scroll-bar
|
||||||
::-webkit-scrollbar {
|
|
||||||
background-color: var(--el-scrollbar-bg-color) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
::-webkit-scrollbar-thumb {
|
::-webkit-scrollbar-thumb {
|
||||||
background-color: var(--el-border-color-darker) !important;
|
background-color: #5c5c5c;
|
||||||
|
border-radius: 10px;
|
||||||
|
transition: all .2s ease-in-out;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
cursor: pointer;
|
||||||
|
background-color: #6d6d6d;
|
||||||
}
|
}
|
||||||
|
|
||||||
.layout-sidebar-container {
|
.el-menu {
|
||||||
background-color: var(--bg-color) !important;
|
border-right: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-main-container {
|
|
||||||
background-color: var(--main-bg-color) !important;
|
|
||||||
|
|
||||||
.card {
|
|
||||||
background-color: var(--bg-color) !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.layout-footer-container {
|
|
||||||
color: var(--el-text-color-regular) !important;
|
|
||||||
background-color: var(--main-bg-color) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fold-unfold {
|
|
||||||
color: #fff !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
// .el-menu,
|
|
||||||
// .el-sub-menu,
|
|
||||||
// .el-menu-item,
|
|
||||||
// .el-sub-menu__title {
|
|
||||||
// background-color: var(--bg-color) !important;
|
|
||||||
|
|
||||||
// &:not(.is-active) {
|
|
||||||
// color: #bdbdc0 !important;
|
|
||||||
// }
|
|
||||||
|
|
||||||
// &.is-active {
|
|
||||||
// color: #fff !important;
|
|
||||||
// background-color: #000 !important;
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
|
|
||||||
.el-menu-item:not(.is-active):hover {
|
.el-menu-item:not(.is-active):hover {
|
||||||
color: var(--el-menu-active-color);
|
color: var(--el-menu-active-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-bar-container {
|
|
||||||
background-color: var(--bg-color) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.table-header {
|
|
||||||
.el-icon {
|
|
||||||
color: #fff;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.tabs-bar-container {
|
|
||||||
background-color: var(--bg-color) !important;
|
|
||||||
border-top: 1px solid var(--el-border-color-light) !important;
|
|
||||||
|
|
||||||
.tabs-action {
|
|
||||||
.el-icon {
|
|
||||||
color: #fff !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.fold-unfold {
|
|
||||||
color: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.el-tabs__item.is-active {
|
|
||||||
color: var(--el-color-primary);
|
|
||||||
background-color: var(--main-bg-color) !important;
|
|
||||||
|
|
||||||
.el-icon {
|
|
||||||
color: var(--el-color-primary) !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.el-tabs__item:not(.is_active):hover {
|
|
||||||
background-color: var(--main-bg-color) !important;
|
|
||||||
|
|
||||||
.el-icon {
|
|
||||||
color: var(--el-color-primary) !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
@ -1,28 +1,41 @@
|
|||||||
// 滚动条
|
html {
|
||||||
html, body, div, ul, section, textarea {
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body,
|
||||||
|
div,
|
||||||
|
ul,
|
||||||
|
section,
|
||||||
|
textarea {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
|
|
||||||
// 滚动条整体部分
|
// 滚动条整体部分
|
||||||
&::-webkit-scrollbar {
|
&::-webkit-scrollbar {
|
||||||
height: 5px;
|
width: 6px;
|
||||||
width: 5px;
|
height: 6px;
|
||||||
background-color: #ffffff;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 底层轨道
|
// 底层轨道
|
||||||
&::-webkit-scrollbar-track {
|
// &::-webkit-scrollbar-track {
|
||||||
background-color: #ffffff;
|
// background-color: #ffffff;
|
||||||
border-radius: 3px;
|
// border-radius: 3px;
|
||||||
}
|
// }
|
||||||
|
|
||||||
// 滚动滑块
|
// 滚动滑块
|
||||||
&::-webkit-scrollbar-thumb {
|
&::-webkit-scrollbar-thumb {
|
||||||
border-radius: 3px;
|
background-color: #0003;
|
||||||
// background-color: #1989fa;
|
border-radius: 10px;
|
||||||
background-image: -webkit-gradient(linear, 40% 0%, 75% 84%, from(#a18cd1), to(#fbc2eb), color-stop(.6, #54DE5D));
|
transition: all .2s ease-in-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
&::-webkit-scrollbar-thumb:hover {
|
&::-webkit-scrollbar-thumb:hover {
|
||||||
background-color: #067ef7;
|
cursor: pointer;
|
||||||
|
background-color: #0000004d;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::-webkit-scrollbar-track {
|
||||||
|
border-radius: 10px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
87
web/src/assets/scss/mobile.scss
Normal file
@ -0,0 +1,87 @@
|
|||||||
|
.mobile_menu_btn {
|
||||||
|
margin-right: auto;
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile_menu_drawer {
|
||||||
|
width: auto !important;
|
||||||
|
.mobile_logo_wrap {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin-top: 15px;
|
||||||
|
img {
|
||||||
|
width: 30px;
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
font-size: 14px;
|
||||||
|
margin-left: 3px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.el-drawer__body {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@media screen and (min-width: 969px) {
|
||||||
|
[class^="mobile_"] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@media screen and (max-width: 968px) {
|
||||||
|
.login_box {
|
||||||
|
width: 86vw!important;
|
||||||
|
}
|
||||||
|
.view_container {
|
||||||
|
.aside_container {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.top_bar_container {
|
||||||
|
width: 100%;
|
||||||
|
.bar_wrap {
|
||||||
|
h2 {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.terminal_container {
|
||||||
|
.terminal_link_tips {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.terminal_wrap {
|
||||||
|
.terminal_and_sftp_wrap {
|
||||||
|
flex: auto;
|
||||||
|
.sftp_tab_container {
|
||||||
|
section {
|
||||||
|
.left {
|
||||||
|
min-width: 150px;
|
||||||
|
max-width: 150px;
|
||||||
|
}
|
||||||
|
.right {
|
||||||
|
.filter_input {
|
||||||
|
width: auto;
|
||||||
|
min-width: auto;
|
||||||
|
margin: 0 5px;
|
||||||
|
}
|
||||||
|
.path {
|
||||||
|
display: inline-block;
|
||||||
|
padding-right: 15px;
|
||||||
|
}
|
||||||
|
.path_input {
|
||||||
|
width: auto;
|
||||||
|
min-width: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.el-dialog {
|
||||||
|
--el-dialog-width: 94%!important;
|
||||||
|
}
|
||||||
|
}
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
Before Width: | Height: | Size: 164 KiB After Width: | Height: | Size: 164 KiB |
Before Width: | Height: | Size: 501 KiB After Width: | Height: | Size: 501 KiB |
Before Width: | Height: | Size: 1.3 MiB After Width: | Height: | Size: 1.3 MiB |
Before Width: | Height: | Size: 39 KiB After Width: | Height: | Size: 39 KiB |
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 11 KiB |
Before Width: | Height: | Size: 38 KiB After Width: | Height: | Size: 38 KiB |
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 58 KiB |
Before Width: | Height: | Size: 177 KiB After Width: | Height: | Size: 177 KiB |
@ -2,107 +2,39 @@
|
|||||||
<div class="aside_container">
|
<div class="aside_container">
|
||||||
<div class="logo_wrap">
|
<div class="logo_wrap">
|
||||||
<img src="@/assets/logo.png" alt="logo">
|
<img src="@/assets/logo.png" alt="logo">
|
||||||
<h1>EasyNode</h1>
|
<Transition name="el-fade-in-linear">
|
||||||
|
<h1 v-show="!menuCollapse">EasyNode</h1>
|
||||||
|
</Transition>
|
||||||
|
</div>
|
||||||
|
<MenuList />
|
||||||
|
<div class="collapse" @click="handleCollapse">
|
||||||
|
<el-icon v-if="menuCollapse"><Expand /></el-icon>
|
||||||
|
<el-icon v-else><Fold /></el-icon>
|
||||||
</div>
|
</div>
|
||||||
<el-menu
|
|
||||||
:default-active="defaultActiveMenu"
|
|
||||||
class="menu"
|
|
||||||
@select="handleSelect"
|
|
||||||
>
|
|
||||||
<el-menu-item v-for="(item, index) in menuList" :key="index" :index="item.index">
|
|
||||||
<template #title>
|
|
||||||
<el-icon>
|
|
||||||
<component :is="item.icon" />
|
|
||||||
</el-icon>
|
|
||||||
<span>{{ item.name }}</span>
|
|
||||||
</template>
|
|
||||||
</el-menu-item>
|
|
||||||
</el-menu>
|
|
||||||
<!-- <div class="logout_wrap">
|
|
||||||
<el-button type="info" link @click="handleLogout">退出登录</el-button>
|
|
||||||
</div> -->
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { reactive, markRaw, getCurrentInstance, computed, watchEffect } from 'vue'
|
import { getCurrentInstance, computed } from 'vue'
|
||||||
import {
|
import {
|
||||||
Menu as IconMenu,
|
Expand,
|
||||||
Key,
|
Fold
|
||||||
Setting,
|
|
||||||
ScaleToOriginal,
|
|
||||||
ArrowRight,
|
|
||||||
Pointer,
|
|
||||||
FolderOpened
|
|
||||||
} from '@element-plus/icons-vue'
|
} from '@element-plus/icons-vue'
|
||||||
import { useRoute } from 'vue-router'
|
import MenuList from './menuList.vue'
|
||||||
|
|
||||||
const route = useRoute()
|
const { proxy: { $store } } = getCurrentInstance()
|
||||||
|
|
||||||
const { proxy: { $router, $store } } = getCurrentInstance()
|
let menuCollapse = computed(() => $store.menuCollapse)
|
||||||
|
|
||||||
let menuList = reactive([
|
const handleCollapse = () => {
|
||||||
{
|
$store.setMenuCollapse(!menuCollapse.value)
|
||||||
name: '实例配置',
|
|
||||||
icon: markRaw(IconMenu),
|
|
||||||
index: '/server'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: '连接终端',
|
|
||||||
icon: markRaw(ScaleToOriginal),
|
|
||||||
index: '/terminal'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: '凭据管理',
|
|
||||||
icon: markRaw(Key),
|
|
||||||
index: '/credentials'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: '分组管理',
|
|
||||||
icon: markRaw(FolderOpened),
|
|
||||||
index: '/group'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: '脚本库',
|
|
||||||
icon: markRaw(ArrowRight),
|
|
||||||
index: '/scripts'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: '批量指令',
|
|
||||||
icon: markRaw(Pointer),
|
|
||||||
index: '/onekey'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: '系统设置',
|
|
||||||
icon: markRaw(Setting),
|
|
||||||
index: '/setting'
|
|
||||||
},
|
|
||||||
])
|
|
||||||
|
|
||||||
// eslint-disable-next-line no-useless-escape
|
|
||||||
const regex = /^\/([^\/]+)/
|
|
||||||
let defaultActiveMenu = computed(() => {
|
|
||||||
const match = route.path.match(regex)
|
|
||||||
return match[0]
|
|
||||||
})
|
|
||||||
|
|
||||||
watchEffect(() => {
|
|
||||||
let idx = route.path.match(regex)[0]
|
|
||||||
let targetRoute = menuList.find(item => item.index === idx)
|
|
||||||
$store.setTitle(targetRoute?.name || '')
|
|
||||||
})
|
|
||||||
|
|
||||||
const handleSelect = (path) => {
|
|
||||||
// console.log(path)
|
|
||||||
$router.push(path)
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
.aside_container {
|
.aside_container {
|
||||||
background-color: #fff;
|
background-color: #fff;
|
||||||
border-right: 1px solid var(--el-menu-border-color);
|
// width: 180px;
|
||||||
width: 180px;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
:deep(.el-menu) {
|
:deep(.el-menu) {
|
||||||
@ -110,20 +42,29 @@ const handleSelect = (path) => {
|
|||||||
}
|
}
|
||||||
.logo_wrap {
|
.logo_wrap {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 15px 0;
|
padding: 15px 0 15px 20px;
|
||||||
|
position: relative;
|
||||||
img {
|
img {
|
||||||
height: 30px;
|
height: 30px;
|
||||||
width: 30px;
|
width: 30px;
|
||||||
}
|
}
|
||||||
h1 {
|
h1 {
|
||||||
color: #1890ff;
|
position: absolute;
|
||||||
font-size: 16px;
|
left: 52px;
|
||||||
margin: 0 5px;
|
font-size: 14px;
|
||||||
|
color: var(--el-menu-active-color);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-size: 16px;
|
}
|
||||||
vertical-align: middle;
|
}
|
||||||
|
.collapse {
|
||||||
|
margin-top: auto;
|
||||||
|
margin-left: auto;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
margin-right: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
&:hover {
|
||||||
|
color: #1890ff;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.logout_wrap {
|
.logout_wrap {
|
||||||
|
44
web/src/components/common/PlusSupportTip.vue
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
<template>
|
||||||
|
<el-tooltip
|
||||||
|
:disabled="isPlusActive"
|
||||||
|
placement="top"
|
||||||
|
>
|
||||||
|
<template #content>
|
||||||
|
<div class="plus_support_tip">
|
||||||
|
此功能需要激活Plus后使用,
|
||||||
|
<el-button
|
||||||
|
size="small"
|
||||||
|
type="primary"
|
||||||
|
link
|
||||||
|
@click="gotoPlusPage"
|
||||||
|
>
|
||||||
|
去激活
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<slot />
|
||||||
|
</el-tooltip>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed, getCurrentInstance } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
|
||||||
|
const { proxy: { $store } } = getCurrentInstance()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const isPlusActive = computed(() => $store.isPlusActive)
|
||||||
|
|
||||||
|
const gotoPlusPage = () => {
|
||||||
|
router.push('/setting?tabKey=plus')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
.plus_support_tip {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
</style>
|
||||||
|
|
113
web/src/components/float-menu/index.vue
Normal file
@ -0,0 +1,113 @@
|
|||||||
|
<template>
|
||||||
|
<div class="mobile_float_menu_container">
|
||||||
|
<ul class="keyboard">
|
||||||
|
<li
|
||||||
|
v-for="item in keys"
|
||||||
|
:key="item.key"
|
||||||
|
:class="['key', { long_press: item.type === LONG_PRESS }]"
|
||||||
|
@click="handleClickKey(item)"
|
||||||
|
>
|
||||||
|
<div :class="{ active: (item.key === 'Ctrl' && longPressCtrl) || (item.key === 'Alt' && longPressAlt) }">
|
||||||
|
{{ item.key }}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
<li class="key placeholder" />
|
||||||
|
<li class="key placeholder" />
|
||||||
|
<li class="key placeholder" />
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { virtualKeyType } from '@/utils/enum'
|
||||||
|
|
||||||
|
defineProps({
|
||||||
|
longPressCtrl: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
longPressAlt: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['update:show', 'click-key',])
|
||||||
|
|
||||||
|
const { LONG_PRESS, SINGLE_PRESS } = virtualKeyType
|
||||||
|
const keys = ref([
|
||||||
|
{ key: 'Ctrl', ascii: null, type: LONG_PRESS, ansi: '' },
|
||||||
|
{ key: 'Esc', ascii: 27, type: SINGLE_PRESS, ansi: '\x1B' },
|
||||||
|
{ key: 'Tab', ascii: 9, type: SINGLE_PRESS, ansi: '\x09' },
|
||||||
|
{ key: 'Backspace', ascii: 8, type: SINGLE_PRESS, ansi: '\x7F' },
|
||||||
|
// { key: 'Delete', ascii: 46, type: SINGLE_PRESS, ansi: '\x1B[3~' },
|
||||||
|
{ key: '←', ascii: 37, type: SINGLE_PRESS, ansi: '\x1B[D' },
|
||||||
|
{ key: '↑', ascii: 38, type: SINGLE_PRESS, ansi: '\x1B[A' },
|
||||||
|
{ key: '↓', ascii: 40, type: SINGLE_PRESS, ansi: '\x1B[B' },
|
||||||
|
{ key: '→', ascii: 39, type: SINGLE_PRESS, ansi: '\x1B[C' },
|
||||||
|
{ key: 'Home', ascii: 36, type: SINGLE_PRESS, ansi: '\x1B[H' },
|
||||||
|
{ key: 'End', ascii: 35, type: SINGLE_PRESS, ansi: '\x1B[F' },
|
||||||
|
{ key: 'PageUp', ascii: 33, type: SINGLE_PRESS, ansi: '\x1B[5~' },
|
||||||
|
{ key: 'PageDown', ascii: 34, type: SINGLE_PRESS, ansi: '\x1B[6~' },
|
||||||
|
{ key: 'Alt', ascii: null, type: LONG_PRESS, ansi: '' },
|
||||||
|
{ key: 'F1', ascii: 112, type: SINGLE_PRESS, ansi: '\x1BOP' },
|
||||||
|
{ key: 'F2', ascii: 113, type: SINGLE_PRESS, ansi: '\x1BOQ' },
|
||||||
|
{ key: 'F3', ascii: 114, type: SINGLE_PRESS, ansi: '\x1BOR' },
|
||||||
|
{ key: 'F4', ascii: 115, type: SINGLE_PRESS, ansi: '\x1BOS' },
|
||||||
|
{ key: 'F5', ascii: 116, type: SINGLE_PRESS, ansi: '\x1B[15~' },
|
||||||
|
{ key: 'F6', ascii: 117, type: SINGLE_PRESS, ansi: '\x1B[17~' },
|
||||||
|
{ key: 'F7', ascii: 118, type: SINGLE_PRESS, ansi: '\x1B[18~' },
|
||||||
|
{ key: 'F8', ascii: 119, type: SINGLE_PRESS, ansi: '\x1B[19~' },
|
||||||
|
{ key: 'F9', ascii: 120, type: SINGLE_PRESS, ansi: '\x1B[20~' },
|
||||||
|
{ key: 'F10', ascii: 121, type: SINGLE_PRESS, ansi: '\x1B[21~' },
|
||||||
|
{ key: 'F11', ascii: 122, type: SINGLE_PRESS, ansi: '\x1B[23~' },
|
||||||
|
{ key: 'F12', ascii: 123, type: SINGLE_PRESS, ansi: '\x1B[24~' },
|
||||||
|
])
|
||||||
|
|
||||||
|
const handleClickKey = (key) => {
|
||||||
|
emit('click-key', key)
|
||||||
|
}
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.mobile_float_menu_container {
|
||||||
|
height: 55px;
|
||||||
|
padding-top: 5px;
|
||||||
|
overflow-y: auto;
|
||||||
|
&::-webkit-scrollbar {
|
||||||
|
width: 0px;
|
||||||
|
height: 0px;
|
||||||
|
}
|
||||||
|
.keyboard {
|
||||||
|
list-style: none;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: space-around;
|
||||||
|
padding: 0;
|
||||||
|
.key.placeholder {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
.key {
|
||||||
|
width: 25%;
|
||||||
|
height: 25px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 10px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 0 8px;
|
||||||
|
// margin-bottom: 6px;
|
||||||
|
// border: 1px solid #cccccc5b;
|
||||||
|
// border-radius: 2px;
|
||||||
|
}
|
||||||
|
.long_press {
|
||||||
|
.active {
|
||||||
|
// color: red;
|
||||||
|
font-weight: bolder;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}</style>
|
@ -23,7 +23,7 @@
|
|||||||
<template #footer>
|
<template #footer>
|
||||||
<footer>
|
<footer>
|
||||||
<div class="btns">
|
<div class="btns">
|
||||||
<el-button type="primary" @click="handleSave">执行</el-button>
|
<el-button type="primary" @click="handleSave">发送到终端</el-button>
|
||||||
<el-button type="info" @click="visible = false">关闭</el-button>
|
<el-button type="info" @click="visible = false">关闭</el-button>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
96
web/src/components/menuList.vue
Normal file
@ -0,0 +1,96 @@
|
|||||||
|
<template>
|
||||||
|
<el-menu
|
||||||
|
:default-active="defaultActiveMenu"
|
||||||
|
:collapse="menuCollapse"
|
||||||
|
class="menu"
|
||||||
|
:collapse-transition="true"
|
||||||
|
@select="handleSelect"
|
||||||
|
>
|
||||||
|
<el-menu-item v-for="(item, index) in list" :key="index" :index="item.index">
|
||||||
|
<el-icon>
|
||||||
|
<component :is="item.icon" />
|
||||||
|
</el-icon>
|
||||||
|
<template #title>
|
||||||
|
<span>{{ item.name }}</span>
|
||||||
|
</template>
|
||||||
|
</el-menu-item>
|
||||||
|
</el-menu>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { reactive, markRaw, getCurrentInstance, computed, watchEffect } from 'vue'
|
||||||
|
import { useRoute } from 'vue-router'
|
||||||
|
import {
|
||||||
|
Menu as IconMenu,
|
||||||
|
Key,
|
||||||
|
Setting,
|
||||||
|
ScaleToOriginal,
|
||||||
|
ArrowRight,
|
||||||
|
Pointer,
|
||||||
|
FolderOpened
|
||||||
|
} from '@element-plus/icons-vue'
|
||||||
|
const { proxy: { $router, $store } } = getCurrentInstance()
|
||||||
|
|
||||||
|
const emit = defineEmits(['select',])
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
|
||||||
|
const list = reactive([
|
||||||
|
{
|
||||||
|
name: '实例配置',
|
||||||
|
icon: markRaw(IconMenu),
|
||||||
|
index: '/server'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '连接终端',
|
||||||
|
icon: markRaw(ScaleToOriginal),
|
||||||
|
index: '/terminal'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '凭据管理',
|
||||||
|
icon: markRaw(Key),
|
||||||
|
index: '/credentials'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '分组管理',
|
||||||
|
icon: markRaw(FolderOpened),
|
||||||
|
index: '/group'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '脚本库',
|
||||||
|
icon: markRaw(ArrowRight),
|
||||||
|
index: '/scripts'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '批量指令',
|
||||||
|
icon: markRaw(Pointer),
|
||||||
|
index: '/onekey'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '系统设置',
|
||||||
|
icon: markRaw(Setting),
|
||||||
|
index: '/setting'
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
|
const menuCollapse = computed(() => $store.menuCollapse)
|
||||||
|
|
||||||
|
// eslint-disable-next-line no-useless-escape
|
||||||
|
const regex = /^\/([^\/]+)/
|
||||||
|
const defaultActiveMenu = computed(() => {
|
||||||
|
const match = route.path.match(regex)
|
||||||
|
return match[0]
|
||||||
|
})
|
||||||
|
|
||||||
|
watchEffect(() => {
|
||||||
|
const idx = route.path.match(regex)[0]
|
||||||
|
const targetRoute = list.find(item => item.index === idx)
|
||||||
|
$store.setTitle(targetRoute?.name || '')
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleSelect = (path) => {
|
||||||
|
// console.log(path)
|
||||||
|
$router.push(path)
|
||||||
|
emit('select', path)
|
||||||
|
}
|
||||||
|
</script>
|
206
web/src/components/plus-table.vue
Normal file
@ -0,0 +1,206 @@
|
|||||||
|
<template>
|
||||||
|
<div class="comparison-container">
|
||||||
|
<!-- 基础版卡片 -->
|
||||||
|
<el-card class="comparison-card basic-card">
|
||||||
|
<template #header>
|
||||||
|
<div class="card-header">
|
||||||
|
<span class="title">基础功能(免费)</span>
|
||||||
|
<el-tag size="small">Basic</el-tag>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div class="feature-list">
|
||||||
|
<div v-for="(feature, index) in basicFeatures" :key="index" class="feature-item">
|
||||||
|
<el-icon>
|
||||||
|
<Check />
|
||||||
|
</el-icon>
|
||||||
|
<span>{{ feature }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<!-- Plus版卡片 -->
|
||||||
|
<el-card class="comparison-card plus-card">
|
||||||
|
<template #header>
|
||||||
|
<div class="card-header">
|
||||||
|
<div>
|
||||||
|
<span class="title">Plus专属功能</span>
|
||||||
|
<span class="link" style="margin-right: 15px;" @click="() => plusTipsShow = true">Plus说明</span>
|
||||||
|
</div>
|
||||||
|
<el-tag type="success" size="small">PLUS</el-tag>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div class="feature-list">
|
||||||
|
<div v-for="(feature, index) in plusFeatures" :key="index" class="feature-item plus">
|
||||||
|
<el-icon color="#67c23a">
|
||||||
|
<Check />
|
||||||
|
</el-icon>
|
||||||
|
<span>{{ feature }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
<el-dialog
|
||||||
|
v-model="plusTipsShow"
|
||||||
|
title="Plus说明"
|
||||||
|
top="20vh"
|
||||||
|
width="30%"
|
||||||
|
:append-to-body="false"
|
||||||
|
:close-on-click-modal="false"
|
||||||
|
>
|
||||||
|
<div class="about_content">
|
||||||
|
<p style="line-height: 2;letter-spacing: 1px;">
|
||||||
|
<!-- <strong style="color: #F56C6C;font-weight: 600;">PLUS说明:</strong><br> -->
|
||||||
|
<strong>EasyNode</strong>最初是一个简单的Web终端工具,随着用户群的不断扩大,功能需求也日益增长,为了实现大家的功能需求,我投入了大量的业余时间进行开发和维护。
|
||||||
|
一直在为爱发电,渐渐的也没了开发的动力。
|
||||||
|
<br>
|
||||||
|
为了项目的可持续发展,<strong>后续</strong>版本开始推出<strong>PLUS</strong>版本,后续特性功能开发也会优先在<strong>PLUS</strong>版本中实现,但即使不升级到<strong>PLUS</strong>,也不会影响到<strong>EasyNode</strong>的基础功能使用【注意:
|
||||||
|
暂不支持纯内网用户激活PLUS功能】。
|
||||||
|
<br>
|
||||||
|
<span style="text-decoration: underline;">
|
||||||
|
为了感谢前期赞赏过的用户, 在<strong>PLUS</strong>功能正式发布前,所有进行过赞赏的用户,无论金额大小,均可联系作者TG: <a
|
||||||
|
class="link"
|
||||||
|
href="https://t.me/chaoszhu"
|
||||||
|
target="_blank"
|
||||||
|
>@chaoszhu</a> 凭打赏记录免费获取永久<strong>PLUS</strong>授权码。
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
<div class="about_footer">
|
||||||
|
<el-button type="info" @click="plusTipsShow = false">关闭</el-button>
|
||||||
|
<el-button type="primary" @click="handlePlusSupport">购买Plus Key</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</el-dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { Check } from '@element-plus/icons-vue'
|
||||||
|
import { handlePlusSupport } from '@/utils'
|
||||||
|
|
||||||
|
const plusTipsShow = ref(false)
|
||||||
|
|
||||||
|
// 基础版功能列表
|
||||||
|
const basicFeatures = [
|
||||||
|
'服务器管理',
|
||||||
|
'服务器导入导出',
|
||||||
|
'服务器分组',
|
||||||
|
'凭据管理',
|
||||||
|
'脚本库',
|
||||||
|
'批量连接',
|
||||||
|
'批量指令',
|
||||||
|
'通知方式(有限制)',
|
||||||
|
]
|
||||||
|
|
||||||
|
// Plus版专属功能列表
|
||||||
|
const plusFeatures = [
|
||||||
|
'包含基础版全部功能',
|
||||||
|
'服务器跳板机功能,支持任意数量服务器的连续跳板',
|
||||||
|
'批量修改实例配置(优化版)',
|
||||||
|
'脚本库批量导出导入',
|
||||||
|
'凭据管理支持解密带密码保护的密钥',
|
||||||
|
'通知方式无限制',
|
||||||
|
'本地socket断开自动重连',
|
||||||
|
'功能需求更高开发优先级',
|
||||||
|
]
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.comparison-container {
|
||||||
|
display: flex;
|
||||||
|
gap: 20px;
|
||||||
|
padding-bottom: 20px;
|
||||||
|
max-width: 1200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comparison-card {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 300px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: bold;
|
||||||
|
margin-right: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-list {
|
||||||
|
// padding: 10px 0;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 12px 0;
|
||||||
|
/* border-bottom: 1px solid #eee; */
|
||||||
|
border-bottom: 1px solid var(--el-border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-item:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-item .el-icon {
|
||||||
|
color: #409EFF;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plus-card .feature-item.plus {
|
||||||
|
color: #67c23a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about_content {
|
||||||
|
h1 {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 20px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
line-height: 1.8;
|
||||||
|
margin: 12px 0;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link {
|
||||||
|
color: #409EFF;
|
||||||
|
text-decoration: none;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.conspicuous {
|
||||||
|
color: #F56C6C;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about_footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 响应式设计 */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.comparison-container {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comparison-card {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
@ -1,8 +1,12 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="top_bar_container">
|
<div class="top_bar_container">
|
||||||
<div class="bar_wrap">
|
<div class="bar_wrap">
|
||||||
|
<div class="mobile_menu_btn">
|
||||||
|
<el-icon @click="handleCollapse">
|
||||||
|
<Fold />
|
||||||
|
</el-icon>
|
||||||
|
</div>
|
||||||
<h2>{{ title }}</h2>
|
<h2>{{ title }}</h2>
|
||||||
<!-- <el-icon><UserFilled /></el-icon> -->
|
|
||||||
<el-switch
|
<el-switch
|
||||||
v-model="isDark"
|
v-model="isDark"
|
||||||
inline-prompt
|
inline-prompt
|
||||||
@ -16,10 +20,15 @@
|
|||||||
link
|
link
|
||||||
@click="visible = true"
|
@click="visible = true"
|
||||||
>
|
>
|
||||||
关于 <span class="new_version">{{ isNew ? `(新版本可用)` : '' }}</span>
|
版本更新 <span class="new_version">{{ isNew ? `(新版本可用)` : '' }}</span>
|
||||||
</el-button>
|
</el-button>
|
||||||
<el-dropdown trigger="click">
|
<el-dropdown trigger="click">
|
||||||
<span class="username"><el-icon><User /></el-icon> {{ user }}</span>
|
<span class="username_wrap">
|
||||||
|
<el-icon>
|
||||||
|
<User />
|
||||||
|
</el-icon>
|
||||||
|
<span class="username">{{ user }}</span>
|
||||||
|
</span>
|
||||||
<template #dropdown>
|
<template #dropdown>
|
||||||
<el-dropdown-menu>
|
<el-dropdown-menu>
|
||||||
<el-dropdown-item @click="handleLogout">
|
<el-dropdown-item @click="handleLogout">
|
||||||
@ -28,63 +37,114 @@
|
|||||||
</el-dropdown-menu>
|
</el-dropdown-menu>
|
||||||
</template>
|
</template>
|
||||||
</el-dropdown>
|
</el-dropdown>
|
||||||
|
<div class="plus_icon_wrapper" @click="gotoPlusPage">
|
||||||
|
<img
|
||||||
|
class="plus_icon"
|
||||||
|
src="@/assets/plus.png"
|
||||||
|
alt="PLUS"
|
||||||
|
:style="{ filter: isPlusActive ? 'grayscale(0%)' : 'grayscale(100%)' }"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
v-if="!isPlusActive && discount"
|
||||||
|
class="discount_badge"
|
||||||
|
src="@/assets/discount.png"
|
||||||
|
alt="Discount"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<el-dialog
|
<el-dialog
|
||||||
v-model="visible"
|
v-model="visible"
|
||||||
title="关于"
|
title="版本更新"
|
||||||
|
top="20vh"
|
||||||
width="30%"
|
width="30%"
|
||||||
:append-to-body="false"
|
:append-to-body="false"
|
||||||
|
:close-on-click-modal="false"
|
||||||
>
|
>
|
||||||
<div class="about_content">
|
<div class="about_content">
|
||||||
<h1>EasyNode</h1>
|
<!-- <h1>EasyNode</h1> -->
|
||||||
<p>当前版本: {{ currentVersion }}</p>
|
<p>当前版本: {{ currentVersion }} <span v-show="!isNew">(最新)</span> </p>
|
||||||
<p v-if="checkVersionErr" class="conspicuous">Error:版本更新检测失败(版本检测API需要外网环境)</p>
|
<p v-if="checkVersionErr" class="conspicuous">Error:版本更新检测失败(版本检测API需要外网环境),请手动访问GitHub查看</p>
|
||||||
<p v-if="isNew" class="conspicuous">
|
<p v-if="isNew" class="conspicuous">
|
||||||
新版本可用: {{ latestVersion }} -> <a class="link" href="https://github.com/chaos-zhu/easynode/releases" target="_blank">https://github.com/chaos-zhu/easynode/releases</a>
|
新版本可用: {{ latestVersion }} -> <a
|
||||||
|
class="link"
|
||||||
|
href="https://github.com/chaos-zhu/easynode/releases"
|
||||||
|
target="_blank"
|
||||||
|
>https://github.com/chaos-zhu/easynode/releases</a>
|
||||||
</p>
|
</p>
|
||||||
<p>作者: <a class="link" href="https://github.com/chaos-zhu" target="_blank">ChaosZhu</a></p>
|
|
||||||
<p>开源仓库: <a class="link" href="https://github.com/chaos-zhu/easynode" target="_blank">https://github.com/chaos-zhu/easynode</a></p>
|
|
||||||
<p>tg交流群:<a class="link" href="https://t.me/+aB62lFaqp8EwOWRl" target="_blank">https://t.me/+aB62lFaqp8EwOWRl</a></p>
|
|
||||||
<p>
|
<p>
|
||||||
打赏: EasyNode开源且无任何收费,如果您认为此项目帮到了您, 您可以请我喝杯阔乐~
|
功能更新日志:<a
|
||||||
|
class="link"
|
||||||
|
href="https://github.com/chaos-zhu/easynode/blob/main/CHANGELOG.md"
|
||||||
|
target="_blank"
|
||||||
|
>https://github.com/chaos-zhu/easynode/blob/main/CHANGELOG.md</a>
|
||||||
</p>
|
</p>
|
||||||
<p class="qrcode">
|
<p>
|
||||||
<img src="@/assets/wx.jpg" alt="">
|
TG更新通知频道:<a class="link" href="https://t.me/easynode_notify" target="_blank">https://t.me/easynode_notify</a>
|
||||||
</p>
|
</p>
|
||||||
|
<div class="about_footer">
|
||||||
|
<el-button type="info" @click="visible = false">关闭</el-button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
|
|
||||||
|
<el-drawer
|
||||||
|
v-model="menuCollapse"
|
||||||
|
:with-header="false"
|
||||||
|
direction="ltr"
|
||||||
|
class="mobile_menu_drawer"
|
||||||
|
>
|
||||||
|
<div class="mobile_logo_wrap">
|
||||||
|
<img src="@/assets/logo.png" alt="logo">
|
||||||
|
<h1>EasyNode</h1>
|
||||||
|
</div>
|
||||||
|
<MenuList @select="() => menuCollapse = false" />
|
||||||
|
</el-drawer>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, getCurrentInstance, computed } from 'vue'
|
import { ref, getCurrentInstance, computed, onMounted, onBeforeUnmount } from 'vue'
|
||||||
import { User, Sunny, Moon } from '@element-plus/icons-vue'
|
import { useRouter } from 'vue-router'
|
||||||
|
import { User, Sunny, Moon, Fold } from '@element-plus/icons-vue'
|
||||||
import packageJson from '../../package.json'
|
import packageJson from '../../package.json'
|
||||||
|
import MenuList from './menuList.vue'
|
||||||
|
|
||||||
const { proxy: { $router, $store, $message } } = getCurrentInstance()
|
const { proxy: { $router, $store, $api, $message } } = getCurrentInstance()
|
||||||
|
const router = useRouter()
|
||||||
|
const visible = ref(false)
|
||||||
|
const checkVersionErr = ref(false)
|
||||||
|
const currentVersion = ref(`v${ packageJson.version }`)
|
||||||
|
const latestVersion = ref(null)
|
||||||
|
const menuCollapse = ref(false)
|
||||||
|
const discount = ref(false)
|
||||||
|
|
||||||
let visible = ref(false)
|
const isNew = computed(() => latestVersion.value && latestVersion.value !== currentVersion.value)
|
||||||
let checkVersionErr = ref(false)
|
const user = computed(() => $store.user)
|
||||||
let currentVersion = ref(`v${ packageJson.version }`)
|
const title = computed(() => $store.title)
|
||||||
let latestVersion = ref(null)
|
const isPlusActive = computed(() => $store.isPlusActive)
|
||||||
|
|
||||||
let isNew = computed(() => latestVersion.value && latestVersion.value !== currentVersion.value)
|
const isDark = computed({
|
||||||
let user = computed(() => $store.user)
|
|
||||||
let title = computed(() => $store.title)
|
|
||||||
let isDark = computed({
|
|
||||||
get: () => $store.isDark,
|
get: () => $store.isDark,
|
||||||
set: (isDark) => {
|
set: (isDark) => {
|
||||||
$store.setTheme(isDark)
|
$store.setTheme(isDark)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const handleCollapse = () => {
|
||||||
|
menuCollapse.value = !menuCollapse.value
|
||||||
|
}
|
||||||
|
|
||||||
const handleLogout = () => {
|
const handleLogout = () => {
|
||||||
$store.clearJwtToken()
|
$store.removeJwtToken()
|
||||||
$message({ type: 'success', message: '已安全退出', center: true })
|
$message({ type: 'success', message: '已安全退出', center: true })
|
||||||
$router.push('/login')
|
$router.push('/login')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const gotoPlusPage = () => {
|
||||||
|
router.push('/setting?tabKey=plus')
|
||||||
|
}
|
||||||
|
|
||||||
async function checkLatestVersion() {
|
async function checkLatestVersion() {
|
||||||
const timeout = 3000
|
const timeout = 3000
|
||||||
try {
|
try {
|
||||||
@ -118,6 +178,32 @@ async function checkLatestVersion() {
|
|||||||
|
|
||||||
checkLatestVersion()
|
checkLatestVersion()
|
||||||
|
|
||||||
|
let timer = null
|
||||||
|
const checkFirstVisit = () => {
|
||||||
|
timer = setTimeout(() => {
|
||||||
|
const visitedVersion = localStorage.getItem('visitedVersion')
|
||||||
|
if (!visitedVersion || visitedVersion !== currentVersion.value) {
|
||||||
|
visible.value = true
|
||||||
|
localStorage.setItem('visitedVersion', currentVersion.value)
|
||||||
|
}
|
||||||
|
}, 1500)
|
||||||
|
}
|
||||||
|
|
||||||
|
const getPlusDiscount = async () => {
|
||||||
|
const { data } = await $api.getPlusDiscount()
|
||||||
|
if (data?.discount) {
|
||||||
|
discount.value = data.discount
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
checkFirstVisit()
|
||||||
|
getPlusDiscount()
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
clearTimeout(timer)
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
@ -128,47 +214,228 @@ checkLatestVersion()
|
|||||||
position: sticky;
|
position: sticky;
|
||||||
top: 0;
|
top: 0;
|
||||||
z-index: 999;
|
z-index: 999;
|
||||||
|
|
||||||
.bar_wrap {
|
.bar_wrap {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 0 20px;
|
padding: 0 20px;
|
||||||
|
|
||||||
h2 {
|
h2 {
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
margin-right: auto;
|
margin-right: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark_switch {
|
.dark_switch {
|
||||||
margin-right: 15px;
|
margin-right: 15px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.about_btn {
|
.about_btn {
|
||||||
margin-right: 15px;
|
margin-right: 15px;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
|
|
||||||
.new_version {
|
.new_version {
|
||||||
color: red;
|
color: red;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.username_wrap {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
.username {
|
.username {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
margin-left: 5px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.plus_icon_wrapper {
|
||||||
|
margin-left: 15px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
.plus_icon {
|
||||||
|
width: 35px;
|
||||||
|
margin-right: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.discount_badge {
|
||||||
|
width: 22px;
|
||||||
|
color: white;
|
||||||
|
font-size: 12px;
|
||||||
|
transform: rotate(25deg);
|
||||||
|
border-radius: 4px;
|
||||||
|
box-shadow: 0 2px 4px rgba(0,0,0,0.2);
|
||||||
|
animation: pulse 2s infinite;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.about_content {
|
.about_content {
|
||||||
h1 {
|
h1 {
|
||||||
font-size: 18px;
|
font-size: 24px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
margin: 15px 0;
|
margin: 20px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
p {
|
p {
|
||||||
line-height: 35px;
|
line-height: 1.8;
|
||||||
|
margin: 12px 0;
|
||||||
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
.qrcode {
|
|
||||||
text-align: center;
|
.link {
|
||||||
img {
|
color: #409EFF;
|
||||||
width: 250px;
|
text-decoration: none;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.conspicuous {
|
.conspicuous {
|
||||||
color: red;
|
color: #F56C6C;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.about_footer {
|
||||||
|
margin-top: 20px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0% {
|
||||||
|
transform: rotate(25deg) scale(1);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: rotate(25deg) scale(1.1);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: rotate(25deg) scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
.plus_content_wrap {
|
||||||
|
.plus_status {
|
||||||
|
margin-bottom: 15px;
|
||||||
|
padding-bottom: 15px;
|
||||||
|
border-bottom: 1px solid #eee;
|
||||||
|
|
||||||
|
.status_header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
color: #67c23a;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
|
||||||
|
.el-icon {
|
||||||
|
margin-right: 5px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.status_info {
|
||||||
|
.info_item {
|
||||||
|
display: flex;
|
||||||
|
margin: 5px 0;
|
||||||
|
font-size: 13px;
|
||||||
|
|
||||||
|
.label {
|
||||||
|
color: #909399;
|
||||||
|
width: 80px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.holder {
|
||||||
|
color: #EED183;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.ip_list {
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
.ip_tags {
|
||||||
|
margin-top: 5px;
|
||||||
|
|
||||||
|
.ip_tag {
|
||||||
|
margin: 2px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.plus_benefits {
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
.support_btn {
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
top: 0;
|
||||||
|
padding: 4px 12px;
|
||||||
|
background-color: #409eff;
|
||||||
|
color: white;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: #66b1ff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.benefits_header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
font-weight: bold;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
|
||||||
|
.el-icon {
|
||||||
|
color: #e6a23c;
|
||||||
|
margin-right: 5px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.current_benefits {
|
||||||
|
margin-bottom: 15px;
|
||||||
|
|
||||||
|
.benefit_item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
margin: 8px 0;
|
||||||
|
font-size: 13px;
|
||||||
|
|
||||||
|
.el-icon {
|
||||||
|
margin-right: 5px;
|
||||||
|
color: #409eff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.coming_soon {
|
||||||
|
.soon_header {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #909399;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.discount_content {
|
||||||
|
margin: 8px 0;
|
||||||
|
|
||||||
|
.el-tag {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 6px 12px;
|
||||||
|
|
||||||
|
.el-icon {
|
||||||
|
margin-right: 4px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
16
web/src/composables/useMobileWidth.js
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
import { ref, onMounted, onUnmounted } from 'vue'
|
||||||
|
|
||||||
|
export default function useMobileWidth(maxWidth = 968) {
|
||||||
|
const isMobileScreen = ref(window.innerWidth < maxWidth)
|
||||||
|
function updateScreenWidth() {
|
||||||
|
isMobileScreen.value = window.innerWidth < maxWidth
|
||||||
|
}
|
||||||
|
onMounted(() => {
|
||||||
|
window.addEventListener('resize', updateScreenWidth)
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
window.removeEventListener('resize', updateScreenWidth)
|
||||||
|
})
|
||||||
|
return { isMobileScreen }
|
||||||
|
}
|
3
web/src/config/index.js
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
export default {
|
||||||
|
defaultClientPort: 22022
|
||||||
|
}
|
@ -10,6 +10,7 @@ import api from './api'
|
|||||||
import App from './app.vue'
|
import App from './app.vue'
|
||||||
import './assets/scss/reset.scss'
|
import './assets/scss/reset.scss'
|
||||||
import './assets/scss/global.scss'
|
import './assets/scss/global.scss'
|
||||||
|
import './assets/scss/mobile.scss'
|
||||||
|
|
||||||
const app = createApp(App)
|
const app = createApp(App)
|
||||||
elementPlugins(app)
|
elementPlugins(app)
|
||||||
@ -24,7 +25,6 @@ app.config.globalProperties.$store = useStore()
|
|||||||
|
|
||||||
const serviceURI = import.meta.env.DEV ? process.env.serviceURI : location.origin
|
const serviceURI = import.meta.env.DEV ? process.env.serviceURI : location.origin
|
||||||
app.config.globalProperties.$serviceURI = serviceURI
|
app.config.globalProperties.$serviceURI = serviceURI
|
||||||
app.config.globalProperties.$clientPort = process.env.clientPort || 22022
|
|
||||||
app.config.globalProperties.$store.$patch({ serviceURI })
|
app.config.globalProperties.$store.$patch({ serviceURI })
|
||||||
console.warn('ISDEV: ', import.meta.env.DEV)
|
console.warn('ISDEV: ', import.meta.env.DEV)
|
||||||
console.warn('serviceURI: ', serviceURI)
|
console.warn('serviceURI: ', serviceURI)
|
||||||
|
@ -1,7 +1,11 @@
|
|||||||
import { io } from 'socket.io-client'
|
import { io } from 'socket.io-client'
|
||||||
import { defineStore, acceptHMRUpdate } from 'pinia'
|
import { defineStore, acceptHMRUpdate } from 'pinia'
|
||||||
|
import dayjs from 'dayjs'
|
||||||
import $api from '@/api'
|
import $api from '@/api'
|
||||||
// import ping from '@/utils/ping'
|
import config from '@/config'
|
||||||
|
import { isHttps } from '@/utils'
|
||||||
|
|
||||||
|
const { defaultClientPort } = config
|
||||||
|
|
||||||
const useStore = defineStore({
|
const useStore = defineStore({
|
||||||
id: 'global',
|
id: 'global',
|
||||||
@ -14,9 +18,35 @@ const useStore = defineStore({
|
|||||||
localScriptList: [],
|
localScriptList: [],
|
||||||
HostStatusSocket: null,
|
HostStatusSocket: null,
|
||||||
user: localStorage.getItem('user') || null,
|
user: localStorage.getItem('user') || null,
|
||||||
token: sessionStorage.getItem('token') || localStorage.getItem('token') || null,
|
token: localStorage.getItem('token') || sessionStorage.getItem('token') || null,
|
||||||
title: '',
|
title: '',
|
||||||
isDark: false
|
isDark: false,
|
||||||
|
menuCollapse: localStorage.getItem('menuCollapse') === 'true',
|
||||||
|
defaultBackgroundImages: [
|
||||||
|
'linear-gradient(-225deg, #CBBACC 0%, #2580B3 100%)',
|
||||||
|
'linear-gradient(to top, #a18cd1 0%, #fbc2eb 100%)',
|
||||||
|
'linear-gradient(to top, #6a85b6 0%, #bac8e0 100%)',
|
||||||
|
'linear-gradient(to top, #7028e4 0%, #e5b2ca 100%)',
|
||||||
|
'linear-gradient(to top, #9be15d 0%, #00e3ae 100%)',
|
||||||
|
'linear-gradient(60deg, #abecd6 0%, #fbed96 100%)',
|
||||||
|
'linear-gradient(-20deg, #2b5876 0%, #4e4376 100%)',
|
||||||
|
'linear-gradient(to top, #1e3c72 0%, #1e3c72 1%, #2a5298 100%)',
|
||||||
|
'linear-gradient(to right, #243949 0%, #517fa4 100%)',
|
||||||
|
],
|
||||||
|
terminalConfig: {
|
||||||
|
...{
|
||||||
|
fontSize: 16,
|
||||||
|
themeName: 'Afterglow',
|
||||||
|
background: 'linear-gradient(-225deg, #CBBACC 0%, #2580B3 100%)',
|
||||||
|
quickCopy: isHttps(),
|
||||||
|
quickPaste: isHttps(),
|
||||||
|
autoReconnect: true,
|
||||||
|
autoExecuteScript: false
|
||||||
|
},
|
||||||
|
...(localStorage.getItem('terminalConfig') ? JSON.parse(localStorage.getItem('terminalConfig')) : {})
|
||||||
|
},
|
||||||
|
plusInfo: {},
|
||||||
|
isPlusActive: false
|
||||||
}),
|
}),
|
||||||
actions: {
|
actions: {
|
||||||
async setJwtToken(token, isSession = true) {
|
async setJwtToken(token, isSession = true) {
|
||||||
@ -31,9 +61,9 @@ const useStore = defineStore({
|
|||||||
async setTitle(title) {
|
async setTitle(title) {
|
||||||
this.$patch({ title })
|
this.$patch({ title })
|
||||||
},
|
},
|
||||||
async clearJwtToken() {
|
async removeJwtToken() {
|
||||||
localStorage.clear('token')
|
localStorage.removeItem('token')
|
||||||
sessionStorage.clear('token')
|
sessionStorage.removeItem('token')
|
||||||
this.$patch({ token: null })
|
this.$patch({ token: null })
|
||||||
},
|
},
|
||||||
async getMainData() {
|
async getMainData() {
|
||||||
@ -41,11 +71,13 @@ const useStore = defineStore({
|
|||||||
await this.getHostList()
|
await this.getHostList()
|
||||||
await this.getSSHList()
|
await this.getSSHList()
|
||||||
await this.getScriptList()
|
await this.getScriptList()
|
||||||
this.wsHostStatus()
|
await this.getPlusInfo()
|
||||||
|
this.wsClientsStatus()
|
||||||
},
|
},
|
||||||
async getHostList() {
|
async getHostList() {
|
||||||
let { data: newHostList } = await $api.getHostList()
|
let { data: newHostList } = await $api.getHostList()
|
||||||
newHostList = newHostList.map(newHostObj => {
|
newHostList = newHostList.map(newHostObj => {
|
||||||
|
newHostObj.expired = dayjs(newHostObj.expired).format('YYYY-MM-DD')
|
||||||
const oldHostObj = this.hostList.find(({ id }) => id === newHostObj.id)
|
const oldHostObj = this.hostList.find(({ id }) => id === newHostObj.id)
|
||||||
return oldHostObj ? Object.assign({}, { ...oldHostObj }, { ...newHostObj }) : newHostObj
|
return oldHostObj ? Object.assign({}, { ...oldHostObj }, { ...newHostObj }) : newHostObj
|
||||||
})
|
})
|
||||||
@ -68,18 +100,29 @@ const useStore = defineStore({
|
|||||||
const { data: localScriptList } = await $api.getLocalScriptList()
|
const { data: localScriptList } = await $api.getLocalScriptList()
|
||||||
this.$patch({ localScriptList })
|
this.$patch({ localScriptList })
|
||||||
},
|
},
|
||||||
// getHostPing() {
|
async getPlusInfo() {
|
||||||
// setInterval(() => {
|
const { data: plusInfo = {} } = await $api.getPlusInfo()
|
||||||
// this.hostList.forEach((item) => {
|
if (plusInfo?.expiryDate) {
|
||||||
// const { host } = item
|
const isPlusActive = new Date(plusInfo.expiryDate) > new Date()
|
||||||
// ping(`http://${ host }:${ this.$clientPort }`)
|
this.$patch({ isPlusActive })
|
||||||
// .then((res) => {
|
if (!isPlusActive) {
|
||||||
// item.ping = res
|
this.setTerminalSetting({ autoReconnect: false })
|
||||||
// })
|
return
|
||||||
// })
|
}
|
||||||
// }, 2000)
|
plusInfo.expiryDate = dayjs(plusInfo.expiryDate).format('YYYY-MM-DD')
|
||||||
// },
|
plusInfo.expiryDate?.startsWith('9999') && (plusInfo.expiryDate = '永久授权')
|
||||||
async wsHostStatus() {
|
this.$patch({ plusInfo })
|
||||||
|
} else {
|
||||||
|
this.$patch({ isPlusActive: false })
|
||||||
|
}
|
||||||
|
this.$patch({ plusInfo })
|
||||||
|
},
|
||||||
|
setTerminalSetting(setTarget = {}) {
|
||||||
|
let newConfig = { ...this.terminalConfig, ...setTarget }
|
||||||
|
localStorage.setItem('terminalConfig', JSON.stringify(newConfig))
|
||||||
|
this.$patch({ terminalConfig: newConfig })
|
||||||
|
},
|
||||||
|
async wsClientsStatus() {
|
||||||
// if (this.HostStatusSocket) this.HostStatusSocket.close()
|
// if (this.HostStatusSocket) this.HostStatusSocket.close()
|
||||||
let socketInstance = io(this.serviceURI, {
|
let socketInstance = io(this.serviceURI, {
|
||||||
path: '/clients',
|
path: '/clients',
|
||||||
@ -95,8 +138,8 @@ const useStore = defineStore({
|
|||||||
socketInstance.on('clients_data', (data) => {
|
socketInstance.on('clients_data', (data) => {
|
||||||
// console.log(data)
|
// console.log(data)
|
||||||
this.hostList.forEach(item => {
|
this.hostList.forEach(item => {
|
||||||
const { host } = item
|
const { host, clientPort } = item
|
||||||
return Object.assign(item, { monitorData: Object.freeze(data[host]) })
|
return Object.assign(item, { monitorData: Object.freeze(data[`${ host }:${ clientPort || defaultClientPort }`]) })
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
socketInstance.on('token_verify_fail', (message) => {
|
socketInstance.on('token_verify_fail', (message) => {
|
||||||
@ -162,6 +205,11 @@ const useStore = defineStore({
|
|||||||
isDark = systemTheme
|
isDark = systemTheme
|
||||||
}
|
}
|
||||||
this.setTheme(isDark, false)
|
this.setTheme(isDark, false)
|
||||||
|
},
|
||||||
|
setMenuCollapse() {
|
||||||
|
let newState = !this.menuCollapse
|
||||||
|
localStorage.setItem('menuCollapse', newState)
|
||||||
|
this.$patch({ menuCollapse: newState })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
@ -1,15 +1,15 @@
|
|||||||
// 终端连接状态
|
// 终端连接状态
|
||||||
export const terminalStatus = {
|
export const terminalStatus = {
|
||||||
CONNECTING: 'connecting',
|
CONNECTING: 'connecting',
|
||||||
RECONNECTING: 'reconnecting',
|
|
||||||
CONNECT_FAIL: 'connect_fail',
|
CONNECT_FAIL: 'connect_fail',
|
||||||
CONNECT_SUCCESS: 'connect_success'
|
CONNECT_SUCCESS: 'connect_success'
|
||||||
}
|
}
|
||||||
export const terminalStatusList = [
|
export const terminalStatusList = [
|
||||||
{ value: terminalStatus.CONNECTING, label: '连接中', color: '#FFA500' },
|
{ value: terminalStatus.CONNECTING, label: '连接中', color: '#FFA500' },
|
||||||
{ value: terminalStatus.RECONNECTING, label: '重连中', color: '#FFA500' },
|
|
||||||
{ value: terminalStatus.CONNECT_FAIL, label: '连接失败', color: '#DC3545' },
|
{ value: terminalStatus.CONNECT_FAIL, label: '连接失败', color: '#DC3545' },
|
||||||
{ value: terminalStatus.CONNECT_SUCCESS, label: '已连接', color: '#28A745' },
|
{ value: terminalStatus.CONNECT_SUCCESS, label: '已连接', color: '#28A745' },
|
||||||
]
|
]
|
||||||
|
export const virtualKeyType = {
|
||||||
// other...
|
LONG_PRESS: 'long-press',
|
||||||
|
SINGLE_PRESS: 'single-press'
|
||||||
|
}
|
||||||
|
@ -1,4 +1,3 @@
|
|||||||
|
|
||||||
import { reactive } from 'vue'
|
import { reactive } from 'vue'
|
||||||
import JSRsaEncrypt from 'jsencrypt'
|
import JSRsaEncrypt from 'jsencrypt'
|
||||||
import CryptoJS from 'crypto-js'
|
import CryptoJS from 'crypto-js'
|
||||||
@ -93,7 +92,20 @@ export const sortDirTree = (tree = []) => {
|
|||||||
}
|
}
|
||||||
sort(dirsAndlinks)
|
sort(dirsAndlinks)
|
||||||
sort(others)
|
sort(others)
|
||||||
return [].concat(dirsAndlinks, others)
|
let res = [].concat(dirsAndlinks, others)
|
||||||
|
let homeDirIndex = res.findIndex(item => item.name === 'home')
|
||||||
|
if (homeDirIndex !== -1) {
|
||||||
|
let homeDir = res[homeDirIndex]
|
||||||
|
res.splice(homeDirIndex, 1)
|
||||||
|
res.unshift(homeDir)
|
||||||
|
}
|
||||||
|
let rootDirIndex = res.findIndex(item => item.name === 'root')
|
||||||
|
if (rootDirIndex !== -1) {
|
||||||
|
let rootDir = res[rootDirIndex]
|
||||||
|
res.splice(rootDirIndex, 1)
|
||||||
|
res.unshift(rootDir)
|
||||||
|
}
|
||||||
|
return res
|
||||||
}
|
}
|
||||||
|
|
||||||
export const downloadFile = ({ buffer, name }) => {
|
export const downloadFile = ({ buffer, name }) => {
|
||||||
@ -116,7 +128,7 @@ export const getSuffix = (name = '') => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const exportFile = (data, filename, mimeType = 'application/json') =>{
|
export const exportFile = (data, filename, mimeType = 'application/json') =>{
|
||||||
const blob = new Blob([JSON.stringify(data),], { type: mimeType })
|
const blob = new Blob([JSON.stringify(data, null, 2),], { type: mimeType })
|
||||||
const url = window.URL.createObjectURL(blob)
|
const url = window.URL.createObjectURL(blob)
|
||||||
|
|
||||||
const link = document.createElement('a')
|
const link = document.createElement('a')
|
||||||
@ -128,3 +140,16 @@ export const exportFile = (data, filename, mimeType = 'application/json') =>{
|
|||||||
document.body.removeChild(link)
|
document.body.removeChild(link)
|
||||||
window.URL.revokeObjectURL(url)
|
window.URL.revokeObjectURL(url)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const isHttps = () => {
|
||||||
|
return window.location.protocol === 'https:'
|
||||||
|
}
|
||||||
|
|
||||||
|
export const isMobile = () => {
|
||||||
|
let userAgent = navigator.userAgent || navigator.vendor || window.opera
|
||||||
|
return /android|webos|iphone|ipad|ipod|blackberry|iemobile|opera mini/i.test(userAgent)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const handlePlusSupport = () => {
|
||||||
|
window.open('https://en.221022.xyz/buy-plus', '_blank')
|
||||||
|
}
|
||||||
|
@ -10,7 +10,7 @@
|
|||||||
{{ row.authType === 'privateKey' ? '密钥' : '密码' }}
|
{{ row.authType === 'privateKey' ? '密钥' : '密码' }}
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="操作">
|
<el-table-column width="160px" label="操作">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-button type="primary" @click="handleChange(row)">修改</el-button>
|
<el-button type="primary" @click="handleChange(row)">修改</el-button>
|
||||||
<el-button v-show="row.id !== 'default'" type="danger" @click="removeSSH(row)">删除</el-button>
|
<el-button v-show="row.id !== 'default'" type="danger" @click="removeSSH(row)">删除</el-button>
|
||||||
@ -84,12 +84,41 @@
|
|||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
|
<el-dialog
|
||||||
|
v-model="keyPasswordVisible"
|
||||||
|
title="输入密钥密码"
|
||||||
|
width="400px"
|
||||||
|
:close-on-click-modal="false"
|
||||||
|
>
|
||||||
|
<el-form @submit.prevent>
|
||||||
|
<el-form-item label="密码">
|
||||||
|
<el-input
|
||||||
|
v-model="keyPassword"
|
||||||
|
type="password"
|
||||||
|
placeholder="请输入密钥密码"
|
||||||
|
show-password
|
||||||
|
autocomplete="off"
|
||||||
|
clearable
|
||||||
|
@keyup.enter="handleDecryptKey"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<span>
|
||||||
|
<el-button @click="keyPasswordVisible = false">取消</el-button>
|
||||||
|
<PlusSupportTip>
|
||||||
|
<el-button type="primary" :disabled="!isPlusActive" @click="handleDecryptKey">确认</el-button>
|
||||||
|
</PlusSupportTip>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, reactive, computed, nextTick, getCurrentInstance } from 'vue'
|
import { ref, reactive, computed, nextTick, getCurrentInstance } from 'vue'
|
||||||
import { randomStr, AESEncrypt, RSAEncrypt } from '@utils/index.js'
|
import { randomStr, AESEncrypt, RSAEncrypt } from '@utils/index.js'
|
||||||
|
import PlusSupportTip from '@/components/common/PlusSupportTip.vue'
|
||||||
|
|
||||||
const { proxy: { $api, $message, $messageBox, $store } } = getCurrentInstance()
|
const { proxy: { $api, $message, $messageBox, $store } } = getCurrentInstance()
|
||||||
|
|
||||||
@ -115,6 +144,7 @@ const updateFormRef = ref(null)
|
|||||||
const privateKeyRef = ref(null)
|
const privateKeyRef = ref(null)
|
||||||
|
|
||||||
let sshList = computed(() => $store.sshList)
|
let sshList = computed(() => $store.sshList)
|
||||||
|
let isPlusActive = computed(() => $store.isPlusActive)
|
||||||
|
|
||||||
let addCredentials = () => {
|
let addCredentials = () => {
|
||||||
sshForm.id = null
|
sshForm.id = null
|
||||||
@ -159,9 +189,9 @@ const removeSSH = ({ id, name }) => {
|
|||||||
type: 'warning'
|
type: 'warning'
|
||||||
})
|
})
|
||||||
.then(async () => {
|
.then(async () => {
|
||||||
await $api.removeSSH(id) // 后台会同步删除关联此凭证的credential字段
|
await $api.removeSSH(id)
|
||||||
await $store.getSSHList()
|
await $store.getSSHList()
|
||||||
await $store.getHostList() // 刷新主机字段 isConfig
|
await $store.getHostList()
|
||||||
$message.success('success')
|
$message.success('success')
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@ -170,16 +200,40 @@ const handleClickUploadBtn = () => {
|
|||||||
privateKeyRef.value.click()
|
privateKeyRef.value.click()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const keyPasswordVisible = ref(false)
|
||||||
|
const keyPassword = ref('')
|
||||||
|
const tempPrivateKey = ref('')
|
||||||
|
|
||||||
const handleSelectPrivateKeyFile = (event) => {
|
const handleSelectPrivateKeyFile = (event) => {
|
||||||
let file = event.target.files[0]
|
let file = event.target.files[0]
|
||||||
let reader = new FileReader()
|
let reader = new FileReader()
|
||||||
reader.onload = (e) => {
|
reader.onload = async (e) => {
|
||||||
sshForm.privateKey = e.target.result
|
const content = e.target.result
|
||||||
|
// 检查是否是加密的私钥
|
||||||
|
if (content.includes('ENCRYPTED')) {
|
||||||
|
tempPrivateKey.value = content
|
||||||
|
keyPasswordVisible.value = true
|
||||||
|
} else {
|
||||||
|
sshForm.privateKey = content
|
||||||
|
}
|
||||||
privateKeyRef.value.value = ''
|
privateKeyRef.value.value = ''
|
||||||
}
|
}
|
||||||
reader.readAsText(file)
|
reader.readAsText(file)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleDecryptKey = async () => {
|
||||||
|
if (!keyPassword.value) return $message.error('请输入密钥密码')
|
||||||
|
const { data } = await $api.decryptPrivateKey({
|
||||||
|
privateKey: tempPrivateKey.value,
|
||||||
|
password: keyPassword.value
|
||||||
|
})
|
||||||
|
sshForm.privateKey = data
|
||||||
|
keyPasswordVisible.value = false
|
||||||
|
keyPassword.value = ''
|
||||||
|
tempPrivateKey.value = ''
|
||||||
|
$message.success('密钥解密成功')
|
||||||
|
}
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
|
@ -4,9 +4,9 @@
|
|||||||
<el-button type="primary" @click="addGroup">添加分组</el-button>
|
<el-button type="primary" @click="addGroup">添加分组</el-button>
|
||||||
</div>
|
</div>
|
||||||
<el-table v-loading="loading" :data="list">
|
<el-table v-loading="loading" :data="list">
|
||||||
<el-table-column prop="index" label="序号" width="100px" />
|
<el-table-column prop="index" label="序号" />
|
||||||
<el-table-column prop="name" label="分组名称" />
|
<el-table-column prop="name" label="分组名称" />
|
||||||
<el-table-column label="关联实例数量">
|
<el-table-column label="关联实例数量" min-width="115px">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-popover
|
<el-popover
|
||||||
v-if="row.hosts.list.length !== 0"
|
v-if="row.hosts.list.length !== 0"
|
||||||
@ -28,7 +28,7 @@
|
|||||||
<u v-else class="host_count">0</u>
|
<u v-else class="host_count">0</u>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="操作">
|
<el-table-column label="操作" fixed="right" width="160px">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-button type="primary" @click="handleChange(row)">修改</el-button>
|
<el-button type="primary" @click="handleChange(row)">修改</el-button>
|
||||||
<el-button v-show="row.id !== 'default'" type="danger" @click="deleteGroup(row)">删除</el-button>
|
<el-button v-show="row.id !== 'default'" type="danger" @click="deleteGroup(row)">删除</el-button>
|
||||||
|
@ -46,10 +46,10 @@ onBeforeMount(async () => {
|
|||||||
height: 100vh;
|
height: 100vh;
|
||||||
.main_container {
|
.main_container {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
height: 100vh;
|
height: 100%;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
.router_box {
|
.router_box {
|
||||||
min-height: calc(100vh - 60px - 20px);
|
height: calc(100% - 60px - 20px);
|
||||||
background-color: #fff;
|
background-color: #fff;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
margin: 10px;
|
margin: 10px;
|
||||||
|
@ -13,9 +13,10 @@
|
|||||||
:model="loginForm"
|
:model="loginForm"
|
||||||
:rules="rules"
|
:rules="rules"
|
||||||
:hide-required-asterisk="true"
|
:hide-required-asterisk="true"
|
||||||
|
:show-message="false"
|
||||||
label-suffix=":"
|
label-suffix=":"
|
||||||
label-width="90px"
|
label-width="90px"
|
||||||
:show-message="false"
|
label-position="top"
|
||||||
>
|
>
|
||||||
<el-form-item prop="loginName" label="用户名">
|
<el-form-item prop="loginName" label="用户名">
|
||||||
<el-input
|
<el-input
|
||||||
@ -43,21 +44,23 @@
|
|||||||
<el-form-item v-show="false" prop="pwd" label="密码">
|
<el-form-item v-show="false" prop="pwd" label="密码">
|
||||||
<el-input v-model.trim="loginForm.pwd" />
|
<el-input v-model.trim="loginForm.pwd" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item prop="jwtExpires" label="有效期">
|
<el-form-item prop="mfa2Token" label="MFA2验证码">
|
||||||
<el-radio-group v-model="isSession" class="login-indate">
|
<el-input
|
||||||
<el-radio :value="true">一次性会话</el-radio>
|
v-model="loginForm.mfa2Token"
|
||||||
<el-radio :value="false">自定义(小时)</el-radio>
|
type="text"
|
||||||
<el-input-number
|
placeholder="MFA2应用上的6位数字(未设置可忽略)"
|
||||||
v-model="loginForm.jwtExpires"
|
autocomplete="off"
|
||||||
:disabled="isSession"
|
:trigger-on-focus="false"
|
||||||
placeholder="单位:小时"
|
clearable
|
||||||
class="input"
|
autofocus
|
||||||
:min="1"
|
@keyup.enter="handleLogin"
|
||||||
:max="72"
|
|
||||||
value-on-clear="min"
|
|
||||||
size="small"
|
|
||||||
controls-position="right"
|
|
||||||
/>
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item prop="jwtExpires" label="有效期">
|
||||||
|
<el-radio-group v-model="expireTime" class="login-indate">
|
||||||
|
<el-radio :value="expireEnum.ONE_SESSION">一次性会话</el-radio>
|
||||||
|
<el-radio :value="expireEnum.CURRENT_DAY">当天有效</el-radio>
|
||||||
|
<el-radio :value="expireEnum.THREE_DAY">三天有效</el-radio>
|
||||||
</el-radio-group>
|
</el-radio-group>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-form>
|
</el-form>
|
||||||
@ -79,44 +82,64 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, reactive, onMounted, getCurrentInstance } from 'vue'
|
import { ref, reactive, onMounted, getCurrentInstance } from 'vue'
|
||||||
import { RSAEncrypt } from '@utils/index.js'
|
import { RSAEncrypt } from '@utils/index.js'
|
||||||
// import { useRouter } from 'vue-router'
|
|
||||||
// import useStore from '@store/index'
|
|
||||||
|
|
||||||
// const router = useRouter()
|
|
||||||
const { proxy: { $store, $api, $message, $messageBox, $router } } = getCurrentInstance()
|
const { proxy: { $store, $api, $message, $messageBox, $router } } = getCurrentInstance()
|
||||||
|
|
||||||
|
const expireEnum = reactive({
|
||||||
|
ONE_SESSION: 'one_session',
|
||||||
|
CURRENT_DAY: 'current_day',
|
||||||
|
THREE_DAY: 'three_day'
|
||||||
|
})
|
||||||
|
const expireTime = ref(expireEnum.CURRENT_DAY)
|
||||||
const loginFormRefs = ref(null)
|
const loginFormRefs = ref(null)
|
||||||
const isSession = ref(true)
|
|
||||||
const notKey = ref(false)
|
const notKey = ref(false)
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const loginForm = reactive({
|
const loginForm = reactive({
|
||||||
loginName: '',
|
loginName: '',
|
||||||
pwd: '',
|
pwd: '',
|
||||||
jwtExpires: 8
|
jwtExpires: 1,
|
||||||
|
mfa2Token: ''
|
||||||
})
|
})
|
||||||
const rules = reactive({
|
const rules = reactive({
|
||||||
loginName: { required: true, message: '需输入用户名', trigger: 'change' },
|
loginName: { required: true, message: '需输入用户名', trigger: 'change' },
|
||||||
pwd: { required: true, message: '需输入密码', trigger: 'change' }
|
pwd: { required: true, message: '需输入密码', trigger: 'change' },
|
||||||
|
mfa2Token: { required: false, message: '需输入MFA2验证码', trigger: 'change' }
|
||||||
})
|
})
|
||||||
|
|
||||||
const handleLogin = () => {
|
const handleLogin = () => {
|
||||||
loginFormRefs.value.validate().then(() => {
|
loginFormRefs.value.validate().then(async () => {
|
||||||
let { jwtExpires, loginName, pwd } = loginForm
|
let { jwtExpires, loginName, pwd, mfa2Token } = loginForm
|
||||||
jwtExpires = isSession.value ? '12h' : `${ jwtExpires }h`
|
switch (expireTime.value) {
|
||||||
if (!isSession.value) {
|
case expireEnum.ONE_SESSION:
|
||||||
localStorage.setItem('jwtExpires', jwtExpires)
|
jwtExpires = '1h' // 会话登录token1小时有效期,浏览器窗口关闭则立即失效
|
||||||
|
break
|
||||||
|
case expireEnum.CURRENT_DAY:
|
||||||
|
jwtExpires = `${ Math.floor((new Date().setHours(24,0,0,0) - Date.now()) / 1000) }s`
|
||||||
|
break
|
||||||
|
case expireEnum.THREE_DAY:
|
||||||
|
jwtExpires = '3d'
|
||||||
|
break
|
||||||
}
|
}
|
||||||
const ciphertext = RSAEncrypt(pwd)
|
const ciphertext = RSAEncrypt(pwd)
|
||||||
if (ciphertext === -1) return $message.error({ message: '公钥加载失败', center: true })
|
if (ciphertext === -1) return $message.error({ message: '公钥加载失败', center: true })
|
||||||
loading.value = true
|
loading.value = true
|
||||||
$api.login({ loginName, ciphertext, jwtExpires })
|
try {
|
||||||
.then(({ data, msg }) => {
|
let { data, msg } = await $api.login({ loginName, ciphertext, jwtExpires, mfa2Token: Number(mfa2Token) })
|
||||||
const { token } = data
|
const { token } = data
|
||||||
$store.setJwtToken(token, isSession.value)
|
$store.setJwtToken(token, expireEnum.ONE_SESSION === expireTime.value)
|
||||||
$store.setUser(loginName)
|
$store.setUser(loginName)
|
||||||
$message.success({ message: msg || 'success', center: true })
|
$message.success({ message: msg || 'success', center: true })
|
||||||
|
loginSuccess()
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const loginSuccess = () => {
|
||||||
|
let { loginName, pwd } = loginForm
|
||||||
if (loginName === 'admin' && pwd === 'admin') {
|
if (loginName === 'admin' && pwd === 'admin') {
|
||||||
$messageBox.confirm('请立即修改初始用户名及密码!防止恶意扫描!', '警告', {
|
$messageBox.confirm('请立即修改初始用户名及密码!防止恶意扫描!', 'Warning', {
|
||||||
confirmButtonText: '确定',
|
confirmButtonText: '确定',
|
||||||
showCancelButton: false,
|
showCancelButton: false,
|
||||||
type: 'warning'
|
type: 'warning'
|
||||||
@ -127,11 +150,6 @@ const handleLogin = () => {
|
|||||||
} else {
|
} else {
|
||||||
$router.push('/')
|
$router.push('/')
|
||||||
}
|
}
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
loading.value = false
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
@ -139,6 +157,7 @@ onMounted(async () => {
|
|||||||
const { data } = await $api.getPubPem()
|
const { data } = await $api.getPubPem()
|
||||||
if (!data) return (notKey.value = true)
|
if (!data) return (notKey.value = true)
|
||||||
localStorage.setItem('publicKey', data)
|
localStorage.setItem('publicKey', data)
|
||||||
|
$store.removeJwtToken()
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@ -156,7 +175,7 @@ onMounted(async () => {
|
|||||||
|
|
||||||
.login_box {
|
.login_box {
|
||||||
margin-top: -80px;
|
margin-top: -80px;
|
||||||
width: 500px;
|
width: 450px;
|
||||||
min-height: 250px;
|
min-height: 250px;
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
@ -182,7 +201,7 @@ onMounted(async () => {
|
|||||||
|
|
||||||
.login-indate {
|
.login-indate {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: nowrap;
|
// flex-wrap: nowrap;
|
||||||
|
|
||||||
.input {
|
.input {
|
||||||
margin-left: -25px;
|
margin-left: -25px;
|
||||||
|
@ -32,28 +32,44 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="name" label="实例">
|
<el-table-column
|
||||||
|
prop="name"
|
||||||
|
label="实例"
|
||||||
|
show-overflow-tooltip
|
||||||
|
min-width="120px"
|
||||||
|
>
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<span style="letter-spacing: 2px;"> {{ row.name }} </span>
|
<span style="letter-spacing: 2px;"> {{ row.name }} </span> -
|
||||||
<span style="letter-spacing: 2px;"> {{ row.host }} </span>
|
<span style="letter-spacing: 2px;"> {{ row.host }} </span> :
|
||||||
|
<span style="letter-spacing: 2px;"> {{ row.port }} </span>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="command" label="指令" show-overflow-tooltip>
|
<el-table-column
|
||||||
|
prop="command"
|
||||||
|
label="指令"
|
||||||
|
show-overflow-tooltip
|
||||||
|
min-width="150px"
|
||||||
|
>
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<span> {{ row.command }} </span>
|
<span> {{ row.command }} </span>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="status" label="执行结果" show-overflow-tooltip>
|
<el-table-column
|
||||||
|
prop="status"
|
||||||
|
label="执行结果"
|
||||||
|
show-overflow-tooltip
|
||||||
|
min-width="100px"
|
||||||
|
>
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-tag :color="getStatusType(row.status)">
|
<el-tag :color="getStatusType(row.status)">
|
||||||
<span style="color: rgb(54, 52, 52);">{{ row.status }}</span>
|
<span style="color: rgb(54, 52, 52);">{{ row.status }}</span>
|
||||||
</el-tag>
|
</el-tag>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="操作">
|
<el-table-column label="操作" fixed="right" width="90px">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-button
|
<el-button
|
||||||
v-if="!row.pendding"
|
v-if="!row.pending"
|
||||||
v-show="row.id !== 'own'"
|
v-show="row.id !== 'own'"
|
||||||
:loading="row.loading"
|
:loading="row.loading"
|
||||||
type="danger"
|
type="danger"
|
||||||
@ -81,10 +97,10 @@
|
|||||||
label-width="80px"
|
label-width="80px"
|
||||||
:show-message="false"
|
:show-message="false"
|
||||||
>
|
>
|
||||||
<el-form-item label="实例" prop="hosts">
|
<el-form-item label="实例" prop="hostIds">
|
||||||
<div class="select_host_wrap">
|
<div class="select_host_wrap">
|
||||||
<el-select
|
<el-select
|
||||||
v-model="formData.hosts"
|
v-model="formData.hostIds"
|
||||||
:teleported="false"
|
:teleported="false"
|
||||||
multiple
|
multiple
|
||||||
placeholder=""
|
placeholder=""
|
||||||
@ -105,7 +121,7 @@
|
|||||||
v-for="item in hasConfigHostList"
|
v-for="item in hasConfigHostList"
|
||||||
:key="item.id"
|
:key="item.id"
|
||||||
:label="item.name"
|
:label="item.name"
|
||||||
:value="item.host"
|
:value="item.id"
|
||||||
/>
|
/>
|
||||||
</el-select>
|
</el-select>
|
||||||
<!-- <el-button type="primary" class="btn" @click="selectAllHost">全选</el-button> -->
|
<!-- <el-button type="primary" class="btn" @click="selectAllHost">全选</el-button> -->
|
||||||
@ -174,7 +190,7 @@ const loading = ref(false)
|
|||||||
const formVisible = ref(false)
|
const formVisible = ref(false)
|
||||||
const socket = ref(null)
|
const socket = ref(null)
|
||||||
let recordList = ref([])
|
let recordList = ref([])
|
||||||
let penddingRecord = ref([])
|
let pendingRecord = ref([])
|
||||||
let checkAll = ref(false)
|
let checkAll = ref(false)
|
||||||
let indeterminate = ref(false)
|
let indeterminate = ref(false)
|
||||||
const updateFormRef = ref(null)
|
const updateFormRef = ref(null)
|
||||||
@ -182,7 +198,7 @@ let timeRemaining = ref(0)
|
|||||||
const isClient = ref(false)
|
const isClient = ref(false)
|
||||||
|
|
||||||
let formData = reactive({
|
let formData = reactive({
|
||||||
hosts: [],
|
hostIds: [],
|
||||||
command: '',
|
command: '',
|
||||||
timeout: 120
|
timeout: 120
|
||||||
})
|
})
|
||||||
@ -194,25 +210,25 @@ let isExecuting = computed(() => timeRemaining.value > 0)
|
|||||||
const hasConfigHostList = computed(() => hostList.value.filter(item => item.isConfig))
|
const hasConfigHostList = computed(() => hostList.value.filter(item => item.isConfig))
|
||||||
|
|
||||||
const tableData = computed(() => {
|
const tableData = computed(() => {
|
||||||
return penddingRecord.value.concat(recordList.value).map(item => {
|
return pendingRecord.value.concat(recordList.value).map(item => {
|
||||||
item.loading = false
|
item.loading = false
|
||||||
return item
|
return item
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
const expandRows = computed(() => {
|
const expandRows = computed(() => {
|
||||||
let rows = tableData.value.filter(item => item.pendding).map(item => item.id)
|
let rows = tableData.value.filter(item => item.pending).map(item => item.id)
|
||||||
return rows
|
return rows
|
||||||
})
|
})
|
||||||
|
|
||||||
const rules = computed(() => {
|
const rules = computed(() => {
|
||||||
return {
|
return {
|
||||||
hosts: { required: true, trigger: 'change' },
|
hostIds: { required: true, trigger: 'change' },
|
||||||
command: { required: true, trigger: 'change' },
|
command: { required: true, trigger: 'change' },
|
||||||
timeout: { required: true, type: 'number', trigger: 'change' }
|
timeout: { required: true, type: 'number', trigger: 'change' }
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
watch(() => formData.hosts, (val) => {
|
watch(() => formData.hostIds, (val) => {
|
||||||
if (val.length === 0) {
|
if (val.length === 0) {
|
||||||
checkAll.value = false
|
checkAll.value = false
|
||||||
indeterminate.value = false
|
indeterminate.value = false
|
||||||
@ -224,7 +240,7 @@ watch(() => formData.hosts, (val) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const createExecShell = (hosts = [], command = 'ls', timeout = 60) => {
|
const createExecShell = (hostIds = [], command = 'ls', timeout = 60) => {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
timeRemaining.value = Number(formData.timeout)
|
timeRemaining.value = Number(formData.timeout)
|
||||||
let timer = null
|
let timer = null
|
||||||
@ -240,17 +256,17 @@ const createExecShell = (hosts = [], command = 'ls', timeout = 60) => {
|
|||||||
console.log('onekey socket已连接:', socket.value.id)
|
console.log('onekey socket已连接:', socket.value.id)
|
||||||
|
|
||||||
socket.value.on('ready', () => {
|
socket.value.on('ready', () => {
|
||||||
penddingRecord.value = [] // 每轮执行前清空
|
pendingRecord.value = [] // 每轮执行前清空
|
||||||
})
|
})
|
||||||
|
|
||||||
socket.value.emit('create', { hosts, token: token.value, command, timeout })
|
socket.value.emit('create', { hostIds, token: token.value, command, timeout })
|
||||||
|
|
||||||
socket.value.on('output', (result) => {
|
socket.value.on('output', (result) => {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
if (Array.isArray(result) && result.length > 0) {
|
if (Array.isArray(result) && result.length > 0) {
|
||||||
// console.log('output', result)
|
// console.log('output', result)
|
||||||
result = result.map(item => ({ ...item, pendding: true }))
|
result = result.map(item => ({ ...item, pending: true }))
|
||||||
penddingRecord.value = result
|
pendingRecord.value = result
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
document.querySelectorAll('.detail_content_box').forEach(container => {
|
document.querySelectorAll('.detail_content_box').forEach(container => {
|
||||||
container.scrollTop = container.scrollHeight
|
container.scrollTop = container.scrollHeight
|
||||||
@ -267,8 +283,8 @@ const createExecShell = (hosts = [], command = 'ls', timeout = 60) => {
|
|||||||
})
|
})
|
||||||
if (Array.isArray(result) && result.length > 0) {
|
if (Array.isArray(result) && result.length > 0) {
|
||||||
// console.log('output', result)
|
// console.log('output', result)
|
||||||
result = result.map(item => ({ ...item, pendding: true }))
|
result = result.map(item => ({ ...item, pending: true }))
|
||||||
penddingRecord.value = result
|
pendingRecord.value = result
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
socket.value.on('create_fail', (reason) => {
|
socket.value.on('create_fail', (reason) => {
|
||||||
@ -320,9 +336,9 @@ onMounted(async () => {
|
|||||||
let selectAllHost = (val) => {
|
let selectAllHost = (val) => {
|
||||||
indeterminate.value = false
|
indeterminate.value = false
|
||||||
if (val) {
|
if (val) {
|
||||||
formData.hosts = hasConfigHostList.value.map(item => item.host)
|
formData.hostIds = hasConfigHostList.value.map(item => item.id)
|
||||||
} else {
|
} else {
|
||||||
formData.hosts = []
|
formData.hostIds = []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -366,16 +382,16 @@ let addOnekey = () => {
|
|||||||
function execOnekey() {
|
function execOnekey() {
|
||||||
updateFormRef.value.validate()
|
updateFormRef.value.validate()
|
||||||
.then(async () => {
|
.then(async () => {
|
||||||
let { hosts, command, timeout } = formData
|
let { hostIds, command, timeout } = formData
|
||||||
timeout = Number(timeout)
|
timeout = Number(timeout)
|
||||||
if (timeout < 1) {
|
if (timeout < 1) {
|
||||||
return $message.error('超时时间不能小于1秒')
|
return $message.error('超时时间不能小于1秒')
|
||||||
}
|
}
|
||||||
if (hosts.length === 0) {
|
if (hostIds.length === 0) {
|
||||||
return $message.error('请选择主机')
|
return $message.error('请选择主机')
|
||||||
}
|
}
|
||||||
await getOnekeyRecord() // 获取新纪录前会清空 penddingRecord,所以需要获取一次最新的list
|
await getOnekeyRecord() // 获取新纪录前会清空 pendingRecord,所以需要获取一次最新的list
|
||||||
createExecShell(hosts, command, timeout)
|
createExecShell(hostIds, command, timeout)
|
||||||
formVisible.value = false
|
formVisible.value = false
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@ -399,7 +415,7 @@ const handleRemoveAll = async () => {
|
|||||||
})
|
})
|
||||||
.then(async () => {
|
.then(async () => {
|
||||||
await $api.deleteOnekeyRecord('ALL')
|
await $api.deleteOnekeyRecord('ALL')
|
||||||
penddingRecord.value = []
|
pendingRecord.value = []
|
||||||
await getOnekeyRecord()
|
await getOnekeyRecord()
|
||||||
$message.success('success')
|
$message.success('success')
|
||||||
})
|
})
|
||||||
@ -407,18 +423,19 @@ const handleRemoveAll = async () => {
|
|||||||
|
|
||||||
onActivated(async () => {
|
onActivated(async () => {
|
||||||
await nextTick()
|
await nextTick()
|
||||||
const { host, execClientInstallScript } = route.query
|
const { hostIds, execClientInstallScript } = route.query
|
||||||
if (!host) return
|
if (!hostIds) return
|
||||||
if (execClientInstallScript === 'true') {
|
if (execClientInstallScript === 'true') {
|
||||||
let clientInstallScript = 'curl -o- https://mirror.ghproxy.com/https://raw.githubusercontent.com/chaos-zhu/easynode/main/client/easynode-client-install.sh | bash\n'
|
let clientInstallScript = 'curl -o- https://git.221022.xyz/https://raw.githubusercontent.com/chaos-zhu/easynode/main/client/easynode-client-install.sh | bash\n'
|
||||||
createExecShell(host.split(','), clientInstallScript, 300)
|
console.log(hostIds.split(','))
|
||||||
|
createExecShell(hostIds.split(','), clientInstallScript, 300)
|
||||||
// $messageBox.confirm(`准备安装客户端服务监控应用:${ host }`, 'Warning', {
|
// $messageBox.confirm(`准备安装客户端服务监控应用:${ host }`, 'Warning', {
|
||||||
// confirmButtonText: '确定',
|
// confirmButtonText: '确定',
|
||||||
// cancelButtonText: '取消',
|
// cancelButtonText: '取消',
|
||||||
// type: 'warning'
|
// type: 'warning'
|
||||||
// })
|
// })
|
||||||
// .then(async () => {
|
// .then(async () => {
|
||||||
// let clientInstallScript = 'curl -o- https://mirror.ghproxy.com/https://raw.githubusercontent.com/chaos-zhu/easynode/main/client/easynode-client-install.sh | bash\n'
|
// let clientInstallScript = 'curl -o- https://git.221022.xyz/https://raw.githubusercontent.com/chaos-zhu/easynode/main/client/easynode-client-install.sh | bash\n'
|
||||||
// createExecShell([host,], clientInstallScript, 300)
|
// createExecShell([host,], clientInstallScript, 300)
|
||||||
// })
|
// })
|
||||||
}
|
}
|
||||||
|
226
web/src/views/scripts/components/import-script.vue
Normal file
@ -0,0 +1,226 @@
|
|||||||
|
<template>
|
||||||
|
<el-dialog
|
||||||
|
v-model="visible"
|
||||||
|
width="600px"
|
||||||
|
top="225px"
|
||||||
|
modal-class="import_form_dialog"
|
||||||
|
append-to-body
|
||||||
|
title="导入脚本配置"
|
||||||
|
:close-on-click-modal="false"
|
||||||
|
>
|
||||||
|
<h2>选择要导入的文件类型</h2>
|
||||||
|
<ul class="type_list">
|
||||||
|
<li @click="handleFromJson">
|
||||||
|
<svg-icon name="icon-json" class="icon" />
|
||||||
|
<span class="from">JSON</span>
|
||||||
|
<input
|
||||||
|
ref="jsonInputRef"
|
||||||
|
type="file"
|
||||||
|
accept=".json"
|
||||||
|
multiple
|
||||||
|
name="jsonInput"
|
||||||
|
style="display: none;"
|
||||||
|
@change="handleJsonFile"
|
||||||
|
>
|
||||||
|
</li>
|
||||||
|
<li @click="manualInputVisible = true">
|
||||||
|
<svg-icon name="icon-bianji1" class="icon" />
|
||||||
|
<span class="from">手动输入</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
|
<el-dialog
|
||||||
|
v-model="manualInputVisible"
|
||||||
|
width="600px"
|
||||||
|
top="150px"
|
||||||
|
title="手动输入"
|
||||||
|
:close-on-click-modal="false"
|
||||||
|
append-to-body
|
||||||
|
>
|
||||||
|
<el-input
|
||||||
|
v-model="manualInput"
|
||||||
|
type="textarea"
|
||||||
|
:autosize="{ minRows: 15 }"
|
||||||
|
placeholder="请输入脚本内容,每行一条脚本"
|
||||||
|
/>
|
||||||
|
<template #footer>
|
||||||
|
<div class="manual-input-footer">
|
||||||
|
<el-button @click="manualInputVisible = false">取消</el-button>
|
||||||
|
<el-button type="primary" @click="handleManualImport">导入</el-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed, getCurrentInstance } from 'vue'
|
||||||
|
|
||||||
|
const { proxy: { $api, $message, $store } } = getCurrentInstance()
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
show: {
|
||||||
|
required: true,
|
||||||
|
type: Boolean
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['update:show', 'update-list',])
|
||||||
|
|
||||||
|
const jsonInputRef = ref(null)
|
||||||
|
const manualInputVisible = ref(false)
|
||||||
|
const manualInput = ref('')
|
||||||
|
|
||||||
|
let visible = computed({
|
||||||
|
get: () => props.show,
|
||||||
|
set: (newVal) => emit('update:show', newVal)
|
||||||
|
})
|
||||||
|
|
||||||
|
let scriptList = computed(() => $store.scriptList)
|
||||||
|
|
||||||
|
function handleFromJson() {
|
||||||
|
jsonInputRef.value.click()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleJsonFile = (event) => {
|
||||||
|
let files = event.target.files
|
||||||
|
let jsonFiles = Array.from(files).filter(file => file.name.endsWith('.json'))
|
||||||
|
if (jsonFiles.length === 0) return $message.warning('未选择有效的JSON文件')
|
||||||
|
|
||||||
|
let readerPromises = jsonFiles.map(file => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
let reader = new FileReader()
|
||||||
|
reader.onload = (e) => {
|
||||||
|
try {
|
||||||
|
let jsonContent = JSON.parse(e.target.result)
|
||||||
|
resolve(jsonContent)
|
||||||
|
} catch (error) {
|
||||||
|
reject(new Error(`Failed to parse JSON file: ${ file.name }`))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
reader.onerror = () => {
|
||||||
|
reject(new Error(`Failed to read file: ${ file.name }`))
|
||||||
|
}
|
||||||
|
reader.readAsText(file)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
Promise.all(readerPromises)
|
||||||
|
.then(async jsonContents => {
|
||||||
|
let formatJson = jsonContents.flat(Infinity)
|
||||||
|
let existCommand = scriptList.value.map(item => item.command)
|
||||||
|
let existId = scriptList.value.map(item => item.id)
|
||||||
|
formatJson = formatJson.filter(({ _id, command }) => {
|
||||||
|
return !existCommand.includes(command) && !existId.includes(_id)
|
||||||
|
})
|
||||||
|
if (formatJson.length === 0) return $message.warning('导入的脚本已存在')
|
||||||
|
try {
|
||||||
|
let { data: { len } } = await $api.importScript({ scripts: formatJson })
|
||||||
|
$message({ type: 'success', center: true, message: `成功导入脚本: ${ len }条` })
|
||||||
|
emit('update-list')
|
||||||
|
visible.value = false
|
||||||
|
} catch (error) {
|
||||||
|
$message.error('导入失败: ' + error.message)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
$message.error('导入失败: ' + error.message)
|
||||||
|
console.error('导入失败: ', error)
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
event.target.value = null
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleManualImport = async () => {
|
||||||
|
if (!manualInput.value.trim()) {
|
||||||
|
return $message.warning('请输入脚本内容')
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
let scripts = manualInput.value.split('\n')
|
||||||
|
scripts = [...new Set(scripts),]
|
||||||
|
.filter(line => line.trim())
|
||||||
|
.map((command) => ({ command: command.trim() }))
|
||||||
|
if (scripts.length === 0) {
|
||||||
|
return $message.warning('未检测到有效的脚本内容')
|
||||||
|
}
|
||||||
|
|
||||||
|
let existCommand = scriptList.value.map(item => item.command)
|
||||||
|
let filterScripts = scripts.filter(({ command }) => {
|
||||||
|
return !existCommand.includes(command)
|
||||||
|
})
|
||||||
|
let filterScriptsLen = filterScripts.length
|
||||||
|
if (filterScriptsLen !== 0 && filterScriptsLen < scripts.length) $message.warning('已过滤重复的脚本')
|
||||||
|
if (filterScriptsLen === 0) return $message.warning('导入的脚本已存在')
|
||||||
|
filterScripts = filterScripts.map((item, index) => {
|
||||||
|
return {
|
||||||
|
...item,
|
||||||
|
name: `${ item.command.slice(0, 15) || `脚本${ index + 1 }` }`,
|
||||||
|
index: scriptList.value.length + index + 1,
|
||||||
|
description: '手动输入'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
let { data: { len } } = await $api.importScript({ scripts: filterScripts })
|
||||||
|
$message({ type: 'success', center: true, message: `成功导入脚本: ${ len }条` })
|
||||||
|
emit('update-list')
|
||||||
|
manualInputVisible.value = false
|
||||||
|
visible.value = false
|
||||||
|
manualInput.value = ''
|
||||||
|
} catch (error) {
|
||||||
|
$message.error('导入失败: ' + error.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
.import_form_dialog {
|
||||||
|
h2 {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-align: center;
|
||||||
|
margin: 15px 0 25px 0;
|
||||||
|
}
|
||||||
|
.type_list {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
user-select: none;
|
||||||
|
li {
|
||||||
|
margin: 0 25px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 150px;
|
||||||
|
height: 150px;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 3px;
|
||||||
|
&:hover {
|
||||||
|
color: var(--el-menu-active-color);
|
||||||
|
}
|
||||||
|
.icon {
|
||||||
|
width: 35px;
|
||||||
|
height: 35px;
|
||||||
|
}
|
||||||
|
span {
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
.from {
|
||||||
|
font-size: 14px;
|
||||||
|
margin: 15px 0;
|
||||||
|
}
|
||||||
|
.type {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.manual-input-footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
</style>
|
@ -1,14 +1,57 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="scripts_container">
|
<div class="scripts_container">
|
||||||
<div class="header">
|
<div class="header">
|
||||||
<el-button type="primary" @click="addScript">添加脚本</el-button>
|
<el-input
|
||||||
|
v-model="searchKeyword"
|
||||||
|
placeholder="搜索名称、描述或指令内容"
|
||||||
|
class="search_input"
|
||||||
|
clearable
|
||||||
|
@input="handleSearch"
|
||||||
|
>
|
||||||
|
<template #prefix>
|
||||||
|
<el-icon><Search /></el-icon>
|
||||||
|
</template>
|
||||||
|
</el-input>
|
||||||
|
<el-button
|
||||||
|
v-show="selectScripts.length"
|
||||||
|
type="danger"
|
||||||
|
class="batch_remove_btn"
|
||||||
|
@click="handleBatchRemove"
|
||||||
|
>
|
||||||
|
批量删除
|
||||||
|
</el-button>
|
||||||
|
<el-button type="primary" class="add_script_btn" @click="addScript">添加脚本</el-button>
|
||||||
|
<PlusSupportTip>
|
||||||
|
<el-dropdown trigger="click" :disabled="!isPlusActive">
|
||||||
|
<el-button type="primary" class="group_action_btn" :disabled="!isPlusActive">
|
||||||
|
导入导出<el-icon class="el-icon--right"><arrow-down /></el-icon>
|
||||||
|
</el-button>
|
||||||
|
<template #dropdown>
|
||||||
|
<el-dropdown-menu>
|
||||||
|
<el-dropdown-item @click="importVisible = true">导入脚本</el-dropdown-item>
|
||||||
|
<el-dropdown-item @click="handleExport">导出脚本</el-dropdown-item>
|
||||||
|
</el-dropdown-menu>
|
||||||
|
</template>
|
||||||
|
</el-dropdown>
|
||||||
|
</PlusSupportTip>
|
||||||
</div>
|
</div>
|
||||||
<el-table v-loading="loading" :data="scriptList">
|
<el-table
|
||||||
|
v-loading="loading"
|
||||||
|
:data="paginatedFilteredList"
|
||||||
|
@selection-change="handleSelectionChange"
|
||||||
|
>
|
||||||
|
<el-table-column
|
||||||
|
type="selection"
|
||||||
|
width="55"
|
||||||
|
:selectable="(row) => {
|
||||||
|
return row.index !== '--' && row.index !== '-' && row.index !== undefined && row.index !== null
|
||||||
|
}"
|
||||||
|
/>
|
||||||
<el-table-column prop="index" label="序号" width="100px" />
|
<el-table-column prop="index" label="序号" width="100px" />
|
||||||
<el-table-column prop="name" label="名称" />
|
<el-table-column prop="name" label="名称" />
|
||||||
<el-table-column prop="description" label="描述" />
|
<el-table-column prop="description" label="描述" />
|
||||||
<el-table-column prop="command" label="指令内容" show-overflow-tooltip />
|
<el-table-column prop="command" label="指令内容" show-overflow-tooltip />
|
||||||
<el-table-column label="操作">
|
<el-table-column label="操作" fixed="right" width="160px">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<template v-if="row.index !== '--'">
|
<template v-if="row.index !== '--'">
|
||||||
<el-button type="primary" @click="handleChange(row)">修改</el-button>
|
<el-button type="primary" @click="handleChange(row)">修改</el-button>
|
||||||
@ -18,6 +61,19 @@
|
|||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
|
|
||||||
|
<div class="pagination-container">
|
||||||
|
<el-pagination
|
||||||
|
v-model:current-page="currentPage"
|
||||||
|
v-model:page-size="pageSize"
|
||||||
|
:page-sizes="[20, 50, 100]"
|
||||||
|
:total="scriptList.length"
|
||||||
|
layout="total, sizes, prev, pager, next"
|
||||||
|
@size-change="handleSizeChange"
|
||||||
|
@current-change="handleCurrentChange"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<el-dialog
|
<el-dialog
|
||||||
v-model="formVisible"
|
v-model="formVisible"
|
||||||
width="600px"
|
width="600px"
|
||||||
@ -78,17 +134,44 @@
|
|||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
|
|
||||||
|
<ImportScript
|
||||||
|
v-model:show="importVisible"
|
||||||
|
@update-list="() => $store.getScriptList()"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, reactive, computed, nextTick, getCurrentInstance } from 'vue'
|
import { ref, reactive, computed, nextTick, getCurrentInstance, h } from 'vue'
|
||||||
|
import ImportScript from './components/import-script.vue'
|
||||||
|
import PlusSupportTip from '@/components/common/PlusSupportTip.vue'
|
||||||
|
import { ArrowDown, Search } from '@element-plus/icons-vue'
|
||||||
|
import { exportFile } from '@/utils'
|
||||||
|
|
||||||
const { proxy: { $api, $message, $messageBox, $store } } = getCurrentInstance()
|
const { proxy: { $api, $message, $messageBox, $store, $tools } } = getCurrentInstance()
|
||||||
|
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const formVisible = ref(false)
|
const formVisible = ref(false)
|
||||||
let isModify = ref(false)
|
let isModify = ref(false)
|
||||||
|
const selectScripts = ref([])
|
||||||
|
const handleSelectionChange = (val) => {
|
||||||
|
selectScripts.value = val
|
||||||
|
}
|
||||||
|
const handleBatchRemove = () => {
|
||||||
|
if (!selectScripts.value.length) return $message.warning('请选择要批量删除的脚本')
|
||||||
|
let ids = selectScripts.value.map(item => item.id)
|
||||||
|
let names = selectScripts.value.map(item => item.name)
|
||||||
|
$messageBox.confirm(() => h('p', { style: 'line-height: 18px;' }, `确认删除\n${ names.join(', ') }吗?`), 'Warning', {
|
||||||
|
confirmButtonText: '确定',
|
||||||
|
cancelButtonText: '取消',
|
||||||
|
type: 'warning'
|
||||||
|
}).then(async () => {
|
||||||
|
await $api.batchRemoveScript({ ids })
|
||||||
|
await $store.getScriptList()
|
||||||
|
$message.success('success')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
let formData = reactive({
|
let formData = reactive({
|
||||||
name: '',
|
name: '',
|
||||||
@ -108,10 +191,12 @@ const rules = computed(() => {
|
|||||||
|
|
||||||
const updateFormRef = ref(null)
|
const updateFormRef = ref(null)
|
||||||
|
|
||||||
let scriptList = computed(() => $store.scriptList)
|
const scriptList = computed(() => $store.scriptList)
|
||||||
|
const isPlusActive = computed(() => $store.isPlusActive)
|
||||||
|
|
||||||
let addScript = () => {
|
let addScript = () => {
|
||||||
formData.id = null
|
formData.id = null
|
||||||
|
formData.index = scriptList.value.reduce((acc, cur) => Math.max(acc, Number(cur.index) || 0), 0) + 1
|
||||||
isModify.value = false
|
isModify.value = false
|
||||||
formVisible.value = true
|
formVisible.value = true
|
||||||
}
|
}
|
||||||
@ -154,6 +239,49 @@ const handleRemove = ({ id, name }) => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const importVisible = ref(false)
|
||||||
|
|
||||||
|
const handleExport = () => {
|
||||||
|
if (!scriptList.value.length) return $message.warning('暂无可导出的脚本')
|
||||||
|
const fileName = `easynode-scripts-${ $tools.formatTimestamp(Date.now(), 'time', '.') }.json`
|
||||||
|
exportFile(scriptList.value, fileName, 'application/json')
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentPage = ref(1)
|
||||||
|
const pageSize = ref(20)
|
||||||
|
|
||||||
|
const searchKeyword = ref('')
|
||||||
|
|
||||||
|
const filteredScriptList = computed(() => {
|
||||||
|
if (!searchKeyword.value) return scriptList.value
|
||||||
|
|
||||||
|
const keyword = searchKeyword.value.toLowerCase()
|
||||||
|
return scriptList.value.filter(item =>
|
||||||
|
(item.name && item.name.toLowerCase().includes(keyword)) ||
|
||||||
|
(item.description && item.description.toLowerCase().includes(keyword)) ||
|
||||||
|
(item.command && item.command.toLowerCase().includes(keyword))
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const paginatedFilteredList = computed(() => {
|
||||||
|
const start = (currentPage.value - 1) * pageSize.value
|
||||||
|
const end = start + pageSize.value
|
||||||
|
return filteredScriptList.value.slice(start, end)
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleSizeChange = (val) => {
|
||||||
|
pageSize.value = val
|
||||||
|
currentPage.value = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCurrentChange = (val) => {
|
||||||
|
currentPage.value = val
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSearch = () => {
|
||||||
|
currentPage.value = 1
|
||||||
|
}
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
@ -164,6 +292,13 @@ const handleRemove = ({ id, name }) => {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: end;
|
justify-content: end;
|
||||||
|
.add_script_btn {
|
||||||
|
margin: 0 10px;
|
||||||
|
}
|
||||||
|
.search_input {
|
||||||
|
width: 300px;
|
||||||
|
margin-right: auto;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -175,4 +310,10 @@ const handleRemove = ({ id, name }) => {
|
|||||||
color: #87cf63;
|
color: #87cf63;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.pagination-container {
|
||||||
|
margin-top: 20px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
@ -1,392 +0,0 @@
|
|||||||
<template>
|
|
||||||
<el-card shadow="always" class="host-card">
|
|
||||||
<div class="host-state">
|
|
||||||
<span v-if="isError" class="offline">未连接</span>
|
|
||||||
<span v-else class="online">已连接</span>
|
|
||||||
<!-- {{ ping }} -->
|
|
||||||
</div>
|
|
||||||
<div class="info">
|
|
||||||
<div class="weizhi field">
|
|
||||||
<el-popover placement="bottom-start" :width="200" trigger="hover">
|
|
||||||
<template #reference>
|
|
||||||
<svg-icon name="icon-fuwuqi" class="svg-icon" />
|
|
||||||
</template>
|
|
||||||
<div class="field-detail">
|
|
||||||
<h2>系统</h2>
|
|
||||||
<h3><span>名称:</span> {{ osInfo.hostname }}</h3>
|
|
||||||
<h3><span>类型:</span> {{ osInfo.type }}</h3>
|
|
||||||
<h3><span>架构:</span> {{ osInfo.arch }}</h3>
|
|
||||||
<h3><span>平台:</span> {{ osInfo.platform }}</h3>
|
|
||||||
<h3><span>版本:</span> {{ osInfo.release }}</h3>
|
|
||||||
<h3><span>开机时长:</span> {{ $tools.formatTime(osInfo.uptime) }}</h3>
|
|
||||||
<h3><span>到期时间:</span> {{ expiredTime }}</h3>
|
|
||||||
<h3><span>本地IP:</span> {{ osInfo.ip }}</h3>
|
|
||||||
<h3><span>连接数:</span> {{ openedCount || 0 }}</h3>
|
|
||||||
</div>
|
|
||||||
</el-popover>
|
|
||||||
<div class="fields">
|
|
||||||
<span class="name" @click="handleUpdate">
|
|
||||||
{{ name || '--' }}
|
|
||||||
<svg-icon name="icon-xiugai" class="svg-icon" />
|
|
||||||
</span>
|
|
||||||
<span>{{ osInfo?.type || '--' }}</span>
|
|
||||||
<!-- <span>{{ osInfo?.hostname || '--' }}</span> -->
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="weizhi field">
|
|
||||||
<el-popover placement="bottom-start" :width="200" trigger="hover">
|
|
||||||
<template #reference>
|
|
||||||
<svg-icon name="icon-position" class="svg-icon" />
|
|
||||||
</template>
|
|
||||||
<div class="field-detail">
|
|
||||||
<h2>位置信息</h2>
|
|
||||||
<h3><span>详细:</span> {{ ipInfo.country || '--' }} {{ ipInfo.regionName }}</h3>
|
|
||||||
<!-- <h3><span>详细:</span> {{ ipInfo.country || '--' }} {{ ipInfo.regionName }} {{ ipInfo.city }}</h3> -->
|
|
||||||
<!-- <h3><span>IP:</span> {{ hostIp }}</h3> -->
|
|
||||||
<h3><span>提供商:</span> {{ ipInfo.isp || '--' }}</h3>
|
|
||||||
<h3><span>线路:</span> {{ ipInfo.as || '--' }}</h3>
|
|
||||||
</div>
|
|
||||||
</el-popover>
|
|
||||||
<div class="fields">
|
|
||||||
<span>{{ `${ipInfo?.country || '--'} ${ipInfo?.regionName || '--'}` }}</span>
|
|
||||||
<!-- <span>{{ `${ipInfo?.country || '--' } ${ipInfo?.regionName || '--'} ${ipInfo?.city || '--'}` }}</span> -->
|
|
||||||
<span>{{ hostIp }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="cpu field">
|
|
||||||
<el-popover placement="bottom-start" :width="200" trigger="hover">
|
|
||||||
<template #reference>
|
|
||||||
<svg-icon name="icon-xingzhuang" class="svg-icon" />
|
|
||||||
</template>
|
|
||||||
<div class="field-detail">
|
|
||||||
<h2>CPU</h2>
|
|
||||||
<h3><span>利用率:</span> {{ cpuInfo.cpuUsage }}%</h3>
|
|
||||||
<h3><span>物理核心:</span> {{ cpuInfo.cpuCount }}</h3>
|
|
||||||
<h3><span>型号:</span> {{ cpuInfo.cpuModel }}</h3>
|
|
||||||
</div>
|
|
||||||
</el-popover>
|
|
||||||
<div class="fields">
|
|
||||||
<span :style="{ color: setColor(cpuInfo.cpuUsage) }">{{ cpuInfo.cpuUsage || '0' || '--' }}%</span>
|
|
||||||
<span>{{ cpuInfo.cpuCount || '--' }} 核心</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="ram field">
|
|
||||||
<el-popover placement="bottom-start" :width="200" trigger="hover">
|
|
||||||
<template #reference>
|
|
||||||
<svg-icon name="icon-neicun1" class="svg-icon" />
|
|
||||||
</template>
|
|
||||||
<div class="field-detail">
|
|
||||||
<h2>内存</h2>
|
|
||||||
<h3><span>总大小:</span> {{ $tools.toFixed(memInfo.totalMemMb / 1024) }} GB</h3>
|
|
||||||
<h3><span>已使用:</span> {{ $tools.toFixed(memInfo.usedMemMb / 1024) }} GB</h3>
|
|
||||||
<h3><span>占比:</span> {{ $tools.toFixed(memInfo.usedMemPercentage) }}%</h3>
|
|
||||||
<h3><span>空闲:</span> {{ $tools.toFixed(memInfo.freeMemMb / 1024) }} GB</h3>
|
|
||||||
</div>
|
|
||||||
</el-popover>
|
|
||||||
<div class="fields">
|
|
||||||
<span :style="{ color: setColor(memInfo.usedMemPercentage) }">{{ $tools.toFixed(memInfo.usedMemPercentage)
|
|
||||||
}}%</span>
|
|
||||||
<span>{{ $tools.toFixed(memInfo.usedMemMb / 1024) }} | {{ $tools.toFixed(memInfo.totalMemMb / 1024) }} GB</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="yingpan field">
|
|
||||||
<el-popover placement="bottom-start" :width="200" trigger="hover">
|
|
||||||
<template #reference>
|
|
||||||
<svg-icon name="icon-xingzhuang1" class="svg-icon" />
|
|
||||||
</template>
|
|
||||||
<div class="field-detail">
|
|
||||||
<h2>存储</h2>
|
|
||||||
<h3><span>总空间:</span> {{ driveInfo.totalGb || '--' }} GB</h3>
|
|
||||||
<h3><span>已使用:</span> {{ driveInfo.usedGb || '--' }} GB</h3>
|
|
||||||
<h3><span>剩余:</span> {{ driveInfo.freeGb || '--' }} GB</h3>
|
|
||||||
<h3><span>占比:</span> {{ driveInfo.usedPercentage || '--' }}%</h3>
|
|
||||||
</div>
|
|
||||||
</el-popover>
|
|
||||||
<div class="fields">
|
|
||||||
<span :style="{ color: setColor(driveInfo.usedPercentage) }">{{ driveInfo.usedPercentage || '--' }}%</span>
|
|
||||||
<span>{{ driveInfo.usedGb || '--' }} | {{ driveInfo.totalGb || '--' }} GB</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="wangluo field">
|
|
||||||
<el-popover placement="bottom-start" :width="200" trigger="hover">
|
|
||||||
<template #reference>
|
|
||||||
<svg-icon name="icon-wangluo1" class="svg-icon" />
|
|
||||||
</template>
|
|
||||||
<div class="field-detail">
|
|
||||||
<h2>网卡</h2>
|
|
||||||
<!-- <h3>
|
|
||||||
<span>实时流量</span>
|
|
||||||
<div>↑ {{ $tools.toFixed(netstatInfo.netTotal?.outputMb) || 0 }}MB / s</div>
|
|
||||||
<div>↓ {{ $tools.toFixed(netstatInfo.netTotal?.inputMb) || 0 }}MB / s</div>
|
|
||||||
</h3> -->
|
|
||||||
<div v-for="(value, key) in netstatInfo.netCards" :key="key" style="display: flex; flex-direction: column;">
|
|
||||||
<h3>
|
|
||||||
<span>{{ key }}</span>
|
|
||||||
<div>↑ {{ $tools.formatNetSpeed(value?.outputMb) || 0 }}</div>
|
|
||||||
<div>↓ {{ $tools.formatNetSpeed(value?.inputMb) || 0 }}</div>
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</el-popover>
|
|
||||||
<div class="fields">
|
|
||||||
<span>↑ {{ $tools.formatNetSpeed(netstatInfo.netTotal?.outputMb) || 0 }}</span>
|
|
||||||
<span>↓ {{ $tools.formatNetSpeed(netstatInfo.netTotal?.inputMb) || 0 }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="field actions">
|
|
||||||
<svg-icon
|
|
||||||
name="icon-zhongduanguanli24"
|
|
||||||
title="终端"
|
|
||||||
class="actions-icon"
|
|
||||||
@click="handleSSH"
|
|
||||||
/>
|
|
||||||
<svg-icon
|
|
||||||
v-show="consoleUrl"
|
|
||||||
name="icon-a-zu391"
|
|
||||||
title="服务商控制台"
|
|
||||||
class="actions-icon"
|
|
||||||
@click="handleToConsole"
|
|
||||||
/>
|
|
||||||
<svg-icon
|
|
||||||
name="icon-bianji1"
|
|
||||||
title="编辑"
|
|
||||||
class="actions-icon"
|
|
||||||
@click="handleUpdate"
|
|
||||||
/>
|
|
||||||
<svg-icon
|
|
||||||
name="icon-shanchu1"
|
|
||||||
title="删除"
|
|
||||||
class="actions-icon"
|
|
||||||
@click="handleRemoveHost"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</el-card>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { computed, getCurrentInstance } from 'vue'
|
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
|
||||||
|
|
||||||
const { proxy: { $api, $router, $tools } } = getCurrentInstance()
|
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
hostInfo: {
|
|
||||||
required: true,
|
|
||||||
type: Object
|
|
||||||
},
|
|
||||||
hiddenIp: {
|
|
||||||
required: true,
|
|
||||||
type: [Number, Boolean,]
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const emit = defineEmits(['update-list', 'update-host',])
|
|
||||||
|
|
||||||
const hostIp = computed(() => {
|
|
||||||
let ip = hostInfo.value?.ipInfo?.query || hostInfo.value?.host || '--'
|
|
||||||
try {
|
|
||||||
let formatIp = ip.replace(/\d/g, '*').split('.').map((item) => item.padStart(3, '*')).join('.')
|
|
||||||
return props.hiddenIp ? formatIp : ip
|
|
||||||
} catch (error) {
|
|
||||||
return ip
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const hostInfo = computed(() => props.hostInfo || {})
|
|
||||||
const host = computed(() => hostInfo.value?.host)
|
|
||||||
const name = computed(() => hostInfo.value?.name)
|
|
||||||
const ping = computed(() => hostInfo.value?.ping || '')
|
|
||||||
const expiredTime = computed(() => $tools.formatTimestamp(hostInfo.value?.expired, 'date'))
|
|
||||||
const consoleUrl = computed(() => hostInfo.value?.consoleUrl)
|
|
||||||
const ipInfo = computed(() => hostInfo.value?.ipInfo || {})
|
|
||||||
const isError = computed(() => !Boolean(hostInfo.value?.osInfo))
|
|
||||||
const cpuInfo = computed(() => hostInfo.value?.cpuInfo || {})
|
|
||||||
const memInfo = computed(() => hostInfo.value?.memInfo || {})
|
|
||||||
const osInfo = computed(() => hostInfo.value?.osInfo || {})
|
|
||||||
const driveInfo = computed(() => hostInfo.value?.driveInfo || {})
|
|
||||||
const netstatInfo = computed(() => {
|
|
||||||
let { total: netTotal, ...netCards } = hostInfo.value?.netstatInfo || {}
|
|
||||||
return { netTotal, netCards: netCards || {} }
|
|
||||||
})
|
|
||||||
const openedCount = computed(() => hostInfo.value?.openedCount || 0)
|
|
||||||
|
|
||||||
const setColor = (num) => {
|
|
||||||
num = Number(num)
|
|
||||||
return num ? (num < 80 ? '#595959' : (num >= 80 && num < 90 ? '#FF6600' : '#FF0000')) : '#595959'
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleUpdate = () => {
|
|
||||||
emit('update-host', hostInfo.value)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleToConsole = () => {
|
|
||||||
window.open(consoleUrl.value)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleSSH = async () => {
|
|
||||||
if(!hostInfo.value?.isConfig) {
|
|
||||||
ElMessage({
|
|
||||||
message: '请先配置SSH连接信息',
|
|
||||||
type: 'warning',
|
|
||||||
center: true
|
|
||||||
})
|
|
||||||
handleUpdate()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
$router.push({ path: '/terminal', query: { host: host.value } })
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleRemoveHost = async () => {
|
|
||||||
ElMessageBox.confirm('确认删除实例', 'Warning', {
|
|
||||||
confirmButtonText: '确定',
|
|
||||||
cancelButtonText: '取消',
|
|
||||||
type: 'warning'
|
|
||||||
}).then(async () => {
|
|
||||||
let { data } = await $api.removeHost({ host: host.value })
|
|
||||||
ElMessage({
|
|
||||||
message: data,
|
|
||||||
type: 'success',
|
|
||||||
center: true
|
|
||||||
})
|
|
||||||
emit('update-list')
|
|
||||||
})
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
|
||||||
.host-card {
|
|
||||||
margin: 0px 30px 20px;
|
|
||||||
transition: all 0.5s;
|
|
||||||
position: relative;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
box-shadow: 0px 0px 15px rgba(6, 30, 37, 0.5);
|
|
||||||
}
|
|
||||||
|
|
||||||
.host-state {
|
|
||||||
position: absolute;
|
|
||||||
top: 0px;
|
|
||||||
left: 0px;
|
|
||||||
|
|
||||||
span {
|
|
||||||
font-size: 10px;
|
|
||||||
// transform: rotate(-45deg);
|
|
||||||
// transform: scale(0.95);
|
|
||||||
display: inline-block;
|
|
||||||
padding: 3px 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.online {
|
|
||||||
color: #009933;
|
|
||||||
background-color: #e8fff3;
|
|
||||||
}
|
|
||||||
|
|
||||||
.offline {
|
|
||||||
color: #FF0033;
|
|
||||||
background-color: #fff5f8;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.info {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
height: 50px;
|
|
||||||
|
|
||||||
&>div {
|
|
||||||
flex: 1
|
|
||||||
}
|
|
||||||
|
|
||||||
.field {
|
|
||||||
height: 100%;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
|
|
||||||
.svg-icon {
|
|
||||||
width: 25px;
|
|
||||||
height: 25px;
|
|
||||||
color: #1989fa;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fields {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
|
|
||||||
// justify-content: center;
|
|
||||||
span {
|
|
||||||
padding: 3px 0;
|
|
||||||
margin-left: 5px;
|
|
||||||
font-weight: 600;
|
|
||||||
font-size: 13px;
|
|
||||||
color: #595959;
|
|
||||||
}
|
|
||||||
|
|
||||||
.name {
|
|
||||||
display: inline-block;
|
|
||||||
height: 19px;
|
|
||||||
cursor: pointer;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
text-decoration-line: underline;
|
|
||||||
text-decoration-color: #1989fa;
|
|
||||||
|
|
||||||
.svg-icon {
|
|
||||||
display: inline-block;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.svg-icon {
|
|
||||||
display: none;
|
|
||||||
width: 13px;
|
|
||||||
height: 13px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.actions {
|
|
||||||
.actions-icon {
|
|
||||||
margin: 0 10px;
|
|
||||||
width: 16px;
|
|
||||||
height: 16px;
|
|
||||||
color: #1989fa;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.web-ssh {
|
|
||||||
|
|
||||||
// ::v-deep has been deprecated. Use :deep(<inner-selector>) instead.
|
|
||||||
:deep(.el-dropdown__caret-button) {
|
|
||||||
margin-left: -5px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
||||||
<style lang="scss">
|
|
||||||
.field-detail {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
|
|
||||||
h2 {
|
|
||||||
font-weight: 600;
|
|
||||||
font-size: 16px;
|
|
||||||
margin: 0px 0 8px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
h3 {
|
|
||||||
span {
|
|
||||||
font-weight: 600;
|
|
||||||
color: #797979;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
span {
|
|
||||||
display: inline-block;
|
|
||||||
margin: 4px 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
@ -30,7 +30,6 @@
|
|||||||
label-width="100px"
|
label-width="100px"
|
||||||
:show-message="false"
|
:show-message="false"
|
||||||
>
|
>
|
||||||
<transition-group name="list" mode="out-in" tag="div">
|
|
||||||
<el-form-item key="group" label="分组" prop="group">
|
<el-form-item key="group" label="分组" prop="group">
|
||||||
<el-select
|
<el-select
|
||||||
v-model="hostForm.group"
|
v-model="hostForm.group"
|
||||||
@ -114,20 +113,18 @@
|
|||||||
<el-button type="primary" size="small" @click="handleClickUploadBtn">
|
<el-button type="primary" size="small" @click="handleClickUploadBtn">
|
||||||
本地私钥...
|
本地私钥...
|
||||||
</el-button>
|
</el-button>
|
||||||
<!-- <el-button type="primary" size="small" @click="handleClickUploadBtn">
|
|
||||||
从凭据导入...
|
|
||||||
</el-button> -->
|
|
||||||
<input
|
<input
|
||||||
ref="privateKeyRef"
|
ref="privateKeyRef"
|
||||||
type="file"
|
type="file"
|
||||||
name="privateKey"
|
name="privateKey"
|
||||||
style="display: none;"
|
style="display: none;"
|
||||||
|
autocomplete="off"
|
||||||
@change="handleSelectPrivateKeyFile"
|
@change="handleSelectPrivateKeyFile"
|
||||||
>
|
>
|
||||||
<el-input
|
<el-input
|
||||||
v-model="hostForm.privateKey"
|
v-model="hostForm.privateKey"
|
||||||
type="textarea"
|
type="textarea"
|
||||||
:rows="5"
|
:rows="3"
|
||||||
clearable
|
clearable
|
||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
style="margin-top: 5px;"
|
style="margin-top: 5px;"
|
||||||
@ -144,7 +141,7 @@
|
|||||||
v-model.trim="hostForm.password"
|
v-model.trim="hostForm.password"
|
||||||
type="password"
|
type="password"
|
||||||
placeholder=""
|
placeholder=""
|
||||||
autocomplete="off"
|
autocomplete="new-password"
|
||||||
clearable
|
clearable
|
||||||
show-password
|
show-password
|
||||||
/>
|
/>
|
||||||
@ -155,9 +152,9 @@
|
|||||||
prop="credential"
|
prop="credential"
|
||||||
label="凭据"
|
label="凭据"
|
||||||
>
|
>
|
||||||
<el-select v-model="hostForm.credential" class="credential_select" placeholder="">
|
<el-select v-model="hostForm.credential" placeholder="">
|
||||||
<template #empty>
|
<template #empty>
|
||||||
<div class="empty_credential">
|
<div class="empty_text">
|
||||||
<span>无凭据数据,</span>
|
<span>无凭据数据,</span>
|
||||||
<el-button type="primary" link @click="toCredentials">
|
<el-button type="primary" link @click="toCredentials">
|
||||||
去添加
|
去添加
|
||||||
@ -170,7 +167,7 @@
|
|||||||
:label="item.name"
|
:label="item.name"
|
||||||
:value="item.id"
|
:value="item.id"
|
||||||
>
|
>
|
||||||
<div class="auth_type_wrap">
|
<div class="select_warp">
|
||||||
<span>{{ item.name }}</span>
|
<span>{{ item.name }}</span>
|
||||||
<span class="auth_type_text">
|
<span class="auth_type_text">
|
||||||
{{ item.authType === 'privateKey' ? '密钥' : '密码' }}
|
{{ item.authType === 'privateKey' ? '密钥' : '密码' }}
|
||||||
@ -179,11 +176,41 @@
|
|||||||
</el-option>
|
</el-option>
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item key="command" prop="command" label="执行指令">
|
<el-form-item
|
||||||
|
key="jumpHosts"
|
||||||
|
prop="jumpHosts"
|
||||||
|
label="跳板机"
|
||||||
|
>
|
||||||
|
<PlusSupportTip>
|
||||||
|
<el-select
|
||||||
|
v-model="hostForm.jumpHosts"
|
||||||
|
placeholder="支持多选,跳板机连接顺序从前到后"
|
||||||
|
multiple
|
||||||
|
:disabled="!isPlusActive"
|
||||||
|
>
|
||||||
|
<template #empty>
|
||||||
|
<div class="empty_text">
|
||||||
|
<span>无可用跳板机器</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<el-option
|
||||||
|
v-for="item in confHostList"
|
||||||
|
:key="item.id"
|
||||||
|
:label="item.name"
|
||||||
|
:value="item.id"
|
||||||
|
>
|
||||||
|
<div class="select_wrap">
|
||||||
|
<span>{{ item.name }}</span>
|
||||||
|
</div>
|
||||||
|
</el-option>
|
||||||
|
</el-select>
|
||||||
|
</PlusSupportTip>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item key="command" prop="command" label="登录指令">
|
||||||
<el-input
|
<el-input
|
||||||
v-model="hostForm.command"
|
v-model="hostForm.command"
|
||||||
type="textarea"
|
type="textarea"
|
||||||
:rows="5"
|
:rows="3"
|
||||||
clearable
|
clearable
|
||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
placeholder="连接服务器后自动执行的指令(例如: sudo -i)"
|
placeholder="连接服务器后自动执行的指令(例如: sudo -i)"
|
||||||
@ -194,6 +221,7 @@
|
|||||||
<el-date-picker
|
<el-date-picker
|
||||||
v-model="hostForm.expired"
|
v-model="hostForm.expired"
|
||||||
type="date"
|
type="date"
|
||||||
|
:editable="false"
|
||||||
style="width: 100%;"
|
style="width: 100%;"
|
||||||
value-format="x"
|
value-format="x"
|
||||||
placeholder="实例到期时间"
|
placeholder="实例到期时间"
|
||||||
@ -218,6 +246,14 @@
|
|||||||
@keyup.enter="handleSave"
|
@keyup.enter="handleSave"
|
||||||
/>
|
/>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
<el-form-item key="clientPort" label="客户端端口" prop="clientPort">
|
||||||
|
<el-input
|
||||||
|
v-model.trim.number="hostForm.clientPort"
|
||||||
|
clearable
|
||||||
|
placeholder="客户端上报信息端口(默认22022)"
|
||||||
|
autocomplete="off"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
<el-form-item
|
<el-form-item
|
||||||
v-if="!isBatchModify"
|
v-if="!isBatchModify"
|
||||||
key="index"
|
key="index"
|
||||||
@ -241,12 +277,14 @@
|
|||||||
placeholder="简单记录实例用途"
|
placeholder="简单记录实例用途"
|
||||||
/>
|
/>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</transition-group>
|
|
||||||
</el-form>
|
</el-form>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<span class="dialog-footer">
|
<span class="dialog-footer">
|
||||||
<el-button @click="visible = false">关闭</el-button>
|
<el-button @click="visible = false">关闭</el-button>
|
||||||
<el-button type="primary" @click="handleSave">确认</el-button>
|
<el-button v-if="!isBatchModify" type="primary" @click="handleSave">确认</el-button>
|
||||||
|
<PlusSupportTip v-else>
|
||||||
|
<el-button type="primary" :disabled="!isPlusActive" @click="handleSave">确认</el-button>
|
||||||
|
</PlusSupportTip>
|
||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
@ -254,6 +292,7 @@
|
|||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, getCurrentInstance, nextTick } from 'vue'
|
import { ref, computed, getCurrentInstance, nextTick } from 'vue'
|
||||||
|
import PlusSupportTip from '@/components/common/PlusSupportTip.vue'
|
||||||
import { RSAEncrypt, AESEncrypt, randomStr } from '@utils/index.js'
|
import { RSAEncrypt, AESEncrypt, randomStr } from '@utils/index.js'
|
||||||
|
|
||||||
const { proxy: { $api, $router, $message, $store } } = getCurrentInstance()
|
const { proxy: { $api, $router, $message, $store } } = getCurrentInstance()
|
||||||
@ -287,21 +326,22 @@ const formField = {
|
|||||||
host: '',
|
host: '',
|
||||||
port: 22,
|
port: 22,
|
||||||
username: 'root',
|
username: 'root',
|
||||||
authType: 'privateKey',
|
authType: 'privateKey', // privateKey, password, credential
|
||||||
password: '',
|
password: '',
|
||||||
privateKey: '',
|
privateKey: '',
|
||||||
credential: '', // credentials -> _id
|
credential: '', // credentials -> _id
|
||||||
|
clientPort: 22022,
|
||||||
index: 0,
|
index: 0,
|
||||||
expired: null,
|
expired: null,
|
||||||
expiredNotify: false,
|
expiredNotify: false,
|
||||||
consoleUrl: '',
|
consoleUrl: '',
|
||||||
remark: '',
|
remark: '',
|
||||||
command: ''
|
command: '',
|
||||||
|
jumpHosts: []
|
||||||
}
|
}
|
||||||
|
|
||||||
let hostForm = ref({ ...formField })
|
let hostForm = ref({ ...formField })
|
||||||
let privateKeyRef = ref(null)
|
let privateKeyRef = ref(null)
|
||||||
let oldHost = ref('')
|
|
||||||
let formRef = ref(null)
|
let formRef = ref(null)
|
||||||
|
|
||||||
let isBatchModify = computed(() => props.isBatchModify)
|
let isBatchModify = computed(() => props.isBatchModify)
|
||||||
@ -313,6 +353,8 @@ const rules = computed(() => {
|
|||||||
name: { required: !isBatchModify.value, message: '输入实例别名', trigger: 'change' },
|
name: { required: !isBatchModify.value, message: '输入实例别名', trigger: 'change' },
|
||||||
host: { required: !isBatchModify.value, message: '输入IP/域名', trigger: 'change' },
|
host: { required: !isBatchModify.value, message: '输入IP/域名', trigger: 'change' },
|
||||||
port: { required: !isBatchModify.value, type: 'number', message: '输入ssh端口', trigger: 'change' },
|
port: { required: !isBatchModify.value, type: 'number', message: '输入ssh端口', trigger: 'change' },
|
||||||
|
clientPort: { required: false, type: 'number' },
|
||||||
|
jumpHosts: { required: false, type: 'array' },
|
||||||
index: { required: !isBatchModify.value, type: 'number', message: '输入数字', trigger: 'change' },
|
index: { required: !isBatchModify.value, type: 'number', message: '输入数字', trigger: 'change' },
|
||||||
// password: [{ required: hostForm.authType === 'password', trigger: 'change' },],
|
// password: [{ required: hostForm.authType === 'password', trigger: 'change' },],
|
||||||
// privateKey: [{ required: hostForm.authType === 'privateKey', trigger: 'change' },],
|
// privateKey: [{ required: hostForm.authType === 'privateKey', trigger: 'change' },],
|
||||||
@ -322,6 +364,7 @@ const rules = computed(() => {
|
|||||||
remark: { required: false }
|
remark: { required: false }
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
const isPlusActive = computed(() => $store.isPlusActive)
|
||||||
|
|
||||||
const visible = computed({
|
const visible = computed({
|
||||||
get: () => props.show,
|
get: () => props.show,
|
||||||
@ -334,29 +377,38 @@ const title = computed(() => {
|
|||||||
|
|
||||||
let groupList = computed(() => $store.groupList)
|
let groupList = computed(() => $store.groupList)
|
||||||
let sshList = computed(() => $store.sshList)
|
let sshList = computed(() => $store.sshList)
|
||||||
|
let hostList = computed(() => $store.hostList)
|
||||||
|
let confHostList = computed(() => {
|
||||||
|
return hostList.value?.filter(item => item.isConfig)
|
||||||
|
})
|
||||||
|
|
||||||
const setDefaultData = () => {
|
const setDefaultData = () => {
|
||||||
if (!defaultData.value) return
|
if (!defaultData.value) return
|
||||||
// eslint-disable-next-line no-unused-vars
|
// eslint-disable-next-line no-unused-vars
|
||||||
let { host, monitorData, ...rest } = defaultData.value
|
let { id, ...rest } = defaultData.value
|
||||||
oldHost.value = host
|
for (let [key,] of Object.entries(hostForm.value)) {
|
||||||
Object.assign(hostForm.value, { host, ...rest })
|
if (rest[key] !== undefined) hostForm.value[key] = rest[key]
|
||||||
|
}
|
||||||
|
hostForm.value.id = id
|
||||||
}
|
}
|
||||||
|
|
||||||
const setBatchDefaultData = () => {
|
const setBatchDefaultData = () => {
|
||||||
if (!isBatchModify.value) return
|
if (!isBatchModify.value) return
|
||||||
Object.assign(hostForm.value, { ...formField }, { group: '', port: '', username: '', authType: '' })
|
Object.assign(hostForm.value, { ...formField }, { group: '', port: '', username: '', authType: '', clientPort: '', jumpHosts: [] })
|
||||||
}
|
}
|
||||||
const handleOpen = async () => {
|
const handleOpen = async () => {
|
||||||
setDefaultData()
|
if (isBatchModify.value) {
|
||||||
setBatchDefaultData()
|
setBatchDefaultData()
|
||||||
|
} else {
|
||||||
|
setDefaultData()
|
||||||
|
}
|
||||||
await nextTick()
|
await nextTick()
|
||||||
formRef.value.clearValidate()
|
formRef.value.clearValidate()
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleClosed = async () => {
|
const handleClosed = async () => {
|
||||||
emit('closed')
|
emit('closed')
|
||||||
Object.assign(hostForm.value, { ...formField })
|
hostForm.value = { ...formField }
|
||||||
await nextTick()
|
await nextTick()
|
||||||
formRef.value.resetFields()
|
formRef.value.resetFields()
|
||||||
}
|
}
|
||||||
@ -403,30 +455,39 @@ const handleSave = () => {
|
|||||||
let formData = { ...hostForm.value }
|
let formData = { ...hostForm.value }
|
||||||
if (isBatchModify.value) {
|
if (isBatchModify.value) {
|
||||||
// eslint-disable-next-line
|
// eslint-disable-next-line
|
||||||
let updateFileData = Object.fromEntries(Object.entries(formData).filter(([key, value]) => Boolean(value))) // 剔除掉未更改的值
|
let updateFieldData = Object.fromEntries(Object.entries(formData).filter(([key, value]) => {
|
||||||
if (Object.keys(updateFileData).length === 0) return $message.warning('没有任何修改')
|
if (Array.isArray(value)) return value.length > 0
|
||||||
// console.log(updateFileData)
|
return Boolean(value)
|
||||||
let newHosts = batchHosts.value
|
})) // 剔除掉未更改的值
|
||||||
.map(item => ({ ...item, ...updateFileData }))
|
let { authType = '' } = updateFieldData
|
||||||
.map(item => {
|
if (authType && !updateFieldData[authType]) {
|
||||||
const { authType } = item
|
delete updateFieldData.authType
|
||||||
|
delete updateFieldData.privateKey
|
||||||
|
delete updateFieldData.password
|
||||||
|
delete updateFieldData.credential
|
||||||
|
}
|
||||||
|
if (Object.keys(updateFieldData).length === 0) return $message.warning('没有任何修改')
|
||||||
|
console.log(updateFieldData)
|
||||||
|
if (updateFieldData.authType) {
|
||||||
let tempKey = randomStr(16)
|
let tempKey = randomStr(16)
|
||||||
if (item[authType]) item[authType] = AESEncrypt(item[authType], tempKey)
|
updateFieldData[authType] = AESEncrypt(updateFieldData[authType], tempKey)
|
||||||
item.tempKey = RSAEncrypt(tempKey)
|
updateFieldData.tempKey = RSAEncrypt(tempKey)
|
||||||
return item
|
}
|
||||||
})
|
let updateIds = batchHosts.value.map(item => item.id)
|
||||||
let { msg } = await $api.updateHost({ hosts: newHosts })
|
let { msg } = await $api.batchUpdateHost({ updateIds, updateFieldData })
|
||||||
$message({ type: 'success', center: true, message: msg })
|
$message({ type: 'success', center: true, message: msg })
|
||||||
} else {
|
} else {
|
||||||
let tempKey = randomStr(16)
|
|
||||||
let { authType } = formData
|
let { authType } = formData
|
||||||
if (formData[authType]) formData[authType] = AESEncrypt(formData[authType], tempKey)
|
if (formData[authType]) {
|
||||||
|
let tempKey = randomStr(16)
|
||||||
|
formData[authType] = AESEncrypt(formData[authType], tempKey)
|
||||||
formData.tempKey = RSAEncrypt(tempKey)
|
formData.tempKey = RSAEncrypt(tempKey)
|
||||||
|
}
|
||||||
if (defaultData.value) {
|
if (defaultData.value) {
|
||||||
let { msg } = await $api.updateHost(Object.assign({}, formData, { oldHost: oldHost.value }))
|
let { msg } = await $api.updateHost({ ...formData })
|
||||||
$message({ type: 'success', center: true, message: msg })
|
$message({ type: 'success', center: true, message: msg })
|
||||||
} else {
|
} else {
|
||||||
let { msg } = await $api.addHost(formData)
|
let { msg } = await $api.addHost({ ...formData })
|
||||||
$message({ type: 'success', center: true, message: msg })
|
$message({ type: 'success', center: true, message: msg })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -461,13 +522,13 @@ const handleSave = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty_credential {
|
.empty_text {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.auth_type_wrap {
|
.select_warp {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
@ -3,7 +3,7 @@
|
|||||||
<el-table
|
<el-table
|
||||||
ref="tableRef"
|
ref="tableRef"
|
||||||
:data="hosts"
|
:data="hosts"
|
||||||
row-key="host"
|
row-key="id"
|
||||||
:default-sort="defaultSort"
|
:default-sort="defaultSort"
|
||||||
@sort-change="handleSortChange"
|
@sort-change="handleSortChange"
|
||||||
@selection-change="handleSelectionChange"
|
@selection-change="handleSelectionChange"
|
||||||
@ -36,12 +36,15 @@
|
|||||||
<el-descriptions-item label="位置" width="20%">
|
<el-descriptions-item label="位置" width="20%">
|
||||||
{{ row.monitorData?.ipInfo.country || '--' }} {{ row.monitorData?.ipInfo.regionName }}
|
{{ row.monitorData?.ipInfo.country || '--' }} {{ row.monitorData?.ipInfo.regionName }}
|
||||||
</el-descriptions-item>
|
</el-descriptions-item>
|
||||||
<el-descriptions-item v-show="row.consoleUrl" label="其他" width="20%">
|
<el-descriptions-item v-show="row.expired" label="到期时间" width="20%">
|
||||||
|
<span>{{ row.expired }}</span>
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item v-show="row.consoleUrl" label="服务商控制台" width="20%">
|
||||||
<span class="link" @click="handleToConsole(row)">服务商控制台</span>
|
<span class="link" @click="handleToConsole(row)">服务商控制台</span>
|
||||||
</el-descriptions-item>
|
</el-descriptions-item>
|
||||||
</el-descriptions>
|
</el-descriptions>
|
||||||
<div v-else class="no_client_data">
|
<div v-else class="no_client_data">
|
||||||
监控客户端服务未连接,无法获取实例监控数据。<span class="link" @click="handleOnekey(row)">去安装</span>
|
客户端监控服务未安装或连接失败,无法获取实例监控数据。<span class="link" @click="handleOnekey(row)">去安装</span>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
@ -79,19 +82,49 @@
|
|||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<!-- <el-table-column property="isConfig" label="登录配置" /> -->
|
<!-- <el-table-column property="isConfig" label="登录配置" /> -->
|
||||||
<el-table-column label="操作" width="300px">
|
<el-table-column label="操作" fixed="right" :width="isMobileScreen ? 'auto' : '260px'">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
|
<el-dropdown v-if="isMobileScreen" trigger="click">
|
||||||
|
<span class="link">
|
||||||
|
操作
|
||||||
|
<el-icon class="el-icon--right">
|
||||||
|
<arrow-down />
|
||||||
|
</el-icon>
|
||||||
|
</span>
|
||||||
|
<template #dropdown>
|
||||||
|
<el-dropdown-menu>
|
||||||
|
<el-dropdown-item>
|
||||||
<el-tooltip
|
<el-tooltip
|
||||||
:disabled="row.isConfig"
|
:disabled="row.isConfig"
|
||||||
effect="dark"
|
effect="dark"
|
||||||
content="请先配置ssh连接信息"
|
content="请先配置ssh连接信息"
|
||||||
placement="left"
|
placement="left"
|
||||||
>
|
>
|
||||||
<el-button type="success" :disabled="!row.isConfig" @click="handleSSH(row)">连接终端</el-button>
|
<el-button type="success" :disabled="!row.isConfig" @click="handleSSH(row)">连接</el-button>
|
||||||
|
</el-tooltip>
|
||||||
|
</el-dropdown-item>
|
||||||
|
<el-dropdown-item>
|
||||||
|
<el-button type="primary" @click="handleUpdate(row)">配置</el-button>
|
||||||
|
</el-dropdown-item>
|
||||||
|
<el-dropdown-item>
|
||||||
|
<el-button type="danger" @click="handleRemoveHost(row)">删除</el-button>
|
||||||
|
</el-dropdown-item>
|
||||||
|
</el-dropdown-menu>
|
||||||
|
</template>
|
||||||
|
</el-dropdown>
|
||||||
|
<template v-else>
|
||||||
|
<el-tooltip
|
||||||
|
:disabled="row.isConfig"
|
||||||
|
effect="dark"
|
||||||
|
content="请先配置ssh连接信息"
|
||||||
|
placement="left"
|
||||||
|
>
|
||||||
|
<el-button type="success" :disabled="!row.isConfig" @click="handleSSH(row)">连接</el-button>
|
||||||
</el-tooltip>
|
</el-tooltip>
|
||||||
<el-button type="primary" @click="handleUpdate(row)">配置</el-button>
|
<el-button type="primary" @click="handleUpdate(row)">配置</el-button>
|
||||||
<el-button type="danger" @click="handleRemoveHost(row)">删除</el-button>
|
<el-button type="danger" @click="handleRemoveHost(row)">删除</el-button>
|
||||||
</template>
|
</template>
|
||||||
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
</div>
|
</div>
|
||||||
@ -100,6 +133,8 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, getCurrentInstance, nextTick } from 'vue'
|
import { ref, computed, getCurrentInstance, nextTick } from 'vue'
|
||||||
import { Download, Upload } from '@element-plus/icons-vue'
|
import { Download, Upload } from '@element-plus/icons-vue'
|
||||||
|
import { ArrowDown } from '@element-plus/icons-vue'
|
||||||
|
import useMobileWidth from '@/composables/useMobileWidth'
|
||||||
|
|
||||||
const { proxy: { $message, $messageBox, $api, $router, $tools } } = getCurrentInstance()
|
const { proxy: { $message, $messageBox, $api, $router, $tools } } = getCurrentInstance()
|
||||||
|
|
||||||
@ -112,6 +147,7 @@ const props = defineProps({
|
|||||||
|
|
||||||
const emit = defineEmits(['update-list', 'update-host', 'select-change',])
|
const emit = defineEmits(['update-list', 'update-host', 'select-change',])
|
||||||
|
|
||||||
|
const { isMobileScreen } = useMobileWidth()
|
||||||
let tableRef = ref(null)
|
let tableRef = ref(null)
|
||||||
|
|
||||||
let hosts = computed(() => {
|
let hosts = computed(() => {
|
||||||
@ -128,12 +164,12 @@ const handleToConsole = ({ consoleUrl }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleSSH = async (row) => {
|
const handleSSH = async (row) => {
|
||||||
let { host } = row
|
let { id } = row
|
||||||
$router.push({ path: '/terminal', query: { host } })
|
$router.push({ path: '/terminal', query: { hostIds: id } })
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleOnekey = async (row) => {
|
const handleOnekey = async (row) => {
|
||||||
let { host, isConfig } = row
|
let { id, isConfig } = row
|
||||||
if (!isConfig) {
|
if (!isConfig) {
|
||||||
$message({
|
$message({
|
||||||
message: '请先配置SSH连接信息',
|
message: '请先配置SSH连接信息',
|
||||||
@ -143,7 +179,7 @@ const handleOnekey = async (row) => {
|
|||||||
handleUpdate(row)
|
handleUpdate(row)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
$router.push({ path: '/onekey', query: { host, execClientInstallScript: 'true' } })
|
$router.push({ path: '/onekey', query: { hostIds: id, execClientInstallScript: 'true' } })
|
||||||
}
|
}
|
||||||
|
|
||||||
let defaultSortLocal = localStorage.getItem('host_table_sort')
|
let defaultSortLocal = localStorage.getItem('host_table_sort')
|
||||||
@ -175,13 +211,13 @@ defineExpose({
|
|||||||
clearSelection
|
clearSelection
|
||||||
})
|
})
|
||||||
|
|
||||||
const handleRemoveHost = async ({ host }) => {
|
const handleRemoveHost = async ({ id }) => {
|
||||||
$messageBox.confirm('确认删除实例', 'Warning', {
|
$messageBox.confirm('确认删除实例', 'Warning', {
|
||||||
confirmButtonText: '确定',
|
confirmButtonText: '确定',
|
||||||
cancelButtonText: '取消',
|
cancelButtonText: '取消',
|
||||||
type: 'warning'
|
type: 'warning'
|
||||||
}).then(async () => {
|
}).then(async () => {
|
||||||
let { data } = await $api.removeHost({ host })
|
let { data } = await $api.removeHost({ ids: [id,] })
|
||||||
$message({
|
$message({
|
||||||
message: data,
|
message: data,
|
||||||
type: 'success',
|
type: 'success',
|
||||||
|
@ -133,7 +133,7 @@ const handleCsvFile = (event) => {
|
|||||||
|
|
||||||
const handleJsonFile = (event) => {
|
const handleJsonFile = (event) => {
|
||||||
let files = event.target.files
|
let files = event.target.files
|
||||||
let jsonFiles = Array.from(files).filter(file => file.type === 'application/json')
|
let jsonFiles = Array.from(files).filter(file => file.name.endsWith('.json'))
|
||||||
if (jsonFiles.length === 0) return $message.warning('未选择有效的JSON文件')
|
if (jsonFiles.length === 0) return $message.warning('未选择有效的JSON文件')
|
||||||
|
|
||||||
let readerPromises = jsonFiles.map(file => {
|
let readerPromises = jsonFiles.map(file => {
|
||||||
|
@ -3,10 +3,6 @@
|
|||||||
<div class="server_group_header">
|
<div class="server_group_header">
|
||||||
<!-- <el-button v-show="selectHosts.length" type="primary" @click="hostFormVisible = true">批量操作</el-button> -->
|
<!-- <el-button v-show="selectHosts.length" type="primary" @click="hostFormVisible = true">批量操作</el-button> -->
|
||||||
<el-button type="primary" class="add_host_btn" @click="hostFormVisible = true">添加实例</el-button>
|
<el-button type="primary" class="add_host_btn" @click="hostFormVisible = true">添加实例</el-button>
|
||||||
<!-- <el-button type="primary" @click="handleHiddenIP">
|
|
||||||
{{ hiddenIp ? '显示IP' : '隐藏IP' }}
|
|
||||||
</el-button> -->
|
|
||||||
<!-- <el-button type="primary" @click="importVisible = true">导入实例</el-button> -->
|
|
||||||
<el-dropdown trigger="click">
|
<el-dropdown trigger="click">
|
||||||
<el-button type="primary" class="group_action_btn">
|
<el-button type="primary" class="group_action_btn">
|
||||||
导入导出<el-icon class="el-icon--right"><arrow-down /></el-icon>
|
导入导出<el-icon class="el-icon--right"><arrow-down /></el-icon>
|
||||||
@ -44,17 +40,9 @@
|
|||||||
<el-collapse-item v-for="(hosts, groupName) in groupHostList" :key="groupName" :name="groupName">
|
<el-collapse-item v-for="(hosts, groupName) in groupHostList" :key="groupName" :name="groupName">
|
||||||
<template #title>
|
<template #title>
|
||||||
<div class="group_title">
|
<div class="group_title">
|
||||||
{{ groupName }}
|
{{ `${groupName}`+`${hosts.length ? `(${hosts.length})` : ''}` }}
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<!-- <HostCard
|
|
||||||
v-for="(item, index) in hosts"
|
|
||||||
:key="index"
|
|
||||||
:host-info="item"
|
|
||||||
:hidden-ip="hiddenIp"
|
|
||||||
@update-host="handleUpdateHost"
|
|
||||||
@update-list="handleUpdateList"
|
|
||||||
/> -->
|
|
||||||
<HostTable
|
<HostTable
|
||||||
ref="hostTableRefs"
|
ref="hostTableRefs"
|
||||||
:hosts="hosts"
|
:hosts="hosts"
|
||||||
@ -90,17 +78,15 @@ import { exportFile } from '@/utils'
|
|||||||
|
|
||||||
const { proxy: { $api, $store, $router, $message, $messageBox, $tools } } = getCurrentInstance()
|
const { proxy: { $api, $store, $router, $message, $messageBox, $tools } } = getCurrentInstance()
|
||||||
|
|
||||||
let updateHostData = ref(null)
|
const updateHostData = ref(null)
|
||||||
let hostFormVisible = ref(false)
|
const hostFormVisible = ref(false)
|
||||||
let importVisible = ref(false)
|
const importVisible = ref(false)
|
||||||
let selectHosts = ref([])
|
const selectHosts = ref([])
|
||||||
let isBatchModify = ref(false)
|
const isBatchModify = ref(false)
|
||||||
const hostTableRefs = ref([])
|
const hostTableRefs = ref([])
|
||||||
|
const activeGroup = ref([])
|
||||||
|
|
||||||
let hiddenIp = ref(Number(localStorage.getItem('hiddenIp') || 0))
|
const handleUpdateList = async () => {
|
||||||
let activeGroup = ref([])
|
|
||||||
|
|
||||||
let handleUpdateList = async () => {
|
|
||||||
try {
|
try {
|
||||||
await $store.getHostList()
|
await $store.getHostList()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@ -121,10 +107,10 @@ let collectSelectHost = () => {
|
|||||||
let handleBatchSSH = () => {
|
let handleBatchSSH = () => {
|
||||||
collectSelectHost()
|
collectSelectHost()
|
||||||
if (!selectHosts.value.length) return $message.warning('请选择要批量操作的实例')
|
if (!selectHosts.value.length) return $message.warning('请选择要批量操作的实例')
|
||||||
let ips = selectHosts.value.filter(item => item.isConfig).map(item => item.host)
|
let ids = selectHosts.value.filter(item => item.isConfig).map(item => item.id)
|
||||||
if (!ips.length) return $message.warning('所选实例未配置ssh连接信息')
|
if (!ids.length) return $message.warning('所选实例未配置ssh连接信息')
|
||||||
if (ips.length < selectHosts.value.length) $message.warning('部分实例未配置ssh连接信息,已忽略')
|
if (ids.length < selectHosts.value.length) $message.warning('部分实例未配置ssh连接信息,已忽略')
|
||||||
$router.push({ path: '/terminal', query: { host: ips.join(',') } })
|
$router.push({ path: '/terminal', query: { hostIds: ids.join(',') } })
|
||||||
}
|
}
|
||||||
|
|
||||||
let handleBatchModify = async () => {
|
let handleBatchModify = async () => {
|
||||||
@ -137,7 +123,7 @@ let handleBatchModify = async () => {
|
|||||||
let handleBatchRemove = async () => {
|
let handleBatchRemove = async () => {
|
||||||
collectSelectHost()
|
collectSelectHost()
|
||||||
if (!selectHosts.value.length) return $message.warning('请选择要批量操作的实例')
|
if (!selectHosts.value.length) return $message.warning('请选择要批量操作的实例')
|
||||||
let ips = selectHosts.value.map(item => item.host)
|
let ids = selectHosts.value.map(item => item.id)
|
||||||
let names = selectHosts.value.map(item => item.name)
|
let names = selectHosts.value.map(item => item.name)
|
||||||
|
|
||||||
$messageBox.confirm(() => h('p', { style: 'line-height: 18px;' }, `确认删除\n${ names.join(', ') }吗?`), 'Warning', {
|
$messageBox.confirm(() => h('p', { style: 'line-height: 18px;' }, `确认删除\n${ names.join(', ') }吗?`), 'Warning', {
|
||||||
@ -145,7 +131,7 @@ let handleBatchRemove = async () => {
|
|||||||
cancelButtonText: '取消',
|
cancelButtonText: '取消',
|
||||||
type: 'warning'
|
type: 'warning'
|
||||||
}).then(async () => {
|
}).then(async () => {
|
||||||
let { data } = await $api.removeHost({ host: ips })
|
let { data } = await $api.removeHost({ ids })
|
||||||
$message({ message: data, type: 'success', center: true })
|
$message({ message: data, type: 'success', center: true })
|
||||||
selectHosts.value = []
|
selectHosts.value = []
|
||||||
await handleUpdateList()
|
await handleUpdateList()
|
||||||
@ -161,14 +147,13 @@ let handleUpdateHost = (defaultData) => {
|
|||||||
let handleBatchOnekey = async () => {
|
let handleBatchOnekey = async () => {
|
||||||
collectSelectHost()
|
collectSelectHost()
|
||||||
if (!selectHosts.value.length) return $message.warning('请选择要批量操作的实例')
|
if (!selectHosts.value.length) return $message.warning('请选择要批量操作的实例')
|
||||||
let ips = selectHosts.value.map(item => item.host).join(',')
|
let ids = selectHosts.value.map(item => item.id).join(',')
|
||||||
$router.push({ path: '/onekey', query: { host: ips, execClientInstallScript: 'true' } })
|
$router.push({ path: '/onekey', query: { hostIds: ids, execClientInstallScript: 'true' } })
|
||||||
}
|
}
|
||||||
|
|
||||||
let handleBatchExport = () => {
|
let handleBatchExport = () => {
|
||||||
collectSelectHost()
|
collectSelectHost()
|
||||||
if (!selectHosts.value.length) return $message.warning('请选择要批量操作的实例')
|
if (!selectHosts.value.length) return $message.warning('请选择要批量操作的实例')
|
||||||
console.log(selectHosts.value)
|
|
||||||
let exportData = JSON.parse(JSON.stringify(selectHosts.value))
|
let exportData = JSON.parse(JSON.stringify(selectHosts.value))
|
||||||
exportData = exportData.map(item => {
|
exportData = exportData.map(item => {
|
||||||
delete item.monitorData
|
delete item.monitorData
|
||||||
@ -179,11 +164,6 @@ let handleBatchExport = () => {
|
|||||||
hostTableRefs.value.forEach(item => item.clearSelection())
|
hostTableRefs.value.forEach(item => item.clearSelection())
|
||||||
}
|
}
|
||||||
|
|
||||||
let handleHiddenIP = () => {
|
|
||||||
hiddenIp.value = hiddenIp.value ? 0 : 1
|
|
||||||
localStorage.setItem('hiddenIp', String(hiddenIp.value))
|
|
||||||
}
|
|
||||||
|
|
||||||
let hostList = computed(() => $store.hostList)
|
let hostList = computed(() => $store.hostList)
|
||||||
|
|
||||||
let groupHostList = computed(() => {
|
let groupHostList = computed(() => {
|
||||||
|
@ -69,20 +69,45 @@
|
|||||||
/>
|
/>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</template>
|
</template>
|
||||||
|
<!-- Telegram -->
|
||||||
|
<template v-if="noticeConfig.type === 'tg'">
|
||||||
|
<el-form-item label="Token" prop="tg.token" class="form_item">
|
||||||
|
<el-input
|
||||||
|
v-model.trim="noticeConfig.tg.token"
|
||||||
|
clearable
|
||||||
|
placeholder="Telegram Token"
|
||||||
|
autocomplete="off"
|
||||||
|
class="input"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="ChatId" prop="tg.chatId" class="form_item">
|
||||||
|
<el-input
|
||||||
|
v-model="noticeConfig.tg.chatId"
|
||||||
|
clearable
|
||||||
|
placeholder="Telegram ChatId"
|
||||||
|
autocomplete="off"
|
||||||
|
class="input"
|
||||||
|
/>
|
||||||
|
<span class="tips">Telegram Token/ChatId 获取: <a class="link" href="https://easynode.chaoszhu.com/zh/guide/get-tg-token" target="_blank">查看教程</a> </span>
|
||||||
|
</el-form-item>
|
||||||
|
</template>
|
||||||
<el-form-item label="" class="form_item">
|
<el-form-item label="" class="form_item">
|
||||||
<el-button type="primary" :loading="loading" @click="handleSave">
|
<el-button
|
||||||
|
type="primary"
|
||||||
|
:loading="loading"
|
||||||
|
@click="handleSave"
|
||||||
|
>
|
||||||
测试并保存
|
测试并保存
|
||||||
</el-button>
|
</el-button>
|
||||||
<!-- <el-tooltip effect="dark" content="重复添加的邮箱将会被覆盖" placement="right">
|
|
||||||
</el-tooltip> -->
|
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-form>
|
</el-form>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, reactive, onMounted, getCurrentInstance } from 'vue'
|
import { ref, reactive, onMounted, getCurrentInstance, computed } from 'vue'
|
||||||
|
// import PlusSupportTip from '@/components/common/PlusSupportTip.vue'
|
||||||
|
|
||||||
const { proxy: { $api, $notification } } = getCurrentInstance()
|
const { proxy: { $api, $notification, $store } } = getCurrentInstance()
|
||||||
|
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const noticeConfig = ref({})
|
const noticeConfig = ref({})
|
||||||
@ -95,14 +120,28 @@ const noticeTypeList = ref([
|
|||||||
type: 'sct',
|
type: 'sct',
|
||||||
desc: 'Server酱'
|
desc: 'Server酱'
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
type: 'tg',
|
||||||
|
desc: 'Telegram'
|
||||||
|
},
|
||||||
])
|
])
|
||||||
const formRef = ref(null)
|
const formRef = ref(null)
|
||||||
|
const isPlusActive = computed(() => $store.isPlusActive)
|
||||||
|
|
||||||
const rules = reactive({
|
const rules = reactive({
|
||||||
'sct.sendKey': { required: true, message: '需输入sendKey', trigger: 'change' },
|
'sct.sendKey': { required: true, message: '需输入sendKey', trigger: 'change' },
|
||||||
'email.service': { required: true, message: '需输入邮箱提供商', trigger: 'change' },
|
'email.service': { required: true, message: '需输入邮箱提供商', trigger: 'change' },
|
||||||
'email.user': { required: true, type: 'email', message: '需输入邮箱', trigger: 'change' },
|
'email.user': { required: true, type: 'email', message: '需输入邮箱', trigger: 'change' },
|
||||||
'email.pass': { required: true, message: '需输入邮箱SMTP授权码', trigger: 'change' }
|
'email.pass': { required: true, message: '需输入邮箱SMTP授权码', trigger: 'change' },
|
||||||
|
'tg.token': { required: true, message: '需输入Telegram Token', trigger: 'change' },
|
||||||
|
'tg.chatId': [
|
||||||
|
{ required: true, message: '需输入Telegram ChatId', trigger: 'change' },
|
||||||
|
{
|
||||||
|
pattern: /^-?\d+$/,
|
||||||
|
message: 'ChatId必须为数字',
|
||||||
|
trigger: ['blur', 'change',]
|
||||||
|
},
|
||||||
|
]
|
||||||
})
|
})
|
||||||
|
|
||||||
const handleSave = () => {
|
const handleSave = () => {
|
||||||
|