Compare commits

...

42 Commits
v2.2.7 ... main

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

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

Signed-off-by: dependabot[bot] <support@github.com>
2024-11-19 14:33:35 +00:00
chaos-zhu
cb866c6d26 🐛 修复添加实例错误禁用的bug 2024-11-19 22:31:30 +08:00
chaos-zhu
079c62b838 更新版本通知 2024-11-18 22:22:13 +08:00
chaos-zhu
c04989b951 优化脚本库新增脚本时序号自动累加 2024-11-09 23:43:02 +08:00
chaos-zhu
7c15d311c1 📝 更新文档 2024-11-09 23:29:39 +08:00
chaos-zhu
29fd0a5bbf 📝 更新文档 2024-11-09 23:24:24 +08:00
chaos-zhu
2c41928f65 plus&功能重构 2024-11-09 23:14:51 +08:00
chaos-zhu
1fdf8c6a09 📝 描述更新 2024-10-24 00:26:06 +08:00
chaos-zhu
678a1e4d04 支持MFA2二次验证 2024-10-24 00:00:44 +08:00
chaos-zhu
f0b492da26 支持MFA2二次验证 2024-10-23 22:48:24 +08:00
chaos-zhu
70bdaa5b69 ♻️ 重构本地数据库-credentials模块 2024-10-22 23:57:32 +08:00
chaos-zhu
cdd741b7fd ♻️ 重构本地数据库-keyConfig模块 2024-10-22 23:22:48 +08:00
chaos-zhu
90ee38ff44 ♻️ 重构本地数据库-onekey模块 2024-10-22 23:00:12 +08:00
chaos-zhu
9b71b28e46 ♻️ 重构本地数据库-scripts模块 2024-10-22 22:50:00 +08:00
chaos-zhu
98d44e8ab4 ♻️ 重构本地数据库-notify模块 2024-10-22 22:41:49 +08:00
chaos-zhu
dafb2cc5c9 ♻️ 重构本地数据库-log模块 2024-10-22 22:02:05 +08:00
chaos-zhu
5437486eba ♻️ 重构本地数据库-group模块 2024-10-22 21:48:29 +08:00
chaos-zhu
7aefa410dc 调整移动端虚拟按键位置 2024-10-22 21:32:52 +08:00
chaos-zhu
5724ede172 ♻️ 重构本地数据库-host模块 2024-10-22 00:48:26 +08:00
chaos-zhu
a72ab84cee 增强终端背景设定 2024-10-21 22:08:55 +08:00
chaos-zhu
6273a9498e 🐛 修复移动端软键盘弹起UI问题 2024-10-21 21:15:33 +08:00
chaos-zhu
c8898e6acb 🐛 修复删除实例异常 2024-10-21 18:55:41 +08:00
chaos-zhu
fe5e75878a 登录日志本地化储存 2024-10-20 22:35:50 +08:00
chaos-zhu
e9a567c3fe 优化移动端兼容UI 2024-10-20 21:56:41 +08:00
chaos-zhu
846c19ceb3 :fix: 修复移动端alt按键映射 2024-10-20 21:34:46 +08:00
chaos-zhu
fc42e1b29a 新增移动端虚拟按键映射 2024-10-20 19:59:34 +08:00
chaos-zhu
6b5f882808 兼容移动端UI 2024-10-20 16:22:56 +08:00
chaos-zhu
d8f0938a11 添加ping命令 2024-10-20 10:59:08 +08:00
90 changed files with 4951 additions and 1842 deletions

4
.gitignore vendored
View File

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

View File

@ -1,3 +1,54 @@
## [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

View File

@ -4,10 +4,7 @@ COPY ./web .
COPY yarn.lock .
RUN yarn
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
WORKDIR /easynode/server
COPY ./server .
@ -16,6 +13,7 @@ COPY --from=builder_web /easynode/web/dist ./app/static
RUN yarn
FROM node:20.16-alpine3.20
RUN apk add --no-cache iputils
WORKDIR /easynode
COPY --from=builder_server /easynode/server .
ENV HOST=0.0.0.0

131
README.md
View File

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

View File

@ -10,7 +10,7 @@ SERVER_NAME=easynode-client
FILE_PATH=/root/local/easynode-client
SERVICE_PATH=/etc/systemd/system
CLIENT_VERSION=client-2024-10-13 # 目前监控客户端版本发布需手动更改为最新版本号
SERVER_PROXY="https://ghp.ci/"
SERVER_PROXY="https://git.221022.xyz/"
if [ ! -z "$1" ]; then
clientPort=$1

View File

@ -22,7 +22,8 @@
"license": "ISC",
"scripts": {
"dev": "concurrently \"yarn workspace web run dev\" \"yarn workspace server run local\"",
"clean": "rimraf web/node_modules server/node_modules client/node_modules node_modules"
"clean": "rimraf web/node_modules server/node_modules client/node_modules node_modules",
"encrypt": "node ./local-script/encrypt-file.js"
},
"bugs": {
"url": "https://github.com/chaos-zhu/easynode/issues"

View File

@ -2,4 +2,7 @@
DEBUG=1
# 访问IP限制
allowedIPs=['127.0.0.1']
allowedIPs=['127.0.0.1']
# 激活PLUS功能的授权码
PLUS_KEY=

View File

@ -16,6 +16,8 @@ module.exports = {
notifyDBPath: path.join(process.cwd(),'app/db/notify.db'),
notifyConfigDBPath: path.join(process.cwd(),'app/db/notify-config.db'),
onekeyDBPath: path.join(process.cwd(),'app/db/onekey.db'),
logDBPath: path.join(process.cwd(),'app/db/log.db'),
plusDBPath: path.join(process.cwd(),'app/db/plus.db'),
apiPrefix: '/api/v1',
logConfig: {
outDir: path.join(process.cwd(),'./app/logs'),

View File

@ -1,12 +1,12 @@
[
{
"name": "easynode监控服务安装",
"command": "curl -o- https://ghp.ci/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-监控服务-安装脚本"
},
{
"name": "easynode监控服务卸载",
"command": "curl -o- https://ghp.ci/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-监控服务-卸载脚本"
},
{

View File

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

View File

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

View File

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

View File

@ -1,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 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 }) {
const data = await readNotifyConfig()
const data = await notifyConfigDB.findOneAsync({})
return res.success({ data })
}
@ -11,15 +14,22 @@ async function updateNotifyConfig({ res, request }) {
let { body: { noticeConfig } } = request
let { type } = noticeConfig
try {
switch(type) {
// console.log('noticeConfig: ', noticeConfig[type])
switch (type) {
case 'sct':
await sendServerChan(noticeConfig[type]['sendKey'], 'EasyNode通知测试', '这是一条测试通知')
break
case 'email':
await sendEmail(noticeConfig[type], 'EasyNode通知测试', '这是一条测试通知')
break
case 'tg':
let { sendTg } = await decryptAndExecuteAsync(path.join(__dirname, '../utils/plus.js')) || {}
console.log('sendTg: ', sendTg)
if (!sendTg) return res.fail({ msg: 'Plus专属功能点请激活Plus' })
await sendTg(noticeConfig[type], 'EasyNode通知测试', '这是一条测试通知')
break
}
await writeNotifyConfig(noticeConfig)
await notifyConfigDB.update({}, { $set: noticeConfig }, { upsert: true })
return res.success({ msg: '测试通过 | 保存成功' })
} catch (error) {
return res.fail({ msg: error.message })
@ -27,18 +37,14 @@ async function updateNotifyConfig({ res, request }) {
}
async function getNotifyList({ res }) {
const data = await readNotifyList()
const data = await notifyDB.findAsync({})
res.success({ data })
}
async function updateNotifyList({ res, request }) {
let { body: { type, sw } } = request
if (!([true, false].includes(sw))) return res.fail({ msg: `Error type for sw${ sw }, must be Boolean` })
const notifyList = await readNotifyList()
let target = notifyList.find((item) => item.type === type)
if (!target) return res.fail({ msg: `更新失败, 不存在该通知类型:${ type }` })
target.sw = sw
await writeNotifyList(notifyList)
await notifyDB.updateAsync({ type }, { $set: { sw } })
res.success()
}

View File

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

View File

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

View File

@ -1,13 +1,16 @@
const localShellJson = require('../config/shell.json')
const { readScriptList, writeScriptList } = require('../utils/storage')
const path = require('path')
const decryptAndExecuteAsync = require('../utils/decrypt-file')
const { randomStr } = require('../utils/tools')
const { ScriptsDB } = require('../utils/db-class')
const localShellJson = require('../config/shell.json')
const scriptsDB = new ScriptsDB().getInstance()
let localShell = JSON.parse(JSON.stringify(localShellJson)).map((item) => {
return { ...item, id: randomStr(10), index: '--', description: item.description + '|内置脚本' }
})
async function getScriptList({ res }) {
let data = await readScriptList()
let data = await scriptsDB.findAsync({})
data = data.map(item => {
return { ...item, id: item._id }
})
@ -24,10 +27,8 @@ const addScript = async ({ res, request }) => {
let { body: { name, description, command, index } } = request
if (!name || !command) return res.fail({ data: false, msg: '参数错误' })
index = Number(index) || 0
let scriptsList = await readScriptList()
let record = { name, description, command, index }
scriptsList.push(record)
await writeScriptList(scriptsList)
await scriptsDB.insertAsync(record)
res.success({ data: '添加成功' })
}
@ -35,30 +36,38 @@ const updateScriptList = async ({ res, request }) => {
let { params: { id } } = request
let { body: { name, description, command, index } } = request
if (!name || !command) return res.fail({ data: false, msg: '参数错误' })
let scriptsList = await readScriptList()
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)
await scriptsDB.updateAsync({ _id: id }, { name, description, command, index })
res.success({ data: '修改成功' })
}
const removeScript = async ({ res, request }) => {
let { params: { id } } = request
let scriptsList = await readScriptList()
let idx = scriptsList.findIndex(item => item._id === id)
if (idx === -1) return res.fail({ msg: '脚本ID不存在' })
scriptsList.splice(idx, 1)
await writeScriptList(scriptsList)
await scriptsDB.removeAsync({ _id: id })
res.success({ data: '移除成功' })
}
const batchRemoveScript = async ({ res, request }) => {
let { body: { ids } } = request
if (!Array.isArray(ids)) return res.fail({ msg: '参数错误' })
const numRemoved = await scriptsDB.removeAsync({ _id: { $in: ids } }, { multi: true })
res.success({ data: `批量移除成功,数量: ${ numRemoved }` })
}
const importScript = async ({ res, request }) => {
let { impScript } = (await decryptAndExecuteAsync(path.join(__dirname, 'plus.js'))) || {}
if (impScript) {
await impScript({ res, request })
} else {
return res.fail({ data: false, msg: 'Plus专属功能!' })
}
}
module.exports = {
addScript,
getScriptList,
getLocalScriptList,
updateScriptList,
removeScript
removeScript,
batchRemoveScript,
importScript
}

View File

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

View File

@ -1,12 +1,21 @@
const jwt = require('jsonwebtoken')
const axios = require('axios')
const { asyncSendNotice } = require('../utils/notify')
const { readKey, writeKey } = require('../utils/storage')
const { RSADecryptSync, AESEncryptSync, SHA1Encrypt } = require('../utils/encrypt')
const speakeasy = require('speakeasy')
const QRCode = require('qrcode')
const version = require('../../package.json').version
const getLicenseInfo = require('../utils/get-plus')
const { plusServer1, plusServer2 } = require('../utils/plus-server')
const { sendNoticeAsync } = require('../utils/notify')
const { RSADecryptAsync, AESEncryptAsync, SHA1Encrypt } = require('../utils/encrypt')
const { getNetIPInfo } = require('../utils/tools')
const { KeyDB, LogDB, PlusDB } = require('../utils/db-class')
const keyDB = new KeyDB().getInstance()
const logDB = new LogDB().getInstance()
const plusDB = new PlusDB().getInstance()
const getpublicKey = async ({ res }) => {
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 })
res.success({ data })
}
@ -20,7 +29,7 @@ let loginCountDown = forbidTimer
let forbidLogin = false
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 (forbidLogin) return res.fail({ msg: `禁止登录! 倒计时[${ loginCountDown }s]后尝试登录或重启面板服务` })
loginErrCount++
@ -28,7 +37,7 @@ const login = async ({ res, request }) => {
if (loginErrCount >= allowErrCount) {
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
loginErrCount = 0
@ -51,10 +60,13 @@ const login = async ({ res, request }) => {
// 登录流程
try {
// console.log('ciphertext', ciphertext)
let loginPwd = await RSADecryptSync(ciphertext)
// console.log('Decrypt解密password:', loginPwd)
let { user, pwd } = await readKey()
let loginPwd = await RSADecryptAsync(ciphertext)
let { user, pwd, enableMFA2, secret } = await keyDB.findOneAsync({})
if (enableMFA2) {
const isValid = speakeasy.totp.verify({ secret, encoding: 'base32', token: mfa2Token, window: 1 })
console.log('MFA2 verfify:', isValid)
if (!isValid) return res.fail({ msg: '验证失败' })
}
if (loginName === user && loginPwd === 'admin' && pwd === 'admin') {
const token = await beforeLoginHandler(clientIp, jwtExpires)
return res.success({ data: { token, jwtExpires }, msg: '登录成功,请及时修改默认用户名和密码' })
@ -64,8 +76,8 @@ const login = async ({ res, request }) => {
const token = await beforeLoginHandler(clientIp, jwtExpires)
return res.success({ data: { token, jwtExpires }, msg: '登录成功' })
} catch (error) {
console.log('解密失败:', error)
res.fail({ msg: '解密失败, 请查看服务端日志' })
console.log('登录失败:', error.message)
res.fail({ msg: '登录失败, 请查看服务端日志' })
}
}
@ -74,50 +86,42 @@ const beforeLoginHandler = async (clientIp, jwtExpires) => {
// consola.success('登录成功, 准备生成token', new Date())
// 生产token
let { commonKey } = await readKey()
let { commonKey } = await keyDB.findOneAsync({})
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 { ip, country, city } = clientIPInfo || {}
consola.info('登录成功:', new Date(), { ip, country, city })
// 邮件登录通知
asyncSendNotice('login', '登录提醒', `地点:${ country + city }\nIP: ${ ip }`)
// 登录通知
sendNoticeAsync('login', '登录提醒', `地点:${ country + city }\nIP: ${ ip }`)
global.loginRecord.unshift(clientIPInfo)
if (global.loginRecord.length > 10) global.loginRecord = global.loginRecord.slice(0, 10)
await logDB.insertAsync({ ip, country, city, date: Date.now(), type: 'login' })
return token
}
const updatePwd = async ({ res, request }) => {
let { body: { oldLoginName, oldPwd, newLoginName, newPwd } } = request
let rsaOldPwd = await RSADecryptSync(oldPwd)
let rsaOldPwd = await RSADecryptAsync(oldPwd)
oldPwd = rsaOldPwd === 'admin' ? 'admin' : SHA1Encrypt(rsaOldPwd)
let keyObj = await readKey()
let keyObj = await keyDB.findOneAsync({})
let { user, pwd } = keyObj
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.pwd = newPwd
await writeKey(keyObj)
asyncSendNotice('updatePwd', '用户密码修改提醒', `原用户名:${ user }\n更新用户名: ${ newLoginName }`)
await keyDB.updateAsync({}, keyObj)
sendNoticeAsync('updatePwd', '用户密码修改提醒', `原用户名:${ user }\n更新用户名: ${ newLoginName }`)
res.success({ data: true, msg: 'success' })
}
const getLoginRecord = async ({ res }) => {
res.success({ data: global.loginRecord, msg: 'success' })
}
const getEasynodeVersion = async ({ res }) => {
try {
// const { data } = await axios.get('https://api.github.com/repos/chaos-zhu/easynode/releases/latest')
const { data } = await axios.get('https://get-easynode-latest-version.chaoszhu.workers.dev/version')
console.log(data)
res.success({ data, msg: 'success' })
} catch (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 = {
login,
getpublicKey,
updatePwd,
getLoginRecord,
getEasynodeVersion
getEasynodeVersion,
getMFA2Status,
getMFA2Code,
enableMFA2,
disableMFA2,
getPlusInfo,
getPlusDiscount,
getPlusConf,
updatePlusKey
}

View File

@ -1,127 +1,99 @@
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')
function initKeyDB() {
return new Promise((resolve, reject) => {
const keyDB = new KeyDB().getInstance()
keyDB.count({}, async (err, count) => {
if (err) {
consola.log('初始化keyDB错误:', err)
reject(err)
} else {
if (count === 0) {
consola.log('初始化keyDB✔')
const defaultData = {
user: 'admin',
pwd: 'admin',
commonKey: '',
publicKey: '',
privateKey: ''
}
await writeKey(defaultData)
}
}
resolve()
})
})
async function initKeyDB() {
const keyDB = new KeyDB().getInstance()
let count = await keyDB.countAsync({})
if (count !== 0) return consola.info('公私钥已存在[重新生成会导致已保存的ssh密钥信息失效]')
let newConfig = {
user: 'admin',
pwd: 'admin',
commonKey: randomStr(16),
publicKey: '',
privateKey: ''
}
await keyDB.insertAsync(newConfig)
let key = new NodeRSA({ b: 1024 })
key.setOptions({ encryptionScheme: 'pkcs1', environment: 'browser' })
let privateKey = key.exportKey('pkcs1-private-pem')
let publicKey = key.exportKey('pkcs8-public-pem')
newConfig.privateKey = await AESEncryptAsync(privateKey, newConfig.commonKey) // 加密私钥
newConfig.publicKey = publicKey // 公开公钥
await keyDB.updateAsync({}, { $set: newConfig }, { upsert: true })
consola.info('Task: 已生成新的非对称加密公私钥')
}
function initGroupDB() {
return new Promise((resolve, reject) => {
const groupDB = new GroupDB().getInstance()
groupDB.count({}, async (err, count) => {
if (err) {
consola.log('初始化groupDB错误:', err)
reject(err)
} else {
if (count === 0) {
consola.log('初始化groupDB✔')
const defaultData = [{ '_id': 'default', 'name': '默认分组', 'index': 0 }]
await writeGroupList(defaultData)
}
}
resolve()
})
})
async function initGroupDB() {
const groupDB = new GroupDB().getInstance()
let count = await groupDB.countAsync({})
if (count === 0) {
consola.log('初始化groupDB✔')
const defaultData = [{ '_id': 'default', 'name': '默认分组', 'index': 0 }]
return groupDB.insertAsync(defaultData)
}
return Promise.resolve()
}
function initNotifyDB() {
return new Promise((resolve, reject) => {
const notifyDB = new NotifyDB().getInstance()
notifyDB.find({}, async (err, notifyList) => {
if (err) {
consola.log('初始化notifyDB错误:', err)
reject(err)
} else {
let defaultData = [{
'type': 'login',
'desc': '登录面板提醒',
'sw': false
}, {
'type': 'err_login',
'desc': '登录错误提醒(连续5次)',
'sw': false
}, {
'type': 'updatePwd',
'desc': '修改密码提醒',
'sw': false
}, {
'type': 'host_login',
'desc': '服务器登录提醒',
'sw': false
}, {
'type': 'onekey_complete',
'desc': '批量指令执行完成提醒',
'sw': false
}, {
'type': 'host_expired',
'desc': '服务器到期提醒',
'sw': false
}]
if (notifyList.length === 0) {
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()
})
})
async function initNotifyDB() {
const notifyDB = new NotifyDB().getInstance()
let count = await notifyDB.countAsync({})
if (count !== 0) return
consola.log('初始化notifyDB✔')
let defaultData = [{
'type': 'login',
'desc': '登录面板提醒',
'sw': false
}, {
'type': 'err_login',
'desc': '登录错误提醒(连续5次)',
'sw': false
}, {
'type': 'updatePwd',
'desc': '修改密码提醒',
'sw': false
}, {
'type': 'host_login',
'desc': '服务器登录提醒',
'sw': false
}, {
'type': 'onekey_complete',
'desc': '批量指令执行完成提醒',
'sw': false
}, {
'type': 'host_expired',
'desc': '服务器到期提醒',
'sw': false
}]
return notifyDB.insertAsync(defaultData)
}
function initNotifyConfigDB() {
return new Promise((resolve, reject) => {
const notifyConfigDB = new NotifyConfigDB().getInstance()
notifyConfigDB.count({}, async (err, count) => {
if (err) {
consola.log('初始化NotifyConfigDB错误:', err)
reject(err)
} else {
if (count === 0) {
consola.log('初始化NotifyConfigDB✔')
const defaultData = {
type: 'sct',
sct: {
sendKey: ''
},
email: {
service: 'QQ',
user: '',
pass: ''
}
}
await writeNotifyConfig(defaultData)
}
}
resolve()
})
})
async function initNotifyConfigDB() {
const notifyConfigDB = new NotifyConfigDB().getInstance()
let notifyConfig = await notifyConfigDB.findOneAsync({})
consola.log('初始化NotifyConfigDB✔')
const defaultData = {
type: 'sct',
sct: {
sendKey: ''
},
email: {
service: 'QQ',
user: '',
pass: ''
},
tg: {
token: '',
chatId: ''
}
}
if (notifyConfig) {
await notifyConfigDB.removeAsync({ _id: notifyConfig._id })
delete notifyConfig._id
return notifyConfigDB.insertAsync(Object.assign({}, defaultData, notifyConfig))
}
return notifyConfigDB.insertAsync(defaultData)
}
module.exports = async () => {

View File

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

View File

@ -1,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 = []
}

View File

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

View File

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

View File

@ -1,10 +1,11 @@
const { getSSHList, addSSH, updateSSH, removeSSH, getCommand } = require('../controller/ssh')
const { getHostList, addHost, updateHost, removeHost, importHost } = require('../controller/host')
const { login, getpublicKey, updatePwd, getLoginRecord, getEasynodeVersion } = require('../controller/user')
const { getSSHList, addSSH, updateSSH, removeSSH, getCommand, decryptPrivateKey } = require('../controller/ssh')
const { getHostList, addHost, updateHost, batchUpdateHost, removeHost, importHost } = require('../controller/host')
const { login, getpublicKey, updatePwd, getEasynodeVersion, getMFA2Status, getMFA2Code, enableMFA2, disableMFA2, getPlusInfo, getPlusDiscount, getPlusConf, updatePlusKey } = require('../controller/user')
const { getNotifyConfig, updateNotifyConfig, getNotifyList, updateNotifyList } = require('../controller/notify')
const { getGroupList, addGroupList, updateGroupList, removeGroup } = require('../controller/group')
const { getScriptList, getLocalScriptList, addScript, updateScriptList, removeScript } = require('../controller/scripts')
const { getScriptList, getLocalScriptList, addScript, updateScriptList, removeScript, batchRemoveScript, importScript } = require('../controller/scripts')
const { getOnekeyRecord, removeOnekeyRecord } = require('../controller/onekey')
const { getLog } = require('../controller/log')
const ssh = [
{
@ -31,6 +32,11 @@ const ssh = [
method: 'get',
path: '/command',
controller: getCommand
},
{
method: 'post',
path: '/decrypt-private-key',
controller: decryptPrivateKey
}
]
const host = [
@ -49,6 +55,11 @@ const host = [
path: '/host-save',
controller: updateHost
},
{
method: 'put',
path: '/batch-update-host',
controller: batchUpdateHost
},
{
method: 'post',
path: '/host-remove',
@ -78,13 +89,48 @@ const user = [
},
{
method: 'get',
path: '/get-login-record',
controller: getLoginRecord
path: '/version',
controller: getEasynodeVersion
},
{
method: 'get',
path: '/version',
controller: getEasynodeVersion
path: '/mfa2-status',
controller: getMFA2Status
},
{
method: 'post',
path: '/mfa2-code',
controller: getMFA2Code
},
{
method: 'post',
path: '/mfa2-enable',
controller: enableMFA2
},
{
method: 'post',
path: '/mfa2-disable',
controller: disableMFA2
},
{
method: 'get',
path: '/plus-info',
controller: getPlusInfo
},
{
method: 'get',
path: '/plus-discount',
controller: getPlusDiscount
},
{
method: 'get',
path: '/plus-conf',
controller: getPlusConf
},
{
method: 'post',
path: '/plus-conf',
controller: updatePlusKey
}
]
const notify = [
@ -154,10 +200,20 @@ const scripts = [
path: '/script/:id',
controller: removeScript
},
{
method: 'post',
path: '/batch-remove-script',
controller: batchRemoveScript
},
{
method: 'put',
path: '/script/:id',
controller: updateScriptList
},
{
method: 'post',
path: '/import-script',
controller: importScript
}
]
@ -173,4 +229,12 @@ const onekey = [
controller: removeOnekeyRecord
}
]
module.exports = [].concat(ssh, host, user, notify, group, scripts, onekey)
const log = [
{
method: 'get',
path: '/log',
controller: getLog
}
]
module.exports = [].concat(ssh, host, user, notify, group, scripts, onekey, log)

View File

@ -1,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)
}

View File

@ -1,5 +1,32 @@
const expiredNotify = require('./expired-notify')
const schedule = require('node-schedule')
const { sendNoticeAsync } = require('../utils/notify')
const { formatTimestamp } = require('../utils/tools')
const { HostListDB } = require('../utils/db-class')
const hostListDB = new HostListDB().getInstance()
const expiredNotifyJob = async () => {
consola.info('=====开始检测服务器到期时间=====', new Date())
const hostList = await hostListDB.findAsync({})
for (const item of hostList) {
if (!item.expiredNotify) continue
const { host, name, expired, consoleUrl } = item
const restDay = Number(((expired - Date.now()) / (1000 * 60 * 60 * 24)).toFixed(1))
console.log(Date.now(), restDay)
let title = '服务器到期提醒'
let content = `别名: ${ name }\nIP: ${ host }\n到期时间:${ formatTimestamp(expired, 'week') }\n控制台: ${ consoleUrl || '未填写' }`
if (0 <= restDay && restDay <= 1) {
let temp = '有服务器将在一天后到期,请关注\n'
sendNoticeAsync('host_expired', title, temp + content)
} else if (3 <= restDay && restDay < 4) {
let temp = '有服务器将在三天后到期,请关注\n'
sendNoticeAsync('host_expired', title, temp + content)
} else if (7 <= restDay && restDay < 8) {
let temp = '有服务器将在七天后到期,请关注\n'
sendNoticeAsync('host_expired', title, temp + content)
}
}
}
module.exports = () => {
expiredNotify()
schedule.scheduleJob('0 0 12 1/1 * ?', expiredNotifyJob)
}

View File

@ -1,15 +1,16 @@
const { Server: ServerIO } = require('socket.io')
const { io: ClientIO } = require('socket.io-client')
const { readHostList } = require('../utils/storage')
const { defaultClientPort } = require('../config')
const { verifyAuthSync } = require('../utils/verify-auth')
const { isAllowedIp } = require('../utils/tools')
const { HostListDB } = require('../utils/db-class')
const hostListDB = new HostListDB().getInstance()
let clientSockets = []
let clientsData = {}
async function getClientsInfo(clientSockets) {
let hostList = await readHostList()
let hostList = await hostListDB.findAsync({})
clientSockets.forEach((clientItem) => {
// 被删除的客户端断开连接
if (!hostList.some(item => item.host === clientItem.host)) clientItem.close && clientItem.close()

View File

@ -1,11 +1,14 @@
const { Server } = require('socket.io')
const { Client: SSHClient } = require('ssh2')
const { asyncSendNotice } = require('../utils/notify')
const { readSSHRecord, readHostList, writeOneKeyRecord } = require('../utils/storage')
const { sendNoticeAsync } = require('../utils/notify')
const { verifyAuthSync } = require('../utils/verify-auth')
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 = {
connecting: '连接中',
@ -121,7 +124,7 @@ module.exports = (httpServer) => {
}
})
let reason = `执行超时,已强制终止执行 - 超时时间${ timeout }`
asyncSendNotice('onekey_complete', '批量指令执行超时', reason)
sendNoticeAsync('onekey_complete', '批量指令执行超时', reason)
socket.emit('timeout', { reason, result: execResult })
socket.disconnect()
disconnectAllExecClient()
@ -129,7 +132,7 @@ module.exports = (httpServer) => {
console.log('hostIds:', hostIds)
// console.log('token:', token)
console.log('command:', command)
const hostList = await readHostList()
const hostList = await hostListDB.findAsync({})
const targetHostsInfo = hostList.filter(item => hostIds.some(id => item._id === id)) || {}
// console.log('targetHostsInfo:', targetHostsInfo)
if (!targetHostsInfo.length) return socket.emit('create_fail', `未找到【${ hostIds }】服务器信息`)
@ -145,13 +148,13 @@ module.exports = (httpServer) => {
execResult.push(curRes)
try {
if (authType === 'credential') {
let credentialId = await AESDecryptSync(hostInfo['credential'])
const sshRecordList = await readSSHRecord()
let credentialId = await AESDecryptAsync(hostInfo['credential'])
const sshRecordList = await credentialsDB.findAsync({})
const sshRecord = sshRecordList.find(item => item._id === credentialId)
authInfo.authType = sshRecord.authType
authInfo[authInfo.authType] = await AESDecryptSync(sshRecord[authInfo.authType])
authInfo[authInfo.authType] = await AESDecryptAsync(sshRecord[authInfo.authType])
} else {
authInfo[authType] = await AESDecryptSync(hostInfo[authType])
authInfo[authType] = await AESDecryptAsync(hostInfo[authType])
}
consola.info('准备连接终端执行一次性指令:', host)
consola.log('连接信息', { username, port, authType })
@ -188,7 +191,7 @@ module.exports = (httpServer) => {
await Promise.all(execPromise)
consola.success('onekey执行完成')
socket.emit('exec_complete')
asyncSendNotice('onekey_complete', '批量指令执行完成', '请登录面板查看执行结果')
sendNoticeAsync('onekey_complete', '批量指令执行完成', '请登录面板查看执行结果')
socket.disconnect()
} catch (error) {
consola.error('onekey执行失败', error)
@ -205,7 +208,7 @@ module.exports = (httpServer) => {
item.status = execStatusEnum.socketInterrupt
}
})
await writeOneKeyRecord(execResult)
await onekeyDB.insertAsync(execResult)
isExecuting = false
execResult = []
execClient = []

View File

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

View File

@ -5,9 +5,11 @@ const CryptoJS = require('crypto-js')
const { Server } = require('socket.io')
const { sftpCacheDir } = require('../config')
const { verifyAuthSync } = require('../utils/verify-auth')
const { AESDecryptSync } = require('../utils/encrypt')
const { readSSHRecord, readHostList } = require('../utils/storage')
const { AESDecryptAsync } = require('../utils/encrypt')
const { isAllowedIp } = require('../utils/tools')
const { HostListDB, CredentialsDB } = require('../utils/db-class')
const hostListDB = new HostListDB().getInstance()
const credentialsDB = new CredentialsDB().getInstance()
// 读取切片
const pipeStream = (path, writeStream) => {
@ -222,32 +224,32 @@ module.exports = (httpServer) => {
let sftpClient = new SFTPClient()
consola.success('terminal websocket 已连接')
socket.on('create', async ({ host: ip, token }) => {
socket.on('create', async ({ hostId, token }) => {
const { code } = await verifyAuthSync(token, requestIP)
consola.log('code:', code)
if (code !== 1) {
socket.emit('token_verify_fail')
socket.disconnect()
return
}
const hostList = await readHostList()
const targetHostInfo = hostList.find(item => item.host === ip) || {}
const targetHostInfo = await hostListDB.findOneAsync({ _id: hostId })
if (!targetHostInfo) throw new Error(`Host with ID ${ hostId } not found`)
let { authType, host, port, username } = targetHostInfo
if (!host) return socket.emit('create_fail', `查找${ ip }】凭证信息失败`)
if (!host) return socket.emit('create_fail', `查找id【${ hostId }】凭证信息失败`)
let authInfo = { host, port, username }
// 解密放到try里面防止报错【commonKey必须配对, 否则需要重新添加服务器密钥】
if (authType === 'credential') {
let credentialId = await AESDecryptSync(targetHostInfo[authType])
const sshRecordList = await readSSHRecord()
let credentialId = await AESDecryptAsync(targetHostInfo[authType])
const sshRecordList = await credentialsDB.findAsync({})
const sshRecord = sshRecordList.find(item => item._id === credentialId)
authInfo.authType = sshRecord.authType
authInfo[authInfo.authType] = await AESDecryptSync(sshRecord[authInfo.authType])
authInfo[authInfo.authType] = await AESDecryptAsync(sshRecord[authInfo.authType])
} else {
authInfo[authType] = await AESDecryptSync(targetHostInfo[authType])
authInfo[authType] = await AESDecryptAsync(targetHostInfo[authType])
}
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 })
sftpClient
@ -258,7 +260,7 @@ module.exports = (httpServer) => {
return sftpClient.list('/')
})
.then((rootLs) => {
// 普通文件-、目录文件d、链接文件l
// 普通文件-、目录文件d、链接文件l
socket.emit('root_ls', rootLs) // 先返回根目录
listenInput(sftpClient, socket) // 监听前端请求
})

View File

@ -1,107 +1,104 @@
const path = require('path')
const { Server } = require('socket.io')
const { Client: SSHClient } = require('ssh2')
const { verifyAuthSync } = require('../utils/verify-auth')
const { AESDecryptSync } = require('../utils/encrypt')
const { readSSHRecord, readHostList } = require('../utils/storage')
const { asyncSendNotice } = require('../utils/notify')
const { sendNoticeAsync } = require('../utils/notify')
const { isAllowedIp, ping } = require('../utils/tools')
const { AESDecryptAsync } = require('../utils/encrypt')
const { HostListDB, CredentialsDB } = require('../utils/db-class')
const decryptAndExecuteAsync = require('../utils/decrypt-file')
const hostListDB = new HostListDB().getInstance()
const credentialsDB = new CredentialsDB().getInstance()
function createInteractiveShell(socket, sshClient) {
async function getConnectionOptions(hostId) {
const hostInfo = await hostListDB.findOneAsync({ _id: hostId })
if (!hostInfo) throw new Error(`Host with ID ${ hostId } not found`)
let { authType, host, port, username, name } = hostInfo
let authInfo = { host, port, username }
try {
if (authType === 'credential') {
let credentialId = await AESDecryptAsync(hostInfo[authType])
const sshRecord = await credentialsDB.findOneAsync({ _id: credentialId })
authInfo.authType = sshRecord.authType
authInfo[authInfo.authType] = await AESDecryptAsync(sshRecord[authInfo.authType])
} else {
authInfo[authType] = await AESDecryptAsync(hostInfo[authType])
}
return { authInfo, name }
} catch (err) {
throw new Error(`解密认证信息失败: ${ err.message }`)
}
}
function createInteractiveShell(socket, targetSSHClient) {
return new Promise((resolve) => {
sshClient.shell({ term: 'xterm-color' }, (err, stream) => {
targetSSHClient.shell({ term: 'xterm-color' }, (err, stream) => {
resolve(stream)
if (err) return socket.emit('output', err.toString())
// 终端输出
stream
.on('data', (data) => {
socket.emit('output', data.toString())
})
.on('close', () => {
consola.info('交互终端已关闭')
sshClient.end()
targetSSHClient.end()
})
socket.emit('connect_shell_success') // 已连接终端web端可以执行指令了
})
})
}
// function execShell(sshClient, command = '', callback) {
// if (!command) return
// let result = ''
// sshClient.exec(`source ~/.bashrc && ${ command }`, (err, stream) => {
// if (err) return callback(err.toString())
// stream
// .on('data', (data) => {
// result += data.toString()
// })
// .stderr
// .on('data', (data) => {
// result += data.toString()
// })
// .on('close', () => {
// consola.info('一次性指令执行完成:', command)
// callback(result)
// })
// .on('error', (error) => {
// console.log('Error:', error.toString())
// })
// })
// }
async function createTerminal(hostId, socket, sshClient) {
// eslint-disable-next-line no-async-promise-executor
async function createTerminal(hostId, socket, targetSSHClient) {
return new Promise(async (resolve) => {
const hostList = await readHostList()
const targetHostInfo = hostList.find(item => item._id === hostId) || {}
let { authType, host, port, username, name } = targetHostInfo
if (!host) return socket.emit('create_fail', `查找hostId【${ hostId }】凭证信息失败`)
let authInfo = { host, port, username }
// 统一使用commonKey解密
const targetHostInfo = await hostListDB.findOneAsync({ _id: hostId })
if (!targetHostInfo) return socket.emit('create_fail', `查找hostId【${ hostId }】凭证信息失败`)
let { connectByJumpHosts = null } = (await decryptAndExecuteAsync(path.join(__dirname, 'plus.js'))) || {}
let { authType, host, port, username, name, jumpHosts } = targetHostInfo
try {
// 解密放到try里面防止报错【commonKey必须配对, 否则需要重新添加服务器密钥】
if (authType === 'credential') {
let credentialId = await 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])
let { authInfo: targetConnectionOptions } = await getConnectionOptions(hostId)
let jumpHostResult = connectByJumpHosts && (await connectByJumpHosts(jumpHosts, targetConnectionOptions.host, targetConnectionOptions.port, socket))
if (jumpHostResult) {
targetConnectionOptions.sock = jumpHostResult.sock
}
consola.info('准备连接终端:', host)
// targetHostInfo[targetHostInfo.authType] = await AESDecryptSync(targetHostInfo[targetHostInfo.authType])
socket.emit('terminal_print_info', `准备连接目标终端: ${ name } - ${ host }`)
socket.emit('terminal_print_info', `连接信息: ssh ${ username }@${ host } -p ${ port } -> ${ authType }`)
consola.info('准备连接目标终端:', host)
consola.log('连接信息', { username, port, authType })
sshClient
.on('ready', async() => {
asyncSendNotice('host_login', '终端登录', `别名: ${ name } \n IP${ host } \n 端口:${ port } \n 状态: 登录成功`)
let closeNoticeFlag = false // 避免重复发送通知
targetSSHClient
.on('ready', async () => {
sendNoticeAsync('host_login', '终端登录', `别名: ${ name } \n IP${ host } \n 端口:${ port } \n 状态: 登录成功`)
socket.emit('terminal_print_info', `终端连接成功: ${ name } - ${ host }`)
consola.success('终端连接成功:', host)
socket.emit('connect_terminal_success', `终端连接成功:${ host }`)
let stream = await createInteractiveShell(socket, sshClient)
let stream = await createInteractiveShell(socket, targetSSHClient)
resolve(stream)
// execShell(sshClient, 'history', (data) => {
// data = data.split('\n').filter(item => item)
// console.log(data)
// socket.emit('terminal_command_history', data)
// })
})
.on('close', () => {
consola.info('终端连接断开close: ', host)
socket.emit('connect_close')
.on('close', (err) => {
if (closeNoticeFlag) return closeNoticeFlag = false
const closeReason = err ? '发生错误导致连接断开' : '正常断开连接'
consola.info(`终端连接断开(${ closeReason }): ${ host }`)
socket.emit('connect_close', { reason: closeReason })
})
.on('error', (err) => {
consola.log(err)
asyncSendNotice('host_login', '终端登录', `别名: ${ name } \n IP${ host } \n 端口:${ port } \n 状态: 登录失败`)
closeNoticeFlag = true
sendNoticeAsync('host_login', '终端登录', `别名: ${ name } \n IP${ host } \n 端口:${ port } \n 状态: 登录失败`)
consola.error('连接终端失败:', host, err.message)
socket.emit('connect_fail', err.message)
socket.emit('connect_terminal_fail', err.message)
})
.on('keyboard-interactive', function (name, instructions, instructionsLang, prompts, finish) {
finish([targetConnectionOptions[authType]])
})
.connect({
...authInfo
// debug: (info) => console.log(info)
tryKeyboard: true,
...targetConnectionOptions
// debug: (info) => console.log(info)
})
} catch (err) {
consola.error('创建终端失败: ', host, err.message)
socket.emit('create_fail', err.message)
socket.emit('create_terminal_fail', err.message)
}
})
}
@ -110,11 +107,15 @@ module.exports = (httpServer) => {
const serverIo = new Server(httpServer, {
path: '/terminal',
cors: {
origin: '*' // 'http://localhost:8080'
origin: '*'
}
})
let connectionCount = 0
serverIo.on('connection', (socket) => {
// 前者兼容nginx反代, 后者兼容nodejs自身服务
connectionCount++
consola.success(`terminal websocket 已连接 - 当前连接数: ${ connectionCount }`)
let requestIP = socket.handshake.headers['x-forwarded-for'] || socket.handshake.address
if (!isAllowedIp(requestIP)) {
socket.emit('ip_forbidden', 'IP地址不在白名单中')
@ -122,7 +123,7 @@ module.exports = (httpServer) => {
return
}
consola.success('terminal websocket 已连接')
let sshClient = null
let targetSSHClient = null
socket.on('create', async ({ hostId, token }) => {
const { code } = await verifyAuthSync(token, requestIP)
if (code !== 1) {
@ -130,47 +131,21 @@ module.exports = (httpServer) => {
socket.disconnect()
return
}
sshClient = new SSHClient()
// 尝试手动断开调试再次连接后终端输出内容为4份相同的输出导致异常
// setTimeout(() => {
// sshClient.end()
// }, 3000)
targetSSHClient = new SSHClient()
let stream = null
function listenerInput(key) {
if (sshClient._sock.writable === false) return consola.info('终端连接已关闭,禁止输入')
if (targetSSHClient._sock.writable === false) return consola.info('终端连接已关闭,禁止输入')
stream && stream.write(key)
}
function resizeShell({ rows, cols }) {
// consola.info('更改tty终端行&列: ', { rows, cols })
stream && stream.setWindow(rows, cols)
}
socket.on('input', listenerInput)
socket.on('resize', resizeShell)
// 重连
socket.on('reconnect_terminal', async () => {
consola.info('重连终端: ', hostId)
socket.off('input', listenerInput) // 取消监听,重新注册监听,操作新的stream
socket.off('resize', resizeShell)
sshClient?.end()
sshClient?.destroy()
sshClient = null
stream = null
setTimeout(async () => {
// 初始化新的SSH客户端对象
sshClient = new SSHClient()
stream = await createTerminal(hostId, socket, sshClient)
socket.emit('reconnect_terminal_success')
socket.on('input', listenerInput)
socket.on('resize', resizeShell)
}, 3000)
})
stream = await createTerminal(hostId, socket, sshClient)
stream = await createTerminal(hostId, socket, targetSSHClient)
})
socket.on('get_ping',async (ip) => {
socket.on('get_ping', async (ip) => {
try {
socket.emit('ping_data', await ping(ip, 2500))
} catch (error) {
@ -179,7 +154,10 @@ module.exports = (httpServer) => {
})
socket.on('disconnect', (reason) => {
consola.info('终端socket连接断开:', reason)
connectionCount--
consola.info(`终端socket连接断开: ${ reason } - 当前连接数: ${ connectionCount }`)
})
})
}
module.exports.getConnectionOptions = getConnectionOptions

View File

@ -7,13 +7,16 @@ const {
notifyConfigDBPath,
groupConfDBPath,
scriptsDBPath,
onekeyDBPath
onekeyDBPath,
logDBPath,
plusDBPath
} = require('../config')
module.exports.KeyDB = class KeyDB {
constructor() {
if (!KeyDB.instance) {
KeyDB.instance = new Datastore({ filename: keyDBPath, autoload: true })
// KeyDB.instance.setAutocompactionInterval(5000)
}
}
getInstance() {
@ -25,6 +28,7 @@ module.exports.HostListDB = class HostListDB {
constructor() {
if (!HostListDB.instance) {
HostListDB.instance = new Datastore({ filename: hostListDBPath, autoload: true })
// HostListDB.instance.setAutocompactionInterval(5000)
}
}
getInstance() {
@ -32,14 +36,15 @@ module.exports.HostListDB = class HostListDB {
}
}
module.exports.SshRecordDB = class SshRecordDB {
module.exports.CredentialsDB = class CredentialsDB {
constructor() {
if (!SshRecordDB.instance) {
SshRecordDB.instance = new Datastore({ filename: credentialsDBPath, autoload: true })
if (!CredentialsDB.instance) {
CredentialsDB.instance = new Datastore({ filename: credentialsDBPath, autoload: true })
// CredentialsDB.instance.setAutocompactionInterval(5000)
}
}
getInstance() {
return SshRecordDB.instance
return CredentialsDB.instance
}
}
@ -47,6 +52,7 @@ module.exports.NotifyDB = class NotifyDB {
constructor() {
if (!NotifyDB.instance) {
NotifyDB.instance = new Datastore({ filename: notifyDBPath, autoload: true })
// NotifyDB.instance.setAutocompactionInterval(5000)
}
}
getInstance() {
@ -58,6 +64,7 @@ module.exports.NotifyConfigDB = class NotifyConfigDB {
constructor() {
if (!NotifyConfigDB.instance) {
NotifyConfigDB.instance = new Datastore({ filename: notifyConfigDBPath, autoload: true })
// NotifyConfigDB.instance.setAutocompactionInterval(5000)
}
}
getInstance() {
@ -69,6 +76,7 @@ module.exports.GroupDB = class GroupDB {
constructor() {
if (!GroupDB.instance) {
GroupDB.instance = new Datastore({ filename: groupConfDBPath, autoload: true })
// GroupDB.instance.setAutocompactionInterval(5000)
}
}
getInstance() {
@ -80,6 +88,7 @@ module.exports.ScriptsDB = class ScriptsDB {
constructor() {
if (!ScriptsDB.instance) {
ScriptsDB.instance = new Datastore({ filename: scriptsDBPath, autoload: true })
// ScriptsDB.instance.setAutocompactionInterval(5000)
}
}
getInstance() {
@ -91,9 +100,33 @@ module.exports.OnekeyDB = class OnekeyDB {
constructor() {
if (!OnekeyDB.instance) {
OnekeyDB.instance = new Datastore({ filename: onekeyDBPath, autoload: true })
// OnekeyDB.instance.setAutocompactionInterval(5000)
}
}
getInstance() {
return OnekeyDB.instance
}
}
module.exports.LogDB = class LogDB {
constructor() {
if (!LogDB.instance) {
LogDB.instance = new Datastore({ filename: logDBPath, autoload: true })
// LogDB.instance.setAutocompactionInterval(5000)
}
}
getInstance() {
return LogDB.instance
}
}
module.exports.PlusDB = class PlusDB {
constructor() {
if (!PlusDB.instance) {
PlusDB.instance = new Datastore({ filename: plusDBPath, autoload: true })
}
}
getInstance() {
return PlusDB.instance
}
}

View File

@ -0,0 +1,53 @@
const fs = require('fs-extra')
const path = require('path')
const CryptoJS = require('crypto-js')
const { AESDecryptAsync } = require('./encrypt')
const { PlusDB } = require('./db-class')
const plusDB = new PlusDB().getInstance()
function decryptAndExecuteAsync(plusPath) {
return new Promise(async (resolve) => {
try {
let { decryptKey } = await plusDB.findOneAsync({})
if (!decryptKey) {
throw new Error('缺少解密密钥')
}
decryptKey = await AESDecryptAsync(decryptKey)
const encryptedContent = fs.readFileSync(plusPath, 'utf-8')
const bytes = CryptoJS.AES.decrypt(encryptedContent, decryptKey)
const decryptedContent = bytes.toString(CryptoJS.enc.Utf8)
if (!decryptedContent) {
throw new Error('解密失败,请检查密钥是否正确')
}
const customRequire = (modulePath) => {
if (modulePath.startsWith('.')) {
const absolutePath = path.resolve(path.dirname(plusPath), modulePath)
return require(absolutePath)
}
return require(modulePath)
}
const module = {
exports: {},
require: customRequire,
__filename: plusPath,
__dirname: path.dirname(plusPath)
}
const wrapper = Function('module', 'exports', 'require', '__filename', '__dirname',
decryptedContent + '\n return module.exports;'
)
const exports = wrapper(
module,
module.exports,
customRequire,
module.__filename,
module.__dirname
)
resolve(exports)
} catch (error) {
consola.info('解锁plus功能失败: ', error.message)
resolve(null)
}
})
}
module.exports = decryptAndExecuteAsync

View File

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

View File

@ -0,0 +1,93 @@
const { getLocalNetIP } = require('./tools')
const { AESEncryptAsync } = require('./encrypt')
const version = require('../../package.json').version
const { plusServer1, plusServer2 } = require('./plus-server')
const { PlusDB } = require('./db-class')
const plusDB = new PlusDB().getInstance()
async function getLicenseInfo(key = '') {
const { key: plusKey } = await plusDB.findOneAsync({}) || {}
// console.log('plusKey: ', plusKey)
// console.log('key: ', key)
// console.log('process.env.PLUS_KEY: ', process.env.PLUS_KEY)
key = key || plusKey || process.env.PLUS_KEY
if (!key || key.length < 16) return { success: false, msg: 'Invalid Plus Key' }
let ip = ''
if (global.serverIp && (Date.now() - global.getServerIpLastTime) / 1000 / 60 < 60) {
ip = global.serverIp
consola.log('get server ip by cache: ', ip)
} else {
ip = await getLocalNetIP()
global.serverIp = ip
global.getServerIpLastTime = Date.now()
consola.log('get server ip by net: ', ip)
}
if (!ip) {
consola.error('activate plus failed: get public ip failed')
global.serverIp = ''
return { success: false, msg: 'get public ip failed' }
}
try {
let response
let method = 'POST'
let body = JSON.stringify({ ip, key, version })
let headers = { 'Content-Type': 'application/json' }
let timeout = 10000
try {
response = await fetch(plusServer1 + '/api/licenses/activate', {
method,
headers,
body,
timeout
})
if (!response.ok && (response.status !== 403)) {
throw new Error('port1 error')
}
} catch (error) {
consola.log('retry to activate plus by backup server')
response = await fetch(plusServer2 + '/api/licenses/activate', {
method,
headers,
body,
timeout
})
}
if (!response.ok) {
consola.log('activate plus failed: ', response.status)
if (response.status === 403) {
const errMsg = await response.json()
throw { errMsg, clear: true }
}
throw Error({ errMsg: `HTTP error! status: ${ response.status }` })
}
const { success, data } = await response.json()
if (success) {
let { decryptKey, expiryDate, usedIPCount, maxIPs, usedIPs } = data
decryptKey = await AESEncryptAsync(decryptKey)
consola.success('activate plus success')
const plusData = { key, decryptKey, expiryDate, usedIPCount, maxIPs, usedIPs }
let count = await plusDB.countAsync({})
if (count === 0) {
await plusDB.insertAsync(plusData)
} else {
await plusDB.removeAsync({}, { multi: true })
await plusDB.insertAsync(plusData)
}
return { success: true, msg: '激活成功' }
}
consola.error('activate plus failed: ', data)
return { success: false, msg: '激活失败' }
} catch (error) {
consola.error(`activate plus failed: ${ error.message || error.errMsg?.message }`)
if (error.clear) {
await plusDB.removeAsync({}, { multi: true })
}
return { success: false, msg: error.message || error.errMsg?.message }
}
}
module.exports = getLicenseInfo

View File

@ -1,10 +1,13 @@
const path = require('path')
const decryptAndExecuteAsync = require('./decrypt-file')
const nodemailer = require('nodemailer')
const axios = require('axios')
const { getNotifySwByType, readNotifyConfig } = require('../utils/storage')
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) {
if (!sendKey) return consola.error('发送server酱通知失败, sendKey 为空')
return new Promise((async (resolve, reject) => {
try {
consola.info('server酱通知预发送: ', title)
@ -26,7 +29,6 @@ function sendServerChan(sendKey, 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) => {
try {
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 {
let sw = await getNotifySwByType(noticeAction) // 获取对应动作的通知开关
console.log('notify swtich: ', noticeAction, sw)
if (!sw) return
let notifyConfig = await readNotifyConfig()
let notifyList = await notifyDB.findAsync({})
let { sw } = notifyList.find((item) => item.type === noticeAction) // 获取对应动作的通知开关
// console.log('notify swtich: ', noticeAction, sw)
if (!sw) return consola.info('通知开关关闭, 不发送通知: ', noticeAction)
let notifyConfig = await notifyConfigDB.findOneAsync({})
let { type } = notifyConfig
if (!type) return consola.error('通知类型不存在: ', type)
title = `EasyNode-${ title }`
@ -75,7 +78,15 @@ async function asyncSendNotice(noticeAction, title, content) {
if (!service || !user || !pass) return consola.info('未发送邮件通知通知, 未配置邮箱: ', { service, user, pass })
await sendEmail({ service, user, pass }, title, content)
break
case 'tg':
let { token, chatId } = notifyConfig['tg']
if (!token || !chatId) return consola.info('未发送Telegram通知, 未配置token或chatId: ', { token, chatId })
let { sendTg } = await decryptAndExecuteAsync(path.join(__dirname, 'plus.js'))
if (!sendTg) return consola.info('未发送Telegram通知, Plus功能解析失败')
await sendTg({ token, chatId }, title, content)
break
default:
consola.info('未配置通知类型: ', type)
break
}
} catch (error) {
@ -84,7 +95,7 @@ async function asyncSendNotice(noticeAction, title, content) {
}
module.exports = {
asyncSendNotice,
sendNoticeAsync,
sendServerChan,
sendEmail
}

View File

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

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

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

View File

@ -1,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
}

View File

@ -89,6 +89,35 @@ const getNetIPInfo = async (searchIp = '') => {
}
}
const getLocalNetIP = async () => {
try {
let ipUrls = [
'http://whois.pconline.com.cn/ipJson.jsp?json=true',
'https://www.ip.cn/api/index?ip=&type=0',
'https://freeipapi.com/api/json'
]
let result = await Promise.allSettled(ipUrls.map(url => axios.get(url)))
let [pconline, ipCN, freeipapi] = result
if (pconline.status === 'fulfilled') {
let ip = pconline.value?.data?.ip
if (ip) return ip
}
if (ipCN.status === 'fulfilled') {
let ip = ipCN.value?.data?.ip
consola.log('ipCN:', ip)
if (ip) return ip
}
if (freeipapi.status === 'fulfilled') {
let ip = pconline.value?.data?.ipAddress
if (ip) return ip
}
return null
} catch (error) {
console.error('getIpInfo Error: ', error?.message || error)
return null
}
}
function isLocalIP(ip) {
// Check if IPv4 or IPv6 address
const isIPv4 = net.isIPv4(ip)
@ -159,7 +188,7 @@ const isIP = (ip = '') => {
return isIPv4.test(ip) || isIPv6.test(ip)
}
const randomStr = (len) =>{
const randomStr = (len) => {
len = len || 16
let str = 'ABCDEFGHJKMNPQRSTWXYZabcdefhijkmnprstwxyz2345678',
a = str.length,
@ -178,7 +207,7 @@ const getUTCDate = (num = 8) => {
}
const formatTimestamp = (timestamp = Date.now(), format = 'time') => {
if (typeof(timestamp) !== 'number') return '--'
if (typeof (timestamp) !== 'number') return '--'
let date = new Date(timestamp)
let padZero = (num) => String(num).padStart(2, '0')
let year = date.getFullYear()
@ -187,7 +216,7 @@ const formatTimestamp = (timestamp = Date.now(), format = 'time') => {
let hours = padZero(date.getHours())
let minute = padZero(date.getMinutes())
let second = padZero(date.getSeconds())
let weekday = ['周日', '周一', '周二', '周三', '周四', '周五', '周六' ]
let weekday = ['周日', '周一', '周二', '周三', '周四', '周五', '周六']
let week = weekday[date.getDay()]
switch (format) {
case 'date':
@ -284,6 +313,7 @@ const ping = (ip, timeout = 5000) => {
module.exports = {
getNetIPInfo,
getLocalNetIP,
throwError,
isIP,
randomStr,

View File

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

View File

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

View File

@ -1,56 +1,59 @@
{
"name": "server",
"version": "1.0.0",
"description": "easynode-server",
"bin": "./bin/www",
"scripts": {
"local": "cross-env EXEC_ENV=local nodemon index.js",
"prod": "cross-env EXEC_ENV=production nodemon index.js",
"start": "node ./index.js",
"lint": "eslint . --ext .js,.vue",
"lint:fix": "eslint . --ext .js,.jsx,.cjs,.mjs --fix"
},
"keywords": [],
"author": "",
"license": "ISC",
"nodemonConfig": {
"ignore": [
"*.json"
]
},
"dependencies": {
"@koa/cors": "^5.0.0",
"@seald-io/nedb": "^4.0.4",
"axios": "^1.7.4",
"consola": "^3.2.3",
"cross-env": "^7.0.3",
"crypto-js": "^4.2.0",
"dotenv": "^16.4.5",
"fs-extra": "^11.2.0",
"global": "^4.4.0",
"iconv-lite": "^0.6.3",
"jsonwebtoken": "^9.0.2",
"koa": "^2.15.3",
"koa-body": "^6.0.1",
"koa-compose": "^4.1.0",
"koa-compress": "^5.1.1",
"koa-jwt": "^4.0.4",
"koa-router": "^12.0.1",
"koa-sslify": "^5.0.1",
"koa-static": "^5.0.0",
"koa2-connect-history-api-fallback": "^0.1.3",
"log4js": "^6.9.1",
"node-os-utils": "^1.3.7",
"node-rsa": "^1.1.1",
"node-schedule": "^2.1.1",
"nodemailer": "^6.9.14",
"socket.io": "^4.7.5",
"socket.io-client": "^4.7.5",
"ssh2": "^1.15.0",
"ssh2-sftp-client": "^10.0.3"
},
"devDependencies": {
"eslint": "^8.56.0",
"nodemon": "^3.1.4"
}
}
{
"name": "server",
"version": "3.0.3",
"description": "easynode-server",
"bin": "./bin/www",
"scripts": {
"local": "cross-env EXEC_ENV=local nodemon index.js",
"prod": "cross-env EXEC_ENV=production nodemon index.js",
"start": "node ./index.js",
"lint": "eslint . --ext .js,.vue",
"lint:fix": "eslint . --ext .js,.jsx,.cjs,.mjs --fix"
},
"keywords": [],
"author": "",
"license": "ISC",
"nodemonConfig": {
"ignore": [
"*.json"
]
},
"dependencies": {
"@koa/cors": "^5.0.0",
"@seald-io/nedb": "^4.0.4",
"axios": "^1.7.4",
"consola": "^3.2.3",
"cross-env": "^7.0.3",
"crypto-js": "^4.2.0",
"dotenv": "^16.4.5",
"fs-extra": "^11.2.0",
"global": "^4.4.0",
"iconv-lite": "^0.6.3",
"jsonwebtoken": "^9.0.2",
"koa": "^2.15.3",
"koa-body": "^6.0.1",
"koa-compose": "^4.1.0",
"koa-compress": "^5.1.1",
"koa-jwt": "^4.0.4",
"koa-router": "^12.0.1",
"koa-sslify": "^5.0.1",
"koa-static": "^5.0.0",
"koa2-connect-history-api-fallback": "^0.1.3",
"log4js": "^6.9.1",
"node-os-utils": "^1.3.7",
"node-rsa": "^1.1.1",
"node-schedule": "^2.1.1",
"node-telegram-bot-api": "^0.66.0",
"nodemailer": "^6.9.14",
"qrcode": "^1.5.4",
"socket.io": "^4.7.5",
"socket.io-client": "^4.7.5",
"speakeasy": "^2.0.0",
"ssh2": "^1.15.0",
"ssh2-sftp-client": "^10.0.3"
},
"devDependencies": {
"eslint": "^8.56.0",
"nodemon": "^3.1.4"
}
}

View File

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

View File

@ -1,6 +1,6 @@
{
"name": "web",
"version": "2.2.7",
"version": "3.0.3",
"description": "easynode-web",
"private": true,
"scripts": {
@ -33,6 +33,7 @@
"codemirror": "^6.0.1",
"crypto-js": "^4.2.0",
"csv-parse": "^5.5.6",
"dayjs": "^1.11.13",
"element-plus": "^2.7.6",
"jsencrypt": "^3.3.2",
"pinia": "^2.1.7",
@ -45,6 +46,7 @@
"devDependencies": {
"@vitejs/plugin-vue": "^5.0.5",
"@vitejs/plugin-vue-jsx": "^4.0.0",
"code-inspector-plugin": "^0.17.2",
"eslint": "^8.56.0",
"eslint-plugin-vue": "^9.27.0",
"sass": "^1.77.7",

View File

@ -19,12 +19,18 @@ export default {
removeSSH(id) {
return axios({ url: `/remove-ssh/${ id }`, method: 'delete' })
},
// existSSH(host) {
// return axios({ url: '/exist-ssh', method: 'post', data: { host } })
// },
getPlusInfo() {
return axios({ url: '/plus-info', method: 'get' })
},
getPlusDiscount() {
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() {
return axios({ url: '/host-list', method: 'get' })
},
@ -34,6 +40,9 @@ export default {
updateHost(data) {
return axios({ url: '/host-save', method: 'put', data })
},
batchUpdateHost(data) {
return axios({ url: '/batch-update-host', method: 'put', data })
},
removeHost(data) {
return axios({ url: '/host-remove', method: 'post', data })
},
@ -47,29 +56,23 @@ export default {
return axios({ url: '/login', method: 'post', data })
},
getLoginRecord() {
return axios({ url: '/get-login-record', method: 'get' })
return axios({ url: '/log', method: 'get' })
},
updatePwd(data) {
return axios({ url: '/pwd', method: 'put', data })
},
// updateHostSort(data) {
// return axios({ url: '/host-sort', method: 'put', data })
// },
// getUserEmailList() {
// return axios({ url: '/user-email', method: 'get' })
// },
// getSupportEmailList() {
// return axios({ url: '/support-email', method: 'get' })
// },
// updateUserEmailList(data) {
// return axios({ url: '/user-email', method: 'post', data })
// },
// deleteUserEmail(email) {
// return axios({ url: `/user-email/${ email }`, method: 'delete' })
// },
// pushTestEmail(data) {
// return axios({ url: '/push-email', method: 'post', data })
// },
getMFA2QR() {
return axios({ url: '/mfa2-code', method: 'post' })
},
getMFA2Status() {
return axios({ url: '/mfa2-status', method: 'get' })
},
enableMFA2(data) {
return axios({ url: '/mfa2-enable', method: 'post', data })
},
disableMFA2() {
return axios({ url: '/mfa2-disable', method: 'post' })
},
getNotifyConfig() {
return axios({ url: '/notify-config', method: 'get' })
},
@ -94,8 +97,11 @@ export default {
deleteGroup(id) {
return axios({ url: `/group/${ id }`, method: 'delete' })
},
getScriptList() {
return axios({ url: '/script', method: 'get' })
getScriptList(params = {}) {
return axios({ url: '/script', method: 'get', params })
},
importScript(data) {
return axios({ url: '/import-script', method: 'post', data })
},
getLocalScriptList() {
return axios({ url: '/local-script', method: 'get' })
@ -109,6 +115,9 @@ export default {
deleteScript(id) {
return axios({ url: `/script/${ id }`, method: 'delete' })
},
batchRemoveScript(data) {
return axios({ url: '/batch-remove-script', method: 'post', data })
},
getOnekeyRecord() {
return axios({ url: '/onekey', method: 'get' })
},
@ -117,5 +126,11 @@ export default {
},
getEasynodeVersion() {
return axios({ url: '/version', method: 'get' })
},
getPlusConf() {
return axios({ url: '/plus-conf', method: 'get' })
},
updatePlusKey(data) {
return axios({ url: '/plus-conf', method: 'post', data })
}
}

View File

@ -5,8 +5,8 @@
</template>
<script setup>
import { ref, getCurrentInstance } from 'vue'
import zhCn from 'element-plus/es/locale/lang/zh-cn'
import { ref, getCurrentInstance } from 'vue'
const { proxy: { $store } } = getCurrentInstance()
const locale = ref(zhCn)

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.9 KiB

View File

@ -79,6 +79,9 @@ html.dark {
background-color: #6d6d6d;
}
.el-menu {
border-right: none;
}
.el-menu-item:not(.is-active):hover {
color: var(--el-menu-active-color);
}

View File

@ -1,4 +1,7 @@
// 滚动条
html {
font-size: 15px;
}
html,
body,
div,

View File

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

View File

@ -6,25 +6,7 @@
<h1 v-show="!menuCollapse">EasyNode</h1>
</Transition>
</div>
<el-menu
:default-active="defaultActiveMenu"
:collapse="menuCollapse"
class="menu"
:collapse-transition="true"
@select="handleSelect"
>
<el-menu-item v-for="(item, index) in menuList" :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>
<!-- <div class="logout_wrap">
<el-button type="info" link @click="handleLogout">退出登录</el-button>
</div> -->
<MenuList />
<div class="collapse" @click="handleCollapse">
<el-icon v-if="menuCollapse"><Expand /></el-icon>
<el-icon v-else><Fold /></el-icon>
@ -33,82 +15,17 @@
</template>
<script setup>
import { reactive, markRaw, getCurrentInstance, computed, watchEffect } from 'vue'
import { getCurrentInstance, computed } from 'vue'
import {
Menu as IconMenu,
Key,
Setting,
ScaleToOriginal,
ArrowRight,
Pointer,
FolderOpened,
Expand,
Fold
} from '@element-plus/icons-vue'
import { useRoute } from 'vue-router'
import MenuList from './menuList.vue'
const route = useRoute()
const { proxy: { $router, $store } } = getCurrentInstance()
let menuList = 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 { proxy: { $store } } = getCurrentInstance()
let menuCollapse = computed(() => $store.menuCollapse)
// 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)
}
const handleCollapse = () => {
$store.setMenuCollapse(!menuCollapse.value)
}
@ -136,7 +53,8 @@ const handleCollapse = () => {
position: absolute;
left: 52px;
font-size: 14px;
// color: #1890ff;
color: var(--el-menu-active-color);
font-weight: 600;
}
}
.collapse {

View File

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

View File

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

View File

@ -23,7 +23,7 @@
<template #footer>
<footer>
<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>
</div>
</footer>

View File

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

View File

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

View File

@ -1,8 +1,12 @@
<template>
<div class="top_bar_container">
<div class="bar_wrap">
<div class="mobile_menu_btn">
<el-icon @click="handleCollapse">
<Fold />
</el-icon>
</div>
<h2>{{ title }}</h2>
<!-- <el-icon><UserFilled /></el-icon> -->
<el-switch
v-model="isDark"
inline-prompt
@ -16,10 +20,15 @@
link
@click="visible = true"
>
关于 <span class="new_version">{{ isNew ? `(新版本可用)` : '' }}</span>
版本更新 <span class="new_version">{{ isNew ? `(新版本可用)` : '' }}</span>
</el-button>
<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>
<el-dropdown-menu>
<el-dropdown-item @click="handleLogout">
@ -28,64 +37,114 @@
</el-dropdown-menu>
</template>
</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>
<el-dialog
v-model="visible"
title="关于"
title="版本更新"
top="20vh"
width="30%"
:append-to-body="false"
:close-on-click-modal="false"
>
<div class="about_content">
<h1>EasyNode</h1>
<!-- <h1>EasyNode</h1> -->
<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">
新版本可用: {{ 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>更新日志<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>开源仓库: <a class="link" href="https://github.com/chaos-zhu/easynode" target="_blank">https://github.com/chaos-zhu/easynode</a></p>
<p>作者: <a class="link" href="https://github.com/chaos-zhu" target="_blank">chaoszhu</a></p>
<p>tg更新通知<a class="link" href="https://t.me/easynode_notify" target="_blank">https://t.me/easynode_notify</a></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 class="qrcode">
<img src="@/assets/wx.jpg" alt="">
<p>
TG更新通知频道<a class="link" href="https://t.me/easynode_notify" target="_blank">https://t.me/easynode_notify</a>
</p>
<div class="about_footer">
<el-button type="info" @click="visible = false">关闭</el-button>
</div>
</div>
</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>
</template>
<script setup>
import { ref, getCurrentInstance, computed } from 'vue'
import { User, Sunny, Moon } from '@element-plus/icons-vue'
import { ref, getCurrentInstance, computed, onMounted, onBeforeUnmount } from 'vue'
import { useRouter } from 'vue-router'
import { User, Sunny, Moon, Fold } from '@element-plus/icons-vue'
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)
let checkVersionErr = ref(false)
let currentVersion = ref(`v${ packageJson.version }`)
let latestVersion = ref(null)
const isNew = computed(() => latestVersion.value && latestVersion.value !== currentVersion.value)
const user = computed(() => $store.user)
const title = computed(() => $store.title)
const isPlusActive = computed(() => $store.isPlusActive)
let isNew = computed(() => latestVersion.value && latestVersion.value !== currentVersion.value)
let user = computed(() => $store.user)
let title = computed(() => $store.title)
let isDark = computed({
const isDark = computed({
get: () => $store.isDark,
set: (isDark) => {
$store.setTheme(isDark)
}
})
const handleCollapse = () => {
menuCollapse.value = !menuCollapse.value
}
const handleLogout = () => {
$store.clearJwtToken()
$store.removeJwtToken()
$message({ type: 'success', message: '已安全退出', center: true })
$router.push('/login')
}
const gotoPlusPage = () => {
router.push('/setting?tabKey=plus')
}
async function checkLatestVersion() {
const timeout = 3000
try {
@ -119,6 +178,32 @@ async function 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>
<style lang="scss" scoped>
@ -129,47 +214,228 @@ checkLatestVersion()
position: sticky;
top: 0;
z-index: 999;
.bar_wrap {
height: 100%;
display: flex;
justify-content: space-between;
align-items: center;
padding: 0 20px;
h2 {
font-size: 18px;
margin-right: auto;
}
.dark_switch {
margin-right: 15px;
}
.about_btn {
margin-right: 15px;
font-size: 14px;
.new_version {
color: red;
}
}
.username {
cursor: pointer;
}
}
.about_content {
h1 {
font-size: 18px;
font-weight: 600;
margin: 15px 0;
}
p {
line-height: 35px;
}
.qrcode {
text-align: center;
img {
width: 250px;
.username_wrap {
display: flex;
align-items: center;
.username {
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 {
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: 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;
}
}
}
}

View 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 }
}

View File

@ -10,6 +10,7 @@ import api from './api'
import App from './app.vue'
import './assets/scss/reset.scss'
import './assets/scss/global.scss'
import './assets/scss/mobile.scss'
const app = createApp(App)
elementPlugins(app)

View File

@ -18,7 +18,7 @@ export default {
},
// format: time OR date
formatTimestamp: (timestamp, format = 'time', afterSeparator = ':') => {
if(typeof(timestamp) !== 'number') return '--'
if (typeof(timestamp) !== 'number') return '--'
let date = new Date(timestamp)
let padZero = (num) => String(num).padStart(2, '0')
let year = date.getFullYear()

View File

@ -1,5 +1,6 @@
import { io } from 'socket.io-client'
import { defineStore, acceptHMRUpdate } from 'pinia'
import dayjs from 'dayjs'
import $api from '@/api'
import config from '@/config'
import { isHttps } from '@/utils'
@ -17,32 +18,35 @@ const useStore = defineStore({
localScriptList: [],
HostStatusSocket: null,
user: localStorage.getItem('user') || null,
token: sessionStorage.getItem('token') || localStorage.getItem('token') || null,
token: localStorage.getItem('token') || sessionStorage.getItem('token') || null,
title: '',
isDark: false,
menuCollapse: localStorage.getItem('menuCollapse') === 'true',
defaultBackgroundImages: [
'https://wmimg.com/i/1099/2024/08/66c42ff3cd6ab.png',
'https://wmimg.com/i/1099/2024/08/66c42ff3e3f45.png',
'https://wmimg.com/i/1099/2024/08/66c42ff411ffb.png',
'https://wmimg.com/i/1099/2024/08/66c42ff4c5753.png',
'https://wmimg.com/i/1099/2024/08/66c42ff4e8b4d.jpg',
'https://wmimg.com/i/1099/2024/08/66c42ff51ee3a.jpg',
'https://wmimg.com/i/1099/2024/08/66c42ff5db377.png',
'https://wmimg.com/i/1099/2024/08/66c42ff536a64.png',
'https://wmimg.com/i/1099/2024/08/66c42ff51d8dd.png',
'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: '',
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: {
async setJwtToken(token, isSession = true) {
@ -57,9 +61,9 @@ const useStore = defineStore({
async setTitle(title) {
this.$patch({ title })
},
async clearJwtToken() {
localStorage.clear('token')
sessionStorage.clear('token')
async removeJwtToken() {
localStorage.removeItem('token')
sessionStorage.removeItem('token')
this.$patch({ token: null })
},
async getMainData() {
@ -67,11 +71,13 @@ const useStore = defineStore({
await this.getHostList()
await this.getSSHList()
await this.getScriptList()
await this.getPlusInfo()
this.wsClientsStatus()
},
async getHostList() {
let { data: newHostList } = await $api.getHostList()
newHostList = newHostList.map(newHostObj => {
newHostObj.expired = dayjs(newHostObj.expired).format('YYYY-MM-DD')
const oldHostObj = this.hostList.find(({ id }) => id === newHostObj.id)
return oldHostObj ? Object.assign({}, { ...oldHostObj }, { ...newHostObj }) : newHostObj
})
@ -94,6 +100,23 @@ const useStore = defineStore({
const { data: localScriptList } = await $api.getLocalScriptList()
this.$patch({ localScriptList })
},
async getPlusInfo() {
const { data: plusInfo = {} } = await $api.getPlusInfo()
if (plusInfo?.expiryDate) {
const isPlusActive = new Date(plusInfo.expiryDate) > new Date()
this.$patch({ isPlusActive })
if (!isPlusActive) {
this.setTerminalSetting({ autoReconnect: false })
return
}
plusInfo.expiryDate = dayjs(plusInfo.expiryDate).format('YYYY-MM-DD')
plusInfo.expiryDate?.startsWith('9999') && (plusInfo.expiryDate = '永久授权')
this.$patch({ plusInfo })
} else {
this.$patch({ isPlusActive: false })
}
this.$patch({ plusInfo })
},
setTerminalSetting(setTarget = {}) {
let newConfig = { ...this.terminalConfig, ...setTarget }
localStorage.setItem('terminalConfig', JSON.stringify(newConfig))

View File

@ -1,15 +1,15 @@
// 终端连接状态
export const terminalStatus = {
CONNECTING: 'connecting',
RECONNECTING: 'reconnecting',
CONNECT_FAIL: 'connect_fail',
CONNECT_SUCCESS: 'connect_success'
}
export const terminalStatusList = [
{ value: terminalStatus.CONNECTING, label: '连接中', color: '#FFA500' },
{ value: terminalStatus.RECONNECTING, label: '重连中', color: '#FFA500' },
{ value: terminalStatus.CONNECT_FAIL, label: '连接失败', color: '#DC3545' },
{ value: terminalStatus.CONNECT_SUCCESS, label: '已连接', color: '#28A745' },
]
// other...
export const virtualKeyType = {
LONG_PRESS: 'long-press',
SINGLE_PRESS: 'single-press'
}

View File

@ -1,4 +1,3 @@
import { reactive } from 'vue'
import JSRsaEncrypt from 'jsencrypt'
import CryptoJS from 'crypto-js'
@ -93,7 +92,20 @@ export const sortDirTree = (tree = []) => {
}
sort(dirsAndlinks)
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 }) => {
@ -116,7 +128,7 @@ export const getSuffix = (name = '') => {
}
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 link = document.createElement('a')
@ -132,3 +144,12 @@ export const exportFile = (data, filename, mimeType = 'application/json') =>{
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')
}

View File

@ -10,7 +10,7 @@
{{ row.authType === 'privateKey' ? '密钥' : '密码' }}
</template>
</el-table-column>
<el-table-column label="操作">
<el-table-column width="160px" label="操作">
<template #default="{ row }">
<el-button type="primary" @click="handleChange(row)">修改</el-button>
<el-button v-show="row.id !== 'default'" type="danger" @click="removeSSH(row)">删除</el-button>
@ -84,12 +84,41 @@
</span>
</template>
</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>
</template>
<script setup>
import { ref, reactive, computed, nextTick, getCurrentInstance } from 'vue'
import { randomStr, AESEncrypt, RSAEncrypt } from '@utils/index.js'
import PlusSupportTip from '@/components/common/PlusSupportTip.vue'
const { proxy: { $api, $message, $messageBox, $store } } = getCurrentInstance()
@ -115,6 +144,7 @@ const updateFormRef = ref(null)
const privateKeyRef = ref(null)
let sshList = computed(() => $store.sshList)
let isPlusActive = computed(() => $store.isPlusActive)
let addCredentials = () => {
sshForm.id = null
@ -159,9 +189,9 @@ const removeSSH = ({ id, name }) => {
type: 'warning'
})
.then(async () => {
await $api.removeSSH(id) // credential
await $api.removeSSH(id)
await $store.getSSHList()
await $store.getHostList() // isConfig
await $store.getHostList()
$message.success('success')
})
}
@ -170,16 +200,40 @@ const handleClickUploadBtn = () => {
privateKeyRef.value.click()
}
const keyPasswordVisible = ref(false)
const keyPassword = ref('')
const tempPrivateKey = ref('')
const handleSelectPrivateKeyFile = (event) => {
let file = event.target.files[0]
let reader = new FileReader()
reader.onload = (e) => {
sshForm.privateKey = e.target.result
reader.onload = async (e) => {
const content = e.target.result
//
if (content.includes('ENCRYPTED')) {
tempPrivateKey.value = content
keyPasswordVisible.value = true
} else {
sshForm.privateKey = content
}
privateKeyRef.value.value = ''
}
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>
<style lang="scss" scoped>

View File

@ -4,9 +4,9 @@
<el-button type="primary" @click="addGroup">添加分组</el-button>
</div>
<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 label="关联实例数量">
<el-table-column label="关联实例数量" min-width="115px">
<template #default="{ row }">
<el-popover
v-if="row.hosts.list.length !== 0"
@ -28,7 +28,7 @@
<u v-else class="host_count">0</u>
</template>
</el-table-column>
<el-table-column label="操作">
<el-table-column label="操作" fixed="right" width="160px">
<template #default="{ row }">
<el-button type="primary" @click="handleChange(row)">修改</el-button>
<el-button v-show="row.id !== 'default'" type="danger" @click="deleteGroup(row)">删除</el-button>

View File

@ -46,10 +46,10 @@ onBeforeMount(async () => {
height: 100vh;
.main_container {
flex: 1;
height: 100vh;
height: 100%;
overflow: auto;
.router_box {
min-height: calc(100vh - 60px - 20px);
height: calc(100% - 60px - 20px);
background-color: #fff;
border-radius: 6px;
margin: 10px;

View File

@ -13,9 +13,10 @@
:model="loginForm"
:rules="rules"
:hide-required-asterisk="true"
:show-message="false"
label-suffix=""
label-width="90px"
:show-message="false"
label-position="top"
>
<el-form-item prop="loginName" label="用户名">
<el-input
@ -43,21 +44,23 @@
<el-form-item v-show="false" prop="pwd" label="密码">
<el-input v-model.trim="loginForm.pwd" />
</el-form-item>
<el-form-item prop="mfa2Token" label="MFA2验证码">
<el-input
v-model="loginForm.mfa2Token"
type="text"
placeholder="MFA2应用上的6位数字(未设置可忽略)"
autocomplete="off"
:trigger-on-focus="false"
clearable
autofocus
@keyup.enter="handleLogin"
/>
</el-form-item>
<el-form-item prop="jwtExpires" label="有效期">
<el-radio-group v-model="isSession" class="login-indate">
<el-radio :value="true">一次性会话</el-radio>
<el-radio :value="false">自定义(小时)</el-radio>
<el-input-number
v-model="loginForm.jwtExpires"
:disabled="isSession"
placeholder="单位:小时"
class="input"
:min="1"
:max="72"
value-on-clear="min"
size="small"
controls-position="right"
/>
<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-form-item>
</el-form>
@ -79,66 +82,82 @@
<script setup>
import { ref, reactive, onMounted, getCurrentInstance } from 'vue'
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 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 isSession = ref(true)
const notKey = ref(false)
const loading = ref(false)
const loginForm = reactive({
loginName: '',
pwd: '',
jwtExpires: 8
jwtExpires: 1,
mfa2Token: ''
})
const rules = reactive({
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 = () => {
loginFormRefs.value.validate().then(() => {
let { jwtExpires, loginName, pwd } = loginForm
jwtExpires = isSession.value ? '12h' : `${ jwtExpires }h`
if (!isSession.value) {
localStorage.setItem('jwtExpires', jwtExpires)
loginFormRefs.value.validate().then(async () => {
let { jwtExpires, loginName, pwd, mfa2Token } = loginForm
switch (expireTime.value) {
case expireEnum.ONE_SESSION:
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)
if (ciphertext === -1) return $message.error({ message: '公钥加载失败', center: true })
loading.value = true
$api.login({ loginName, ciphertext, jwtExpires })
.then(({ data, msg }) => {
const { token } = data
$store.setJwtToken(token, isSession.value)
$store.setUser(loginName)
$message.success({ message: msg || 'success', center: true })
if (loginName === 'admin' && pwd === 'admin') {
$messageBox.confirm('请立即修改初始用户名及密码!防止恶意扫描!', '警告', {
confirmButtonText: '确定',
showCancelButton: false,
type: 'warning'
})
.then(async () => {
$router.push('/setting')
})
} else {
$router.push('/')
}
})
.finally(() => {
loading.value = false
})
try {
let { data, msg } = await $api.login({ loginName, ciphertext, jwtExpires, mfa2Token: Number(mfa2Token) })
const { token } = data
$store.setJwtToken(token, expireEnum.ONE_SESSION === expireTime.value)
$store.setUser(loginName)
$message.success({ message: msg || 'success', center: true })
loginSuccess()
} finally {
loading.value = false
}
})
}
const loginSuccess = () => {
let { loginName, pwd } = loginForm
if (loginName === 'admin' && pwd === 'admin') {
$messageBox.confirm('请立即修改初始用户名及密码!防止恶意扫描!', 'Warning', {
confirmButtonText: '确定',
showCancelButton: false,
type: 'warning'
})
.then(async () => {
$router.push('/setting')
})
} else {
$router.push('/')
}
}
onMounted(async () => {
if (localStorage.getItem('jwtExpires')) loginForm.jwtExpires = Number(localStorage.getItem('jwtExpires'))
const { data } = await $api.getPubPem()
if (!data) return (notKey.value = true)
localStorage.setItem('publicKey', data)
$store.removeJwtToken()
})
</script>
@ -156,7 +175,7 @@ onMounted(async () => {
.login_box {
margin-top: -80px;
width: 500px;
width: 450px;
min-height: 250px;
padding: 20px;
border-radius: 6px;
@ -182,7 +201,7 @@ onMounted(async () => {
.login-indate {
display: flex;
flex-wrap: nowrap;
// flex-wrap: nowrap;
.input {
margin-left: -25px;

View File

@ -32,26 +32,41 @@
</div>
</template>
</el-table-column>
<el-table-column prop="name" label="实例">
<el-table-column
prop="name"
label="实例"
show-overflow-tooltip
min-width="120px"
>
<template #default="{ row }">
<span style="letter-spacing: 2px;"> {{ row.name }} </span> -
<span style="letter-spacing: 2px;"> {{ row.host }} </span> :
<span style="letter-spacing: 2px;"> {{ row.port }} </span>
</template>
</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 }">
<span> {{ row.command }} </span>
</template>
</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 }">
<el-tag :color="getStatusType(row.status)">
<span style="color: rgb(54, 52, 52);">{{ row.status }}</span>
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作">
<el-table-column label="操作" fixed="right" width="90px">
<template #default="{ row }">
<el-button
v-if="!row.pending"
@ -411,7 +426,7 @@ onActivated(async () => {
const { hostIds, execClientInstallScript } = route.query
if (!hostIds) return
if (execClientInstallScript === 'true') {
let clientInstallScript = 'curl -o- https://ghp.ci/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'
console.log(hostIds.split(','))
createExecShell(hostIds.split(','), clientInstallScript, 300)
// $messageBox.confirm(`${ host }`, 'Warning', {
@ -420,7 +435,7 @@ onActivated(async () => {
// type: 'warning'
// })
// .then(async () => {
// let clientInstallScript = 'curl -o- https://ghp.ci/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)
// })
}

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

View File

@ -1,14 +1,57 @@
<template>
<div class="scripts_container">
<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>
<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="name" label="名称" />
<el-table-column prop="description" label="描述" />
<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 v-if="row.index !== '--'">
<el-button type="primary" @click="handleChange(row)">修改</el-button>
@ -18,6 +61,19 @@
</template>
</el-table-column>
</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
v-model="formVisible"
width="600px"
@ -78,17 +134,44 @@
</span>
</template>
</el-dialog>
<ImportScript
v-model:show="importVisible"
@update-list="() => $store.getScriptList()"
/>
</div>
</template>
<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 formVisible = 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({
name: '',
@ -108,10 +191,12 @@ const rules = computed(() => {
const updateFormRef = ref(null)
let scriptList = computed(() => $store.scriptList)
const scriptList = computed(() => $store.scriptList)
const isPlusActive = computed(() => $store.isPlusActive)
let addScript = () => {
formData.id = null
formData.index = scriptList.value.reduce((acc, cur) => Math.max(acc, Number(cur.index) || 0), 0) + 1
isModify.value = false
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>
<style lang="scss" scoped>
@ -164,6 +292,13 @@ const handleRemove = ({ id, name }) => {
display: flex;
align-items: center;
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;
cursor: pointer;
}
.pagination-container {
margin-top: 20px;
display: flex;
justify-content: flex-end;
}
</style>

View File

@ -30,231 +30,261 @@
label-width="100px"
:show-message="false"
>
<transition-group name="list" mode="out-in" tag="div">
<el-form-item key="group" label="分组" prop="group">
<el-select
v-model="hostForm.group"
placeholder=""
clearable
style="width: 100%;"
>
<el-option
v-for="item in groupList"
:key="item.id"
:label="item.name"
:value="item.id"
/>
</el-select>
</el-form-item>
<el-form-item key="group" label="分组" prop="group">
<el-select
v-model="hostForm.group"
placeholder=""
clearable
style="width: 100%;"
>
<el-option
v-for="item in groupList"
:key="item.id"
:label="item.name"
:value="item.id"
/>
</el-select>
</el-form-item>
<el-form-item
v-if="!isBatchModify"
key="name"
label="名称"
prop="name"
>
<el-input
v-model="hostForm.name"
clearable
placeholder=""
autocomplete="off"
/>
</el-form-item>
<div key="instance_info" class="instance_info">
<el-form-item
v-if="!isBatchModify"
key="name"
label="名称"
prop="name"
key="host"
class="form_item_host"
label="主机"
prop="host"
>
<el-input
v-model="hostForm.name"
v-model.trim="hostForm.host"
clearable
placeholder="IP"
autocomplete="off"
/>
</el-form-item>
<el-form-item
key="port"
class="form_item_port"
label="端口"
prop="port"
>
<el-input
v-model.trim.number="hostForm.port"
clearable
placeholder=""
autocomplete="off"
/>
</el-form-item>
<div key="instance_info" class="instance_info">
<el-form-item
v-if="!isBatchModify"
key="host"
class="form_item_host"
label="主机"
prop="host"
>
<el-input
v-model.trim="hostForm.host"
clearable
placeholder="IP"
autocomplete="off"
/>
</el-form-item>
<el-form-item
key="port"
class="form_item_port"
label="端口"
prop="port"
>
<el-input
v-model.trim.number="hostForm.port"
clearable
placeholder=""
autocomplete="off"
/>
</el-form-item>
</div>
<el-form-item key="username" label="用户名" prop="username">
<el-autocomplete
v-model.trim="hostForm.username"
:fetch-suggestions="userSearch"
style="width: 100%;"
clearable
>
<template #default="{ item }">
<div class="value">{{ item.value }}</div>
</template>
</el-autocomplete>
</el-form-item>
<el-form-item key="authType" label="认证方式" prop="authType">
<el-radio v-model="hostForm.authType" value="privateKey">密钥</el-radio>
<el-radio v-model="hostForm.authType" value="password">密码</el-radio>
<el-radio v-model="hostForm.authType" value="credential">凭据</el-radio>
</el-form-item>
<el-form-item
v-if="hostForm.authType === 'privateKey'"
key="privateKey"
prop="privateKey"
label="密钥"
</div>
<el-form-item key="username" label="用户名" prop="username">
<el-autocomplete
v-model.trim="hostForm.username"
:fetch-suggestions="userSearch"
style="width: 100%;"
clearable
>
<el-button type="primary" size="small" @click="handleClickUploadBtn">
本地私钥...
</el-button>
<!-- <el-button type="primary" size="small" @click="handleClickUploadBtn">
从凭据导入...
</el-button> -->
<input
ref="privateKeyRef"
type="file"
name="privateKey"
style="display: none;"
@change="handleSelectPrivateKeyFile"
<template #default="{ item }">
<div class="value">{{ item.value }}</div>
</template>
</el-autocomplete>
</el-form-item>
<el-form-item key="authType" label="认证方式" prop="authType">
<el-radio v-model="hostForm.authType" value="privateKey">密钥</el-radio>
<el-radio v-model="hostForm.authType" value="password">密码</el-radio>
<el-radio v-model="hostForm.authType" value="credential">凭据</el-radio>
</el-form-item>
<el-form-item
v-if="hostForm.authType === 'privateKey'"
key="privateKey"
prop="privateKey"
label="密钥"
>
<el-button type="primary" size="small" @click="handleClickUploadBtn">
本地私钥...
</el-button>
<input
ref="privateKeyRef"
type="file"
name="privateKey"
style="display: none;"
autocomplete="off"
@change="handleSelectPrivateKeyFile"
>
<el-input
v-model="hostForm.privateKey"
type="textarea"
:rows="3"
clearable
autocomplete="off"
style="margin-top: 5px;"
placeholder="-----BEGIN RSA PRIVATE KEY-----"
/>
</el-form-item>
<el-form-item
v-if="hostForm.authType === 'password'"
key="password"
prop="password"
label="密码"
>
<el-input
v-model.trim="hostForm.password"
type="password"
placeholder=""
autocomplete="new-password"
clearable
show-password
/>
</el-form-item>
<el-form-item
v-if="hostForm.authType === 'credential'"
key="credential"
prop="credential"
label="凭据"
>
<el-select v-model="hostForm.credential" placeholder="">
<template #empty>
<div class="empty_text">
<span>无凭据数据,</span>
<el-button type="primary" link @click="toCredentials">
去添加
</el-button>
</div>
</template>
<el-option
v-for="item in sshList"
:key="item.id"
:label="item.name"
:value="item.id"
>
<div class="select_warp">
<span>{{ item.name }}</span>
<span class="auth_type_text">
{{ item.authType === 'privateKey' ? '密钥' : '密码' }}
</span>
</div>
</el-option>
</el-select>
</el-form-item>
<el-form-item
key="jumpHosts"
prop="jumpHosts"
label="跳板机"
>
<PlusSupportTip>
<el-select
v-model="hostForm.jumpHosts"
placeholder="支持多选,跳板机连接顺序从前到后"
multiple
:disabled="!isPlusActive"
>
<el-input
v-model="hostForm.privateKey"
type="textarea"
:rows="3"
clearable
autocomplete="off"
style="margin-top: 5px;"
placeholder="-----BEGIN RSA PRIVATE KEY-----"
/>
</el-form-item>
<el-form-item
v-if="hostForm.authType === 'password'"
key="password"
prop="password"
label="密码"
>
<el-input
v-model.trim="hostForm.password"
type="password"
placeholder=""
autocomplete="off"
clearable
show-password
/>
</el-form-item>
<el-form-item
v-if="hostForm.authType === 'credential'"
key="credential"
prop="credential"
label="凭据"
>
<el-select v-model="hostForm.credential" class="credential_select" placeholder="">
<template #empty>
<div class="empty_credential">
<span>无凭据数据,</span>
<el-button type="primary" link @click="toCredentials">
去添加
</el-button>
<div class="empty_text">
<span>无可用跳板机器</span>
</div>
</template>
<el-option
v-for="item in sshList"
v-for="item in confHostList"
:key="item.id"
:label="item.name"
:value="item.id"
>
<div class="auth_type_wrap">
<div class="select_wrap">
<span>{{ item.name }}</span>
<span class="auth_type_text">
{{ item.authType === 'privateKey' ? '密钥' : '密码' }}
</span>
</div>
</el-option>
</el-select>
</el-form-item>
<el-form-item key="command" prop="command" label="执行指令">
<el-input
v-model="hostForm.command"
type="textarea"
:rows="3"
clearable
autocomplete="off"
placeholder="连接服务器后自动执行的指令(例如: sudo -i)"
/>
</el-form-item>
</PlusSupportTip>
</el-form-item>
<el-form-item key="command" prop="command" label="登录指令">
<el-input
v-model="hostForm.command"
type="textarea"
:rows="3"
clearable
autocomplete="off"
placeholder="连接服务器后自动执行的指令(例如: sudo -i)"
/>
</el-form-item>
<el-form-item key="expired" label="到期时间" prop="expired">
<el-date-picker
v-model="hostForm.expired"
type="date"
style="width: 100%;"
value-format="x"
placeholder="实例到期时间"
/>
</el-form-item>
<el-form-item
v-if="hostForm.expired"
key="expiredNotify"
label="到期提醒"
prop="expiredNotify"
>
<el-tooltip content="将在实例到期前7、3、1天发送提醒(需在设置中绑定有效邮箱)" placement="right">
<el-switch v-model="hostForm.expiredNotify" :active-value="true" :inactive-value="false" />
</el-tooltip>
</el-form-item>
<el-form-item key="consoleUrl" label="控制台URL" prop="consoleUrl">
<el-input
v-model.trim="hostForm.consoleUrl"
clearable
placeholder="用于直达云服务商控制台"
autocomplete="off"
@keyup.enter="handleSave"
/>
</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
v-if="!isBatchModify"
key="index"
label="序号"
prop="index"
>
<el-input
v-model.trim.number="hostForm.index"
clearable
placeholder="用于实例列表中排序(填写数字)"
autocomplete="off"
/>
</el-form-item>
<el-form-item key="remark" label="备注" prop="remark">
<el-input
v-model="hostForm.remark"
type="textarea"
:rows="3"
clearable
autocomplete="off"
placeholder="简单记录实例用途"
/>
</el-form-item>
</transition-group>
<el-form-item key="expired" label="到期时间" prop="expired">
<el-date-picker
v-model="hostForm.expired"
type="date"
:editable="false"
style="width: 100%;"
value-format="x"
placeholder="实例到期时间"
/>
</el-form-item>
<el-form-item
v-if="hostForm.expired"
key="expiredNotify"
label="到期提醒"
prop="expiredNotify"
>
<el-tooltip content="将在实例到期前7、3、1天发送提醒(需在设置中绑定有效邮箱)" placement="right">
<el-switch v-model="hostForm.expiredNotify" :active-value="true" :inactive-value="false" />
</el-tooltip>
</el-form-item>
<el-form-item key="consoleUrl" label="控制台URL" prop="consoleUrl">
<el-input
v-model.trim="hostForm.consoleUrl"
clearable
placeholder="用于直达云服务商控制台"
autocomplete="off"
@keyup.enter="handleSave"
/>
</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
v-if="!isBatchModify"
key="index"
label="序号"
prop="index"
>
<el-input
v-model.trim.number="hostForm.index"
clearable
placeholder="用于实例列表中排序(填写数字)"
autocomplete="off"
/>
</el-form-item>
<el-form-item key="remark" label="备注" prop="remark">
<el-input
v-model="hostForm.remark"
type="textarea"
:rows="3"
clearable
autocomplete="off"
placeholder="简单记录实例用途"
/>
</el-form-item>
</el-form>
<template #footer>
<span class="dialog-footer">
<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>
</template>
</el-dialog>
@ -262,6 +292,7 @@
<script setup>
import { ref, computed, getCurrentInstance, nextTick } from 'vue'
import PlusSupportTip from '@/components/common/PlusSupportTip.vue'
import { RSAEncrypt, AESEncrypt, randomStr } from '@utils/index.js'
const { proxy: { $api, $router, $message, $store } } = getCurrentInstance()
@ -295,7 +326,7 @@ const formField = {
host: '',
port: 22,
username: 'root',
authType: 'privateKey',
authType: 'privateKey', // privateKey, password, credential
password: '',
privateKey: '',
credential: '', // credentials -> _id
@ -305,12 +336,12 @@ const formField = {
expiredNotify: false,
consoleUrl: '',
remark: '',
command: ''
command: '',
jumpHosts: []
}
let hostForm = ref({ ...formField })
let privateKeyRef = ref(null)
let oldHost = ref('')
let formRef = ref(null)
let isBatchModify = computed(() => props.isBatchModify)
@ -322,7 +353,8 @@ const rules = computed(() => {
name: { required: !isBatchModify.value, message: '输入实例别名', trigger: 'change' },
host: { required: !isBatchModify.value, message: '输入IP/域名', trigger: 'change' },
port: { required: !isBatchModify.value, type: 'number', message: '输入ssh端口', trigger: 'change' },
clientPort: { required: false, 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' },
// password: [{ required: hostForm.authType === 'password', trigger: 'change' },],
// privateKey: [{ required: hostForm.authType === 'privateKey', trigger: 'change' },],
@ -332,6 +364,7 @@ const rules = computed(() => {
remark: { required: false }
}
})
const isPlusActive = computed(() => $store.isPlusActive)
const visible = computed({
get: () => props.show,
@ -344,29 +377,38 @@ const title = computed(() => {
let groupList = computed(() => $store.groupList)
let sshList = computed(() => $store.sshList)
let hostList = computed(() => $store.hostList)
let confHostList = computed(() => {
return hostList.value?.filter(item => item.isConfig)
})
const setDefaultData = () => {
if (!defaultData.value) return
// eslint-disable-next-line no-unused-vars
let { host, monitorData, ...rest } = defaultData.value
oldHost.value = host
Object.assign(hostForm.value, { host, ...rest })
let { id, ...rest } = defaultData.value
for (let [key,] of Object.entries(hostForm.value)) {
if (rest[key] !== undefined) hostForm.value[key] = rest[key]
}
hostForm.value.id = id
}
const setBatchDefaultData = () => {
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 () => {
setDefaultData()
setBatchDefaultData()
if (isBatchModify.value) {
setBatchDefaultData()
} else {
setDefaultData()
}
await nextTick()
formRef.value.clearValidate()
}
const handleClosed = async () => {
emit('closed')
Object.assign(hostForm.value, { ...formField })
hostForm.value = { ...formField }
await nextTick()
formRef.value.resetFields()
}
@ -413,30 +455,39 @@ const handleSave = () => {
let formData = { ...hostForm.value }
if (isBatchModify.value) {
// eslint-disable-next-line
let updateFileData = Object.fromEntries(Object.entries(formData).filter(([key, value]) => Boolean(value))) //
if (Object.keys(updateFileData).length === 0) return $message.warning('没有任何修改')
// console.log(updateFileData)
let newHosts = batchHosts.value
.map(item => ({ ...item, ...updateFileData }))
.map(item => {
const { authType } = item
let tempKey = randomStr(16)
if (item[authType]) item[authType] = AESEncrypt(item[authType], tempKey)
item.tempKey = RSAEncrypt(tempKey)
return item
})
let { msg } = await $api.updateHost({ hosts: newHosts })
let updateFieldData = Object.fromEntries(Object.entries(formData).filter(([key, value]) => {
if (Array.isArray(value)) return value.length > 0
return Boolean(value)
})) //
let { authType = '' } = updateFieldData
if (authType && !updateFieldData[authType]) {
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)
updateFieldData[authType] = AESEncrypt(updateFieldData[authType], tempKey)
updateFieldData.tempKey = RSAEncrypt(tempKey)
}
let updateIds = batchHosts.value.map(item => item.id)
let { msg } = await $api.batchUpdateHost({ updateIds, updateFieldData })
$message({ type: 'success', center: true, message: msg })
} else {
let tempKey = randomStr(16)
let { authType } = formData
if (formData[authType]) formData[authType] = AESEncrypt(formData[authType], tempKey)
formData.tempKey = RSAEncrypt(tempKey)
if (formData[authType]) {
let tempKey = randomStr(16)
formData[authType] = AESEncrypt(formData[authType], tempKey)
formData.tempKey = RSAEncrypt(tempKey)
}
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 })
} else {
let { msg } = await $api.addHost(formData)
let { msg } = await $api.addHost({ ...formData })
$message({ type: 'success', center: true, message: msg })
}
}
@ -471,13 +522,13 @@ const handleSave = () => {
}
}
.empty_credential {
.empty_text {
display: flex;
align-items: center;
justify-content: center;
}
.auth_type_wrap {
.select_warp {
height: 100%;
display: flex;
align-items: center;

View File

@ -36,12 +36,15 @@
<el-descriptions-item label="位置" width="20%">
{{ row.monitorData?.ipInfo.country || '--' }} {{ row.monitorData?.ipInfo.regionName }}
</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>
</el-descriptions-item>
</el-descriptions>
<div v-else class="no_client_data">
监控客户端服务未连接无法获取实例监控数据<span class="link" @click="handleOnekey(row)">去安装</span>
客户端监控服务未安装或连接失败无法获取实例监控数据<span class="link" @click="handleOnekey(row)">去安装</span>
</div>
</template>
</el-table-column>
@ -79,18 +82,48 @@
</template>
</el-table-column>
<!-- <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 }">
<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-button type="primary" @click="handleUpdate(row)">配置</el-button>
<el-button type="danger" @click="handleRemoveHost(row)">删除</el-button>
<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
:disabled="row.isConfig"
effect="dark"
content="请先配置ssh连接信息"
placement="left"
>
<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-button type="primary" @click="handleUpdate(row)">配置</el-button>
<el-button type="danger" @click="handleRemoveHost(row)">删除</el-button>
</template>
</template>
</el-table-column>
</el-table>
@ -100,6 +133,8 @@
<script setup>
import { ref, computed, getCurrentInstance, nextTick } from '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()
@ -112,6 +147,7 @@ const props = defineProps({
const emit = defineEmits(['update-list', 'update-host', 'select-change',])
const { isMobileScreen } = useMobileWidth()
let tableRef = ref(null)
let hosts = computed(() => {

View File

@ -133,7 +133,7 @@ const handleCsvFile = (event) => {
const handleJsonFile = (event) => {
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文件')
let readerPromises = jsonFiles.map(file => {

View File

@ -3,10 +3,6 @@
<div class="server_group_header">
<!-- <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" @click="handleHiddenIP">
{{ hiddenIp ? '显示IP' : '隐藏IP' }}
</el-button> -->
<!-- <el-button type="primary" @click="importVisible = true">导入实例</el-button> -->
<el-dropdown trigger="click">
<el-button type="primary" class="group_action_btn">
导入导出<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">
<template #title>
<div class="group_title">
{{ groupName }}
{{ `${groupName}`+`${hosts.length ? `(${hosts.length})` : ''}` }}
</div>
</template>
<!-- <HostCard
v-for="(item, index) in hosts"
:key="index"
:host-info="item"
:hidden-ip="hiddenIp"
@update-host="handleUpdateHost"
@update-list="handleUpdateList"
/> -->
<HostTable
ref="hostTableRefs"
:hosts="hosts"
@ -90,17 +78,15 @@ import { exportFile } from '@/utils'
const { proxy: { $api, $store, $router, $message, $messageBox, $tools } } = getCurrentInstance()
let updateHostData = ref(null)
let hostFormVisible = ref(false)
let importVisible = ref(false)
let selectHosts = ref([])
let isBatchModify = ref(false)
const updateHostData = ref(null)
const hostFormVisible = ref(false)
const importVisible = ref(false)
const selectHosts = ref([])
const isBatchModify = ref(false)
const hostTableRefs = ref([])
const activeGroup = ref([])
let hiddenIp = ref(Number(localStorage.getItem('hiddenIp') || 0))
let activeGroup = ref([])
let handleUpdateList = async () => {
const handleUpdateList = async () => {
try {
await $store.getHostList()
} catch (err) {
@ -168,7 +154,6 @@ let handleBatchOnekey = async () => {
let handleBatchExport = () => {
collectSelectHost()
if (!selectHosts.value.length) return $message.warning('请选择要批量操作的实例')
console.log(selectHosts.value)
let exportData = JSON.parse(JSON.stringify(selectHosts.value))
exportData = exportData.map(item => {
delete item.monitorData
@ -179,11 +164,6 @@ let handleBatchExport = () => {
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 groupHostList = computed(() => {

View File

@ -69,20 +69,45 @@
/>
</el-form-item>
</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-button type="primary" :loading="loading" @click="handleSave">
<el-button
type="primary"
:loading="loading"
@click="handleSave"
>
测试并保存
</el-button>
<!-- <el-tooltip effect="dark" content="重复添加的邮箱将会被覆盖" placement="right">
</el-tooltip> -->
</el-form-item>
</el-form>
</template>
<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 noticeConfig = ref({})
@ -95,14 +120,28 @@ const noticeTypeList = ref([
type: 'sct',
desc: 'Server酱'
},
{
type: 'tg',
desc: 'Telegram'
},
])
const formRef = ref(null)
const isPlusActive = computed(() => $store.isPlusActive)
const rules = reactive({
'sct.sendKey': { required: true, message: '需输入sendKey', trigger: 'change' },
'email.service': { required: true, 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 = () => {

View File

@ -1,7 +1,15 @@
<template>
<el-alert type="success" :closable="false">
<el-alert v-if="allowedIPs" type="success" :closable="false">
<template #title>
<span style="letter-spacing: 2px;"> 系统只保存最近10条登录记录, 目前版本只保存在内存中, 重启面板服务后会丢失 </span>
<span style="letter-spacing: 2px;"> 登录白名单IP: </span>
<el-tag
v-for="(item, index) in allowedIPs"
:key="index"
class="allowed_ip_tag"
type="warning"
>
{{ item }}
</el-tag>
</template>
</el-alert>
<el-table v-loading="loading" :data="loginRecordList">
@ -22,12 +30,17 @@ const { proxy: { $api, $tools } } = getCurrentInstance()
const loginRecordList = ref([])
const loading = ref(false)
const total = ref('')
const allowedIPs = ref('')
const handleLookupLoginRecord = () => {
loading.value = true
$api.getLoginRecord()
.then(({ data }) => {
loginRecordList.value = data.map((item) => {
const { list, whiteList } = data
total.value = list.length
allowedIPs.value = whiteList || []
loginRecordList.value = list.map((item) => {
item.date = $tools.formatTimestamp(item.date)
return item
})
@ -43,4 +56,7 @@ onMounted(() => {
</script>
<style lang="scss" scoped>
.allowed_ip_tag {
margin: 0 5px;
}
</style>

View File

@ -0,0 +1,234 @@
<template>
<el-form
ref="formRef"
class="plus-form"
:model="formData"
:rules="rules"
:hide-required-asterisk="true"
label-suffix=""
label-width="86px"
:show-message="false"
@submit.prevent
>
<el-form-item label="Plus Key" prop="key" class="form_item">
<el-input
v-model.trim="formData.key"
clearable
placeholder=""
autocomplete="off"
class="input"
@keyup.enter.prevent="handleUpdate"
/>
</el-form-item>
<el-form-item>
<div class="form_footer">
<el-button type="primary" :loading="loading" @click="handleUpdate">立即激活</el-button>
<el-button type="success" @click="handlePlusSupport">
购买Plus
<el-icon class="el-icon--right"><TopRight /></el-icon>
</el-button>
<span v-if="!isPlusActive && discount" class="discount_wrapper" @click="handlePlusSupport">
<img
class="discount_badge"
src="@/assets/discount.png"
alt="Discount"
>
<span class="discount_content">{{ discountContent }}</span>
</span>
</div>
</el-form-item>
</el-form>
<!-- Plus 激活状态信息 -->
<div v-if="isPlusActive" class="plus_status">
<div class="status_header">
<span>Plus专属功能已激活</span>
</div>
<div class="status_info">
<div class="info_item">
<span class="label">到期时间:</span>
<span class="value holder">{{ plusInfo.expiryDate }}</span>
</div>
<div class="info_item">
<span class="label">授权IP数:</span>
<span class="value">{{ plusInfo.maxIPs }}</span>
</div>
<div class="info_item">
<span class="label">已授权IP数:</span>
<span class="value">{{ plusInfo.usedIPCount }}</span>
</div>
<div class="info_item ip_list">
<span class="label">已授权IP:</span>
<div class="ip_tags">
<el-tag
v-for="ip in plusInfo.usedIPs"
:key="ip"
size="small"
class="ip_tag"
>
{{ ip }}
</el-tag>
</div>
</div>
</div>
</div>
<PlusTable />
</template>
<script setup>
import { ref, reactive, onMounted, getCurrentInstance, computed } from 'vue'
import { ElMessageBox } from 'element-plus'
import { TopRight } from '@element-plus/icons-vue'
import { handlePlusSupport } from '@/utils'
import PlusTable from '@/components/plus-table.vue'
const { proxy: { $api, $message, $store } } = getCurrentInstance()
const errCount = ref(Number(localStorage.getItem('plusErrCount') || 0))
const loading = ref(false)
const formRef = ref(null)
const formData = reactive({
key: ''
})
const rules = reactive({
key: { required: true, message: '输入Plus Key', trigger: 'change' }
})
const discount = ref(false)
const discountContent = ref('')
const plusInfo = computed(() => $store.plusInfo)
const isPlusActive = computed(() => $store.isPlusActive)
const handleUpdate = () => {
formRef.value.validate()
.then(async () => {
try {
loading.value = true
let { key } = formData
await $api.updatePlusKey({ key })
$message({ type: 'success', center: true, message: '激活成功,感谢支持' })
localStorage.setItem('plusErrCount', 0)
} catch (error) {
localStorage.setItem('plusErrCount', ++errCount.value)
if (errCount.value > 3) {
ElMessageBox.confirm(
'激活失败请确认key正确(20位不规则字符串)有疑问请tg联系@chaoszhu。',
'Warning',
{
showCancelButton : false,
confirmButtonText: '确认',
type: 'warning'
}
)
}
}
})
.finally(() => {
loading.value = false
$store.getPlusInfo()
})
}
const getPlusConf = async () => {
try {
loading.value = true
let { data } = await $api.getPlusConf()
formData.key = data
} catch (error) {
$message({ type: 'error', center: true, message: error.message })
} finally {
loading.value = false
}
}
const getPlusDiscount = async () => {
const { data } = await $api.getPlusDiscount()
if (data?.discount) {
discount.value = data.discount
discountContent.value = data.content
}
}
onMounted(() => {
getPlusConf()
getPlusDiscount()
})
</script>
<style lang="scss" scoped>
.form_item {
.input {
width: 450px;
}
}
.form_footer {
height: 100%;
display: flex;
align-items: center;
justify-content: start;
margin-bottom: 15px;
.discount_wrapper {
height: 100%;
display: flex;
align-items: center;
cursor: pointer;
.discount_badge {
margin: 0 5px 0 10px;
width: 22px;
color: white;
font-size: 12px;
}
.discount_content {
font-size: 12px;
line-height: 1.3;
color: #ff4806;
text-decoration: underline;
}
}
}
.plus_status {
margin-bottom: 15px;
.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;
}
}
}
}
}
}
</style>

View File

@ -6,18 +6,19 @@
:rules="rules"
:hide-required-asterisk="true"
label-suffix=""
label-width="90px"
label-width="86px"
:show-message="false"
>
<el-form-item label="原用户名" prop="oldLoginName">
<el-form-item label="原用户名" prop="oldLoginName" class="form_item">
<el-input
v-model.trim="formData.oldLoginName"
clearable
placeholder=""
autocomplete="off"
class="input"
/>
</el-form-item>
<el-form-item label="原密码" prop="oldPwd">
<el-form-item label="原密码" prop="oldPwd" class="form_item">
<el-input
v-model.trim="formData.oldPwd"
type="password"
@ -25,17 +26,19 @@
show-password
placeholder=""
autocomplete="off"
class="input"
/>
</el-form-item>
<el-form-item label="新用户名" prop="newLoginName">
<el-form-item label="新用户名" prop="newLoginName" class="form_item">
<el-input
v-model.trim="formData.newLoginName"
clearable
placeholder=""
autocomplete="off"
class="input"
/>
</el-form-item>
<el-form-item label="新密码" prop="newPwd">
<el-form-item label="新密码" prop="newPwd" class="form_item">
<el-input
v-model.trim="formData.newPwd"
type="password"
@ -43,6 +46,7 @@
clearable
placeholder=""
autocomplete="off"
class="input"
@keyup.enter="handleUpdate"
/>
</el-form-item>
@ -50,13 +54,38 @@
<el-button type="primary" :loading="loading" @click="handleUpdate">确认</el-button>
</el-form-item>
</el-form>
<h2 class="mfa2_title">两步验证MFA2</h2>
<div v-if="isEnableMFA2">
<span class="enable_text">已启用</span>
<el-button class="disable_btn" type="danger" @click="handleDisableMFA2">禁用</el-button>
</div>
<template v-else>
<el-button v-if="startEnableMFA2" type="primary" @click="handleMFA2">启用</el-button>
<template v-else>
<div class="mfa2_container">
<!-- https://play.google.com/store/apps/details?id=com.google.android.apps.authenticator2 -->
<p>1. 使用MFA2应用(<a href="https://play.google.com/store/apps/details?id=com.google.android.apps.authenticator2" target="_blank" class="link">Google Authenticator</a> )扫描下面二维码或者输入秘钥 <span class="secret">{{ MFA2Data.secret }}</span></p>
<img :src="MFA2Data.qrImage" :alt="MFA2Data.secret">
<p>2. 输入MFA2应用上的6位数字</p>
<el-input
v-model="mfa2Token"
class="mfa2_input"
clearable
placeholder=""
autofocus
@keyup.enter="handleEnableMFA2"
/>
<el-button type="primary" @click="handleEnableMFA2">保存</el-button>
</div>
</template>
</template>
</template>
<script setup>
import { ref, reactive, getCurrentInstance } from 'vue'
import { ref, reactive, onMounted, getCurrentInstance } from 'vue'
import { RSAEncrypt } from '@utils/index.js'
const { proxy: { $api, $message, $store } } = getCurrentInstance()
const { proxy: { $api, $message, $messageBox, $store } } = getCurrentInstance()
const loading = ref(false)
const formRef = ref(null)
@ -73,6 +102,14 @@ const rules = reactive({
newPwd: { required: true, message: '输入新密码', trigger: 'change' }
})
const startEnableMFA2 = ref(true)
const isEnableMFA2 = ref(false)
const MFA2Data = ref({
qrImage: '',
secret: ''
})
const mfa2Token = ref('')
const handleUpdate = () => {
formRef.value.validate()
.then(async () => {
@ -89,10 +126,76 @@ const handleUpdate = () => {
formRef.value.resetFields()
})
}
const getMFA2Status = async () => {
let { data } = await $api.getMFA2Status()
isEnableMFA2.value = data
}
const handleMFA2 = async () => {
startEnableMFA2.value = false
let { data } = await $api.getMFA2QR()
MFA2Data.value = data
}
const handleEnableMFA2 = async () => {
if (!mfa2Token.value) return $message({ type: 'error', center: true, message: '请输入MFA2应用上的6位数字' })
let { msg } = await $api.enableMFA2({ token: mfa2Token.value })
$message({ type: 'success', center: true, message: msg })
getMFA2Status()
}
const handleDisableMFA2 = async () => {
$messageBox.confirm('确认禁用MFA2', 'Warning', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
.then(async () => {
let { msg } = await $api.disableMFA2()
$message({ type: 'success', center: true, message: msg })
getMFA2Status()
})
}
onMounted(() => {
getMFA2Status()
})
</script>
<style lang="scss" scoped>
.password-form {
width: 500px;
.form_item {
.input {
width: 450px;
}
}
}
.mfa2_title {
font-size: 18px;
margin-bottom: 20px;
}
.mfa2_container {
align-items: flex-start;
color: var(--el-text-color-regular);
font-size: var(--el-form-label-font-size);
line-height: 32px;
.secret {
color: var(--el-color-primary);
text-decoration: underline;
}
img {
width: 150px;
height: 150px;
border-radius: 5px;
}
.mfa2_input {
width: 150px;
margin-right: 15px;
}
}
.enable_text {
color: var(--el-color-primary);
}
.disable_btn {
margin: 0 15px;
}
</style>

View File

@ -1,33 +1,56 @@
<template>
<div class="setting_container">
<el-tabs tab-position="top">
<el-tab-pane label="修改密码" lazy>
<el-tabs v-model="tabKey" tab-position="top">
<el-tab-pane label="修改密码" name="user">
<User />
</el-tab-pane>
<el-tab-pane label="登录日志">
<el-tab-pane label="登录日志" name="record" lazy>
<Record />
</el-tab-pane>
<el-tab-pane label="全局通知" lazy>
<el-tab-pane label="全局通知" name="notify">
<GlobalNotify />
</el-tab-pane>
<el-tab-pane label="通知配置" lazy>
<el-tab-pane label="通知配置" name="notify-config">
<NotifyConfig />
</el-tab-pane>
<el-tab-pane label="Plus激活" name="plus">
<UserPlus />
</el-tab-pane>
</el-tabs>
</div>
</template>
<script setup>
import { watch, computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import GlobalNotify from './components/global-notify.vue'
// import EmailList from './components/email-list.vue'
import Record from './components/record.vue'
import User from './components/user.vue'
import NotifyConfig from './components/notify-config.vue'
import UserPlus from './components/user-plus.vue'
const route = useRoute()
const router = useRouter()
const tabKey = computed({
get() {
return route.query.tabKey || 'user'
},
set(newVal) {
router.push({ query: { tabKey: newVal } })
}
})
watch(() => tabKey.value, (newVal) => {
router.push({ query: { tabKey: newVal } })
})
</script>
<style lang="scss" scoped>
.setting_container {
height: 100%;
padding: 20px;
overflow: auto;
}
</style>

View File

@ -15,9 +15,11 @@
</template>
<span style="margin-right: 10px;">{{ host }}</span>
<template v-if="pingMs">
<span class="host-ping" :style="{backgroundColor: handlePingColor(pingMs)}">{{ pingMs }}ms</span>
<el-tooltip effect="dark" content="该值为EasyNode服务端主机到目标主机的ping值" placement="bottom">
<span class="host-ping" :style="{backgroundColor: handlePingColor(pingMs)}">{{ pingMs }}ms</span>
</el-tooltip>
</template>
<el-tag size="small" style="cursor: pointer;margin-left: 15px;" @click="handleCopy">复制</el-tag>
<el-tag size="small" style="cursor: pointer;margin-left: 10px;" @click="handleCopy">复制</el-tag>
</el-descriptions-item>
<el-descriptions-item>
<template #label>
@ -196,22 +198,6 @@
</div>
</el-descriptions-item>
</el-descriptions>
<el-divider content-position="center">FEATURE</el-divider>
<!-- <el-button
:type="sftpStatus ? 'primary' : 'success'"
style="display: block;width: 80%;margin: 30px auto;"
@click="handleSftp"
>
{{ sftpStatus ? '关闭SFTP' : '连接SFTP' }}
</el-button> -->
<el-button
:type="inputCommandStyle ? 'primary' : 'success'"
style="display: block;width: 80%;margin: 15px auto;"
@click="clickInputCommand"
>
长指令输入
</el-button>
</div>
</template>
<script setup>
@ -228,18 +214,12 @@ const props = defineProps({
required: true,
type: Boolean
},
showInputCommand: {
required: true,
type: Boolean
},
pingData: {
required: true,
type: Object
}
})
const emit = defineEmits(['update:inputCommandStyle', 'connect-sftp', 'click-input-command',])
const socket = ref(null)
const pingTimer = ref(null)
@ -275,12 +255,6 @@ const input = computed(() => {
if (inputMb >= 1) return `${ inputMb.toFixed(2) } MB/s`
return `${ (inputMb * 1024).toFixed(1) } KB/s`
})
const inputCommandStyle = computed({
get: () => props.showInputCommand,
set: (val) => {
emit('update:inputCommandStyle', val)
}
})
const pingMs = computed(() => {
let curPingData = props.pingData[host.value] || {}
@ -288,16 +262,6 @@ const pingMs = computed(() => {
return Number(curPingData?.time).toFixed(0)
})
// const handleSftp = () => {
// sftpStatus.value = !sftpStatus.value
// emit('connect-sftp', sftpStatus.value)
// }
const clickInputCommand = () => {
inputCommandStyle.value = true
emit('click-input-command')
}
const handleCopy = async () => {
await navigator.clipboard.writeText(host.value)
$message.success({ message: 'success', center: true })

View File

@ -92,7 +92,7 @@
</template>
</el-dropdown>
</div>
<div class="filter-input">
<div class="filter_input">
<el-input
v-model="filterKey"
size="small"
@ -104,7 +104,7 @@
v-if="showPathInput"
ref="pathInputRef"
v-model="pathInput"
class="path-input"
class="path_input"
size="small"
clearable
@blur="showPathInput = false"
@ -159,7 +159,7 @@
import { ref, computed, onMounted, onBeforeUnmount, getCurrentInstance } from 'vue'
import socketIo from 'socket.io-client'
import CodeEdit from '@/components/code-edit/index.vue'
import { EventBus, isDir, isFile, sortDirTree, downloadFile } from '@/utils'
import { EventBus, isDir, isFile, sortDirTree, downloadFile, isMobile } from '@/utils'
import dirIcon from '@/assets/image/system/dir.png'
import linkIcon from '@/assets/image/system/link.png'
import fileIcon from '@/assets/image/system/file.png'
@ -168,7 +168,7 @@ import unknowIcon from '@/assets/image/system/unknow.png'
const { io } = socketIo
const props = defineProps({
host: {
hostId: {
required: true,
type: String
}
@ -270,7 +270,7 @@ const connectSftp = () => {
socket.value.on('connect', () => {
console.log('/sftp socket已连接', socket.value.id)
listenSftp()
socket.value.emit('create', { host: props.host, token: token.value })
socket.value.emit('create', { hostId: props.hostId, token: token.value })
socket.value.on('root_ls', (tree) => {
let temp = sortDirTree(tree).filter((item) => isDir(item.type))
temp.unshift({ name: '/', type: 'd' })
@ -408,6 +408,7 @@ const handleClosedCode = () => {
}
const selectFile = (item) => {
if (isMobile()) openTarget(item)
curTarget.value = item
}
@ -696,12 +697,12 @@ defineExpose({
}
}
}
.filter-input {
.filter_input {
width: 200px;
min-width: 200px;
margin: 0 20px 0 10px;
}
.path-input {
.path_input {
width: 450px;
min-width: 450px;
}
@ -730,7 +731,7 @@ defineExpose({
}
li {
font-size: 14px;
padding: 5px 3px;
padding: 5px 0 5px 3px;
display: flex;
align-items: center;
// cursor: pointer;
@ -749,7 +750,7 @@ defineExpose({
}
}
.left {
width: 200px;
min-width: 200px;
border-right: 1px solid #dcdfe6;
.dir-list {
li:nth-child(n+2){

View File

@ -1,11 +1,12 @@
<template>
<el-dialog
<el-drawer
v-model="visible"
width="600px"
top="120px"
title="本地设置"
:append-to-body="false"
:close-on-click-modal="false"
:direction="isMobileScreen ? 'ttb' : 'ltr'"
:close-on-click-modal="true"
:close-on-press-escape="true"
:modal="true"
modal-class="local_setting_drawer"
>
<el-tabs tab-position="top">
<el-tab-pane label="终端设置" lazy>
@ -26,7 +27,7 @@
</el-select>
</el-form-item>
<el-form-item label="终端字体" prop="fontSize">
<el-input-number v-model="fontSize" :min="12" :max="30" />
<el-input-number v-model="fontSize" :min="6" :max="30" />
</el-form-item>
<el-form-item label="终端背景" prop="backgroundImage">
<ul class="background_list">
@ -40,13 +41,12 @@
</el-image>
</li>
<li
v-for="url in defaultBackgroundImages"
:key="url"
:class="background === url ? 'active' : ''"
@click="changeBackground(url)"
>
<el-image class="image" :src="url" />
</li>
v-for="item in defaultBackgroundImages"
:key="item"
:class="background === item ? 'active' : ''"
:style="`background: ${item};`"
@click="changeBackground(item)"
/>
</ul>
<div class="custom_background">
<el-input
@ -66,6 +66,22 @@
label-width="100px"
:show-message="false"
>
<el-form-item label="自动重连" prop="autoReconnect">
<PlusSupportTip>
<span>
<el-switch
v-model="autoReconnect"
class="swtich"
inline-prompt
:disabled="!isPlusActive"
style="--el-switch-on-color: #13ce66; --el-switch-off-color: #ff4949"
active-text="开启"
inactive-text="关闭"
/>
<span class="plus_support_tip_text">(Plus专属功能)</span>
</span>
</PlusSupportTip>
</el-form-item>
<el-form-item label="选中复制" prop="quickCopy">
<el-tooltip
effect="dark"
@ -122,12 +138,14 @@
<el-button @click="visible = false">关闭</el-button>
</span>
</template>
</el-dialog>
</el-drawer>
</template>
<script setup>
import { computed, getCurrentInstance } from 'vue'
import themeList from 'xterm-theme'
import useMobileWidth from '@/composables/useMobileWidth'
import PlusSupportTip from '@/components/common/PlusSupportTip.vue'
const { proxy: { $store } } = getCurrentInstance()
const props = defineProps({
@ -138,6 +156,7 @@ const props = defineProps({
})
const emit = defineEmits(['update:show',])
const { isMobileScreen } = useMobileWidth()
const defaultBackgroundImages = computed(() => $store.defaultBackgroundImages)
const visible = computed({
@ -160,6 +179,10 @@ const quickCopy = computed({
get: () => $store.terminalConfig.quickCopy,
set: (newVal) => $store.setTerminalSetting({ quickCopy: newVal })
})
const autoReconnect = computed({
get: () => $store.terminalConfig.autoReconnect,
set: (newVal) => $store.setTerminalSetting({ autoReconnect: newVal })
})
const quickPaste = computed({
get: () => $store.terminalConfig.quickPaste,
set: (newVal) => $store.setTerminalSetting({ quickPaste: newVal })
@ -168,9 +191,10 @@ const autoExecuteScript = computed({
get: () => $store.terminalConfig.autoExecuteScript,
set: (newVal) => $store.setTerminalSetting({ autoExecuteScript: newVal })
})
const isPlusActive = computed(() => $store.isPlusActive)
const changeBackground = (url) => {
background.value = url || ''
const changeBackground = (item) => {
background.value = item || ''
}
</script>
@ -179,7 +203,7 @@ const changeBackground = (url) => {
display: flex;
flex-wrap: wrap;
li {
width: 130px;
width: 126px;
height: 75px;
box-sizing: border-box;
border-radius: 3px;
@ -216,3 +240,15 @@ const changeBackground = (url) => {
justify-content: center;
}
</style>
<style lang="scss">
.local_setting_drawer {
.el-drawer__header {
margin-bottom: 0 !important;
}
}
.plus_support_tip_text {
margin-left: 5px;
color: var(--el-text-color-placeholder);
}
</style>

View File

@ -24,7 +24,7 @@ import socketIo from 'socket.io-client'
import themeList from 'xterm-theme'
import { terminalStatus } from '@/utils/enum'
const { CONNECTING, RECONNECTING, CONNECT_SUCCESS, CONNECT_FAIL } = terminalStatus
const { CONNECTING, CONNECT_SUCCESS, CONNECT_FAIL } = terminalStatus
const { io } = socketIo
const { proxy: { $api, $store, $serviceURI, $notification, $router, $message } } = getCurrentInstance()
@ -33,20 +33,27 @@ const props = defineProps({
hostObj: {
required: true,
type: Object
},
longPressCtrl: {
type: Boolean,
default: false
},
longPressAlt: {
type: Boolean,
default: false
}
})
const emit = defineEmits(['inputCommand', 'cdCommand', 'ping-data',])
const emit = defineEmits(['inputCommand', 'cdCommand', 'ping-data', 'reset-long-press',])
const socket = ref(null)
// const commandHistoryList = ref([])
const term = ref(null)
const command = ref('')
const initCommand = ref('')
const timer = ref(null)
const pingTimer = ref(null)
const fitAddon = ref(null)
// const searchBar = ref(null)
const hasRegisterEvent = ref(false)
const socketConnected = ref(false)
const curStatus = ref(CONNECTING)
@ -54,7 +61,7 @@ const terminal = ref(null)
const terminalRef = ref(null)
const token = computed(() => $store.token)
const theme = computed(() => themeList[$store.terminalConfig.theme])
const theme = computed(() => themeList[$store.terminalConfig.themeName])
const fontSize = computed(() => $store.terminalConfig.fontSize)
const background = computed(() => $store.terminalConfig.background)
const hostObj = computed(() => props.hostObj)
@ -64,6 +71,10 @@ const menuCollapse = computed(() => $store.menuCollapse)
const quickCopy = computed(() => $store.terminalConfig.quickCopy)
const quickPaste = computed(() => $store.terminalConfig.quickPaste)
const autoExecuteScript = computed(() => $store.terminalConfig.autoExecuteScript)
const autoReconnect = computed(() => $store.terminalConfig.autoReconnect)
const isPlusActive = computed(() => $store.isPlusActive)
const isLongPressCtrl = computed(() => props.longPressCtrl)
const isLongPressAlt = computed(() => props.longPressAlt)
watch(menuCollapse, () => {
nextTick(() => {
@ -81,7 +92,6 @@ watch(theme, () => {
watch(fontSize, () => {
nextTick(() => {
terminal.value.options.fontSize = fontSize.value
// fitAddon.value.fit()
handleResize()
})
})
@ -89,10 +99,8 @@ watch(fontSize, () => {
watch(background, (newVal) => {
nextTick(() => {
if (newVal) {
// terminal.value.options.theme.background = '#00000080'
terminal.value.options.theme = { ...theme.value, background: '#00000080' }
terminalRef.value.style.backgroundImage = `url(${ background.value })`
terminalRef.value.style.backgroundImage = `url(${ background.value })`
terminalRef.value.style.backgroundImage = background.value?.startsWith('http') ? `url(${ background.value })` : `${ background.value }`
// terminalRef.value.style.backgroundImage = `linear-gradient(rgba(0, 0, 0, 0.15), rgba(0, 0, 0, 0.15)), url(${ background.value })`
} else {
terminal.value.options.theme = theme.value
@ -108,14 +116,16 @@ watch(curStatus, () => {
const getCommand = async () => {
let { data } = await $api.getCommand(hostId.value)
if (data) command.value = data
if (data) initCommand.value = data
}
const connectIO = () => {
curStatus.value = CONNECTING
socket.value = io($serviceURI, {
path: '/terminal',
forceNew: false,
reconnectionAttempts: 1
reconnection: false,
reconnectionAttempts: 0
})
socket.value.on('connect', () => {
console.log('/terminal socket已连接', hostId.value)
@ -123,20 +133,14 @@ const connectIO = () => {
socketConnected.value = true
socket.value.emit('create', { hostId: hostId.value, token: token.value })
socket.value.on('connect_terminal_success', () => {
if (hasRegisterEvent.value) return // , . socket
hasRegisterEvent.value = true
socket.value.on('output', (str) => {
term.value.write(str)
terminalText.value += str
})
socket.value.on('connect_shell_success', () => {
curStatus.value = CONNECT_SUCCESS
onResize()
onFindText()
onWebLinks()
if (command.value) socket.value.emit('input', command.value + '\n')
shellResize()
if (initCommand.value) socket.value.emit('input', initCommand.value + '\n')
})
// socket.value.on('terminal_command_history', (data) => {
@ -147,7 +151,7 @@ const connectIO = () => {
if (pingTimer.value) clearInterval(pingTimer.value)
pingTimer.value = setInterval(() => {
socket.value.emit('get_ping', host.value)
socket.value?.emit('get_ping', host.value)
}, 3000)
socket.value.emit('get_ping', host.value) // ping
socket.value.on('ping_data', (pingMs) => {
@ -159,50 +163,79 @@ const connectIO = () => {
$router.push('/login')
})
socket.value.on('terminal_print_info', (msg) => {
term.value.write(`${ msg }\r\n`)
})
socket.value.on('connect_close', () => {
if (curStatus.value === CONNECT_FAIL) return //
curStatus.value = RECONNECTING
console.warn('连接断开,3秒后自动重连: ', hostId.value)
term.value.write('\r\n连接断开,3秒后自动重连...\r\n')
socket.value.emit('reconnect_terminal')
})
socket.value.on('reconnect_terminal_success', () => {
curStatus.value = CONNECT_SUCCESS
})
socket.value.on('create_fail', (message) => {
curStatus.value = CONNECT_FAIL
console.error('n创建失败:', hostId.value, message)
term.value.write(`\r\n创建失败: ${ message }\r\n`)
term.value.write('\r\n\x1b[91m终端主动断开连接, 回车重新发起连接\x1b[0m')
})
socket.value.on('connect_fail', (message) => {
socket.value.on('connect_terminal_fail', (message) => {
curStatus.value = CONNECT_FAIL
console.error('连接失败:', hostId.value, message)
term.value.write(`\r\n连接失败: ${ message }\r\n`)
term.value.write(`\r\n\x1b[91m连接终端失败: ${ message }, 回车重新发起连接\x1b[0m`)
})
socket.value.on('create_terminal_fail', (message) => {
curStatus.value = CONNECT_FAIL
term.value.write(`\r\n\x1b[91m创建终端失败: ${ message }, 回车重新发起连接\x1b[0m`)
})
})
socket.value.on('disconnect', () => {
console.warn('terminal websocket 连接断开')
socket.value.removeAllListeners() //
// socket.value.off('output') // output,onData
curStatus.value = CONNECT_FAIL
socketConnected.value = false
term.value.write('\r\nError: 与面板socket连接断开。请关闭此tab并检查本地与面板连接是否稳定\r\n')
socket.value.on('disconnect', (reason) => {
console.warn('terminal websocket 连接断开:', reason)
switch (reason) {
case 'io server disconnect':
reconnectTerminal(true, '服务端主动断开连接')
break
case 'io client disconnect': //
break
case 'transport close':
reconnectTerminal(true, '本地网络连接异常')
break
case 'transport error':
reconnectTerminal(true, '建立连接错误')
break
case 'parse error':
reconnectTerminal(true, '数据解析错误')
break
default:
reconnectTerminal(true, '连接意外断开')
}
})
socket.value.on('connect_error', (err) => {
console.error('terminal websocket 连接错误:', err)
console.error('EasyNode服务端连接错误', err)
curStatus.value = CONNECT_FAIL
term.value.write('\r\n\x1b[91mError: 连接失败,请检查EasyNode服务端是否正常, 回车重新发起连接\x1b[0m \r\n')
$notification({
title: '终端连接失败',
message: '请检查socket服务是否正常',
title: '服务端连接失败',
message: '请检查EasyNode服务端是否正常',
type: 'error'
})
})
}
const reconnectTerminal = (isCommonTips = false, tips) => {
socket.value.removeAllListeners()
socket.value.close()
socket.value = null
socketConnected.value = false
if (isCommonTips) {
if (isPlusActive.value && autoReconnect.value) {
term.value.write(`\r\n\x1b[91m${ tips },自动重连中...\x1b[0m \r\n`)
connectIO()
} else {
term.value.write(`\r\n\x1b[91mError: ${ tips },请重新连接。([功能项->本地设置->快捷操作]中开启自动重连)\x1b[0m \r\n`)
}
} else {
term.value.write(`\n${ tips } \n`)
connectIO()
}
}
const createLocalTerminal = () => {
let terminalInstance = new Terminal({
rendererType: 'dom',
@ -215,13 +248,6 @@ const createLocalTerminal = () => {
fontFamily: 'Cascadia Code, Menlo, monospace',
fontSize: fontSize.value,
theme: theme.value
// {
// foreground: '#ECECEC',
// background: '#000000', // 'transparent',
// // cursor: 'help',
// selection: '#ff9900',
// lineHeight: 20
// }
})
term.value = terminalInstance
terminalInstance.open(terminalRef.value)
@ -229,15 +255,20 @@ const createLocalTerminal = () => {
terminalInstance.writeln('\x1b[1;32mAn experimental Web-SSH Terminal\x1b[0m.')
terminalInstance.focus()
onSelectionChange()
onFindText()
onWebLinks()
onResize()
terminal.value = terminalInstance
}
const shellResize = () => {
fitAddon.value.fit()
let { rows, cols } = term.value
socket.value?.emit('resize', { rows, cols })
}
const onResize = () => {
fitAddon.value = new FitAddon()
term.value.loadAddon(fitAddon.value)
fitAddon.value.fit()
let { rows, cols } = term.value
socket.value.emit('resize', { rows, cols })
window.addEventListener('resize', handleResize)
}
@ -251,10 +282,7 @@ const handleResize = () => {
temp[index] = item.style.display
item.style.display = 'block'
})
fitAddon.value?.fit()
let { rows, cols } = term.value
socket.value?.emit('resize', { rows, cols })
shellResize()
panes.forEach((item, index) => {
item.style.display = temp[index]
})
@ -277,6 +305,7 @@ const onSelectionChange = () => {
term.value.onSelectionChange(() => {
if (!quickCopy.value) return
let str = term.value.getSelection()
console.log(str)
if (!str) return
const text = new Blob([str,], { type: 'text/plain' })
const item = new ClipboardItem({
@ -323,9 +352,26 @@ function extractLastCdPath(text) {
}
const onData = () => {
// term.value.off('data', listenerInput)
term.value.onData((key) => {
if (socketConnected.value === false) return
if ('\r' === key && curStatus.value === CONNECT_FAIL) {
reconnectTerminal(false, '重新连接中...')
return
}
if (!socket.value || !socketConnected.value) return
if (isLongPressCtrl.value || isLongPressAlt.value) {
const keyCode = key.toUpperCase().charCodeAt(0)
console.log('keyCode: ', keyCode)
const ansiCode = keyCode - 64
console.log('ansiCode:', ansiCode)
if (ansiCode >= 1 && ansiCode <= 26) {
const controlChar = String.fromCharCode(ansiCode)
socket.value.emit('input', isLongPressCtrl.value ? controlChar : `\x1b${ key }`)
}
emit('reset-long-press')
return
}
let acsiiCode = key.codePointAt()
// console.log(acsiiCode)
if (acsiiCode === 22) return handlePaste() // Ctrl + V
@ -333,12 +379,6 @@ const onData = () => {
enterTimer.value = setTimeout(() => {
if (enterTimer.value) clearTimeout(enterTimer.value)
if (key === '\r') { // Enter
if (curStatus.value === CONNECT_FAIL) { // &&
curStatus.value = CONNECTING
term.value.write('\r\n连接中...\r\n')
socket.value.emit('reconnect_terminal')
return
}
if (curStatus.value === CONNECT_SUCCESS) {
let cleanText = applyBackspace(filterAnsiSequences(terminalText.value))
const lines = cleanText.split('\n')
@ -348,7 +388,6 @@ const onData = () => {
// '$''#'
const commandStartIndex = lastLine.lastIndexOf('#') + 1
const commandText = lastLine.substring(commandStartIndex).trim()
// console.log('Processed command: ', commandText)
// eslint-disable-next-line
const cdPath = extractLastCdPath(commandText)

View File

@ -3,7 +3,7 @@
<div class="terminal_top">
<div class="left_menu">
<el-dropdown trigger="click">
<span class="link_text">连接管理<el-icon><arrow-down /></el-icon></span>
<span class="link_text">连接<el-icon><arrow-down /></el-icon></span>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item class="link_close_all" @click="handleCloseAllTab">
@ -47,9 +47,12 @@
</template>
</el-dropdown> -->
<el-dropdown trigger="click">
<span class="link_text">首选<el-icon><arrow-down /></el-icon></span>
<span class="link_text">功能<el-icon><arrow-down /></el-icon></span>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item @click="showInputCommand = true">
<span>长指令输入</span>
</el-dropdown-item>
<el-dropdown-item @click="handleFullScreen">
<span>启用全屏</span>
</el-dropdown-item>
@ -61,11 +64,16 @@
</el-dropdown>
</div>
<div class="right_overview">
<div v-if="isMobileScreen" class="switch_wrap">
<el-button :type="curHost?.monitorData?.connect ? 'success' : 'danger'" text @click="() => showMobileInfoSideDialog = true">
状态
</el-button>
</div>
<div class="switch_wrap">
<el-tooltip
effect="dark"
content="开启后同步键盘输入到所有会话"
placement="top"
placement="bottom"
>
<el-switch
v-model="isSyncAllSession"
@ -81,7 +89,7 @@
<el-tooltip
effect="dark"
content="SFTP文件传输"
placement="top"
placement="bottom"
>
<el-switch
v-model="showSftp"
@ -93,22 +101,31 @@
/>
</el-tooltip>
</div>
<!-- <el-icon class="full_icon">
<FullScreen class="icon" @click="handleFullScreen" />
</el-icon> -->
</div>
</div>
<div class="info_box">
<el-drawer
v-if="isMobileScreen"
v-model="showMobileInfoSideDialog"
:with-header="false"
direction="ltr"
class="mobile_menu_drawer"
>
<InfoSide
ref="infoSideRef"
:host-info="curHost"
:visible="visible"
:ping-data="pingData"
/>
</el-drawer>
<div v-else class="info_box">
<InfoSide
ref="infoSideRef"
v-model:show-input-command="showInputCommand"
:host-info="curHost"
:visible="visible"
:ping-data="pingData"
@click-input-command="clickInputCommand"
/>
</div>
<div class="terminals_sftp_wrap">
<div class="terminal_and_sftp_wrap">
<el-tabs
v-model="activeTabIndex"
type="border-card"
@ -134,27 +151,39 @@
<TerminalTab
ref="terminalRefs"
:host-obj="item"
:long-press-ctrl="longPressCtrl"
:long-press-alt="longPressAlt"
@input-command="terminalInput"
@cd-command="cdCommand"
@ping-data="getPingData"
@reset-long-press="resetLongPress"
/>
<FloatMenu
v-if="isMobileScreen"
:long-press-ctrl="longPressCtrl"
:long-press-alt="longPressAlt"
@click-key="handleClickVirtualKeyboard"
/>
<Sftp
v-if="showSftp"
ref="sftpRefs"
:host="item.host"
:host-id="item.id"
@resize="resizeTerminal"
/>
</div>
</el-tab-pane>
</el-tabs>
</div>
<InputCommand v-model:show="showInputCommand" @input-command="handleInputCommand" />
<HostForm
v-model:show="hostFormVisible"
:default-data="updateHostData"
@update-list="handleUpdateList"
@closed="updateHostData = null"
/>
<TerminalSetting v-model:show="showSetting" />
</div>
</template>
@ -162,13 +191,15 @@
<script setup>
import { ref, computed, getCurrentInstance, watch, onMounted, onBeforeUnmount, nextTick } from 'vue'
import { ArrowDown } from '@element-plus/icons-vue'
import useMobileWidth from '@/composables/useMobileWidth'
import InputCommand from '@/components/input-command/index.vue'
import FloatMenu from '@/components/float-menu/index.vue'
import { terminalStatusList, virtualKeyType } from '@/utils/enum'
import TerminalTab from './terminal-tab.vue'
import InfoSide from './info-side.vue'
import Sftp from './sftp.vue'
import InputCommand from '@/components/input-command/index.vue'
import HostForm from '../../server/components/host-form.vue'
import TerminalSetting from './terminal-setting.vue'
import { terminalStatusList } from '@/utils/enum'
const { proxy: { $nextTick, $store, $message } } = getCurrentInstance()
@ -180,7 +211,7 @@ const props = defineProps({
})
const emit = defineEmits(['closed', 'close-all-tab', 'removeTab', 'add-host',])
const { isMobileScreen } = useMobileWidth()
const showInputCommand = ref(false)
const infoSideRef = ref(null)
const pingData = ref({})
@ -194,6 +225,9 @@ const isSyncAllSession = ref(false)
const hostFormVisible = ref(false)
const updateHostData = ref(null)
const showSetting = ref(false)
const showMobileInfoSideDialog = ref(false)
const longPressCtrl = ref(false)
const longPressAlt = ref(false)
const terminalTabs = computed(() => props.terminalTabs)
const terminalTabsLen = computed(() => props.terminalTabs.length)
@ -227,7 +261,7 @@ const handleUpdateList = async ({ host }) => {
const handleResizeTerminalSftp = () => {
$nextTick(() => {
mainHeight.value = document.querySelector('.terminals_sftp_wrap')?.offsetHeight - 45 // 45 is tab-header height+15
mainHeight.value = document.querySelector('.terminal_and_sftp_wrap')?.offsetHeight - 45 // 45 is tab-header height+15
})
}
@ -245,6 +279,41 @@ const handleCloseAllTab = () => {
emit('close-all-tab')
}
const { LONG_PRESS, SINGLE_PRESS } = virtualKeyType
const handleClickVirtualKeyboard = async (virtualKey) => {
const { key, ansi ,type } = virtualKey
// console.log(key, ascii, ansi, type)
switch (type) {
case LONG_PRESS:
// console.log('')
if (key === 'Ctrl') {
longPressCtrl.value = true
longPressAlt.value = false
}
if (key === 'Alt') {
longPressAlt.value = true
longPressCtrl.value = false
}
// eslint-disable-next-line no-case-declarations
const curTerminalRef = terminalRefs.value[activeTabIndex.value]
await $nextTick()
curTerminalRef?.focusTab()
break
case SINGLE_PRESS:
longPressCtrl.value = false
longPressAlt.value = false
handleExecScript({ command: ansi })
break
default:
break
}
}
const resetLongPress = () => {
longPressCtrl.value = false
longPressAlt.value = false
}
const handleExecScript = (scriptObj) => {
let { command } = scriptObj
if (!isSyncAllSession.value) return handleInputCommand(command)
@ -311,10 +380,6 @@ watch(showSftp, () => {
// }
// }
const clickInputCommand = () => {
showInputCommand.value = true
}
const removeTab = (index) => {
emit('removeTab', index)
if (index === activeTabIndex.value) {
@ -325,7 +390,7 @@ const removeTab = (index) => {
}
const handleFullScreen = () => {
document.getElementsByClassName('terminals_sftp_wrap')[0].requestFullscreen()
document.getElementsByClassName('terminal_and_sftp_wrap')[0].requestFullscreen()
}
// const registryDbClick = () => {
@ -387,7 +452,7 @@ const handleInputCommand = async (command) => {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 15px;
padding: 0 5px 0 15px;
position: sticky;
top: 0;
background: var(--el-fill-color-light);
@ -410,7 +475,7 @@ const handleInputCommand = async (command) => {
color: var(--el-text-color-regular);
// color: var(--el-color-primary);
cursor: pointer;
margin-right: 15px;
margin-right: 10px;
.hidden_icon {
opacity: 0;
@ -448,7 +513,7 @@ const handleInputCommand = async (command) => {
border: var(--el-descriptions-table-border);
}
.terminals_sftp_wrap {
.terminal_and_sftp_wrap {
height: calc(100% - $terminalTopHeight);
overflow: hidden;
flex: 1;

View File

@ -10,7 +10,7 @@
}}</span>
</template>
</el-table-column>
<el-table-column v-show="!isAllConfssh">
<el-table-column fixed="right" width="80px">
<template #default="{ row }">
<div class="actios_btns">
<el-button
@ -71,9 +71,6 @@ const route = useRoute()
let showLinkTips = computed(() => !Boolean(terminalTabs.length))
let hostList = computed(() => $store.hostList)
let isAllConfssh = computed(() => {
return hostList.value?.every(item => item.isConfig)
})
function linkTerminal(hostInfo) {
let targetHost = hostList.value.find(item => item.id === hostInfo.id)
@ -121,10 +118,10 @@ onActivated(async () => {
<style lang="scss" scoped>
.terminal_container {
height: calc(100vh - 60px - 20px);
height: calc(100% - 60px - 20px);
overflow: auto;
.terminal_link_tips {
width: 50%;
width: 735px;
display: flex;
flex-direction: column;
justify-content: center;

View File

@ -1,5 +1,4 @@
import { fileURLToPath, URL } from 'url'
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import vueJsx from '@vitejs/plugin-vue-jsx'
@ -7,6 +6,7 @@ import AutoImport from 'unplugin-auto-import/vite'
import Components from 'unplugin-vue-components/vite'
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'
import viteCompression from 'vite-plugin-compression'
import { codeInspectorPlugin } from 'code-inspector-plugin'
const serviceURI = 'http://localhost:8082/'
const serviceApiPrefix = '/api/v1'
@ -16,7 +16,7 @@ export default defineConfig({
server: {
host: '0.0.0.0',
port: 18090,
strictPort: true,
// strictPort: true,
cors: true,
proxy: {
[serviceApiPrefix]: {
@ -57,12 +57,19 @@ export default defineConfig({
algorithm: 'gzip',
deleteOriginFile: false
}),
codeInspectorPlugin({
bundler: 'vite'
}),
],
css: {
preprocessorOptions: {
scss: {
additionalData: '@use "@/assets/scss/element/index.scss" as *;'
}
},
postcss: {
plugins: [
]
}
},
resolve: {

1300
yarn.lock

File diff suppressed because it is too large Load Diff