v1.2 release

This commit is contained in:
chaoszhu 2022-09-12 22:46:41 +08:00
parent 65f305df28
commit ca60f4a87c
61 changed files with 2079 additions and 739 deletions

96
server/.eslintrc.js Normal file
View File

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

7
server/.gitignore vendored Normal file
View File

@ -0,0 +1,7 @@
node_modules
app/static/upload/*
app/socket/temp/*
app/socket/.sftp-cache/*
app/logs/*
!.gitkeep
dist

40
server/README.md Normal file
View File

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

View File

@ -12,19 +12,23 @@ const getCertificate =() => {
}
}
module.exports = {
domain: '', // 域名(必须配置, 跨域使用[不配置将所有域名可访问api])
domain: 'xxx.com', // https域名, 可不配置
httpPort: 8082,
httpsPort: 8083,
clientPort: 22022, // 勿更改
certificate: getCertificate(),
uploadDir: path.join(process.cwd(),'./app/static/upload'),
staticDir: path.join(process.cwd(),'./app/static'),
sshRecordPath: path.join(__dirname,'./storage/ssh-record.json'),
keyPath: path.join(__dirname,'./storage/key.json'),
hostListPath: path.join(__dirname,'./storage/host-list.json'),
uploadDir: path.join(process.cwd(),'app/static/upload'),
staticDir: path.join(process.cwd(),'app/static'),
sftpCacheDir: path.join(process.cwd(),'app/socket/.sftp-cache'),
sshRecordPath: path.join(process.cwd(),'app/storage/ssh-record.json'),
keyPath: path.join(process.cwd(),'app/storage/key.json'),
hostListPath: path.join(process.cwd(),'app/storage/host-list.json'),
emailPath: path.join(process.cwd(),'app/storage/email.json'),
notifyPath: path.join(process.cwd(),'app/storage/notify.json'),
groupPath: path.join(process.cwd(),'app/storage/group.json'),
apiPrefix: '/api/v1',
logConfig: {
outDir: path.join(process.cwd(),'./app/logs'),
flag: false // 是否记录日志
recordLog: false // 是否记录日志
}
}

View File

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

View File

@ -0,0 +1,59 @@
const { readGroupList, writeGroupList, readHostList, writeHostList,randomStr } = require('../utils')
function getGroupList({ res }) {
const data = readGroupList()
res.success({ data })
}
const addGroupList = async ({ res, request }) => {
let { body: { name, index } } = request
if(!name) return res.fail({ data: false, msg: '参数错误' })
let groupList = readGroupList()
let group = { id: randomStr(), name, index }
groupList.push(group)
groupList.sort((a, b) => a.index - b.index)
writeGroupList(groupList)
res.success({ data: '新增成功' })
}
const updateGroupList = async ({ res, request }) => {
let { params: { id } } = request
let { body: { name, index } } = request
if(!id || !name) return res.fail({ data: false, msg: '参数错误' })
let groupList = readGroupList()
let idx = groupList.findIndex(item => item.id === id)
let group = { id, name, index }
if(idx === -1) return res.fail({ data: false, msg: `分组ID${ id }不存在` })
groupList.splice(idx, 1, group)
groupList.sort((a, b) => a.index - b.index)
writeGroupList(groupList)
res.success({ data: '修改成功' })
}
const removeGroup = async ({ res, request }) => {
let { params: { id } } = request
if(id ==='default') return res.fail({ data: false, msg: '保留分组, 禁止删除' })
let groupList = readGroupList()
let idx = groupList.findIndex(item => item.id === id)
if(idx === -1) return res.fail({ msg: '分组不存在' })
// 移除分组将所有该分组下host分配到default中去
let hostList = readHostList()
hostList = hostList.map((item) => {
if(item.group === groupList[idx].id) item.group = 'default'
return item
})
writeHostList(hostList)
groupList.splice(idx, 1)
writeGroupList(groupList)
res.success({ data: '移除成功' })
}
module.exports = {
addGroupList,
getGroupList,
updateGroupList,
removeGroup
}

View File

@ -6,22 +6,22 @@ function getHostList({ res }) {
}
function saveHost({ res, request }) {
let { body: { host: newHost, name } } = request
if(!newHost || !name) return res.fail({ msg: '参数错误' })
let { body: { host: newHost, name, expired, expiredNotify, group, consoleUrl, remark } } = request
if(!newHost || !name) return res.fail({ msg: 'missing params: name or host' })
let hostList = readHostList()
if(hostList.some(({ host }) => host === newHost)) return res.fail({ msg: `主机${ newHost }已存在` })
hostList.push({ host: newHost, name })
hostList.push({ host: newHost, name, expired, expiredNotify, group, consoleUrl, remark })
writeHostList(hostList)
res.success()
}
function updateHost({ res, request }) {
let { body: { host: newHost, name: newName, oldHost } } = request
let { body: { host: newHost, name: newName, oldHost, expired, expiredNotify, group, consoleUrl, remark } } = request
if(!newHost || !newName || !oldHost) return res.fail({ msg: '参数错误' })
let hostList = readHostList()
if(!hostList.some(({ host }) => host === oldHost)) return res.fail({ msg: `主机${ newHost }不存在` })
let targetIdx = hostList.findIndex(({ host }) => host === oldHost)
hostList.splice(targetIdx, 1, { name: newName, host: newHost })
hostList.splice(targetIdx, 1, { name: newName, host: newHost, expired, expiredNotify, group, consoleUrl, remark })
writeHostList(hostList)
res.success()
}

View File

@ -0,0 +1,89 @@
const {
readSupportEmailList,
readUserEmailList,
writeUserEmailList,
emailTransporter,
readNotifyList,
writeNotifyList } = require('../utils')
const commonTemp = require('../template/commonTemp')
function getSupportEmailList({ res }) {
const data = readSupportEmailList()
res.success({ data })
}
function getUserEmailList({ res }) {
const userEmailList = readUserEmailList().map(({ target, auth: { user } }) => ({ target, user }))
const supportEmailList = readSupportEmailList()
const data = userEmailList.map(({ target: userTarget, user: email }) => {
let name = supportEmailList.find(({ target: supportTarget }) => supportTarget === userTarget).name
return { name, email }
})
res.success({ data })
}
async function pushEmail({ res, request }) {
let { body: { toEmail, isTest } } = request
if(!isTest) return res.fail({ msg: '此接口暂时只做测试邮件使用, 需传递参数isTest: true' })
consola.info('发送测试邮件:', toEmail)
let { code, msg } = await emailTransporter({ toEmail, title: '测试邮件', html: commonTemp('邮件通知测试邮件') })
msg = msg && msg.message || msg
if(code === 0) return res.success({ msg })
return res.fail({ msg })
}
function updateUserEmailList({ res, request }) {
let { body: { target, auth } } = request
const supportList = readSupportEmailList()
let flag = supportList.some((item) => item.target === target)
if(!flag) return res.fail({ msg: `不支持的邮箱类型:${ target }` })
if(!auth.user || !auth.pass) return res.fail({ msg: 'missing params: auth.' })
let newUserEmail = { target, auth }
let userEmailList = readUserEmailList()
let idx = userEmailList.findIndex(({ auth: { user } }) => auth.user === user)
if(idx !== -1) userEmailList.splice(idx, 1, newUserEmail)
else userEmailList.unshift(newUserEmail)
const { code, msg } = writeUserEmailList(userEmailList)
if(code === 0) return res.success()
return res.fail({ msg })
}
function removeUserEmail({ res, request }) {
let { params: { email } } = request
const userEmailList = readUserEmailList()
let idx = userEmailList.findIndex(({ auth: { user } }) => user === email)
if(idx === -1) return res.fail({ msg: `删除失败, 不存在该邮箱:${ email }` })
userEmailList.splice(idx, 1)
const { code, msg } = writeUserEmailList(userEmailList)
if(code === 0) return res.success()
return res.fail({ msg })
}
function getNotifyList({ res }) {
const data = readNotifyList()
res.success({ data })
}
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 = readNotifyList()
let target = notifyList.find((item) => item.type === type)
if(!target) return res.fail({ msg: `更新失败, 不存在该通知类型:${ type }` })
target.sw = sw
console.log(notifyList)
writeNotifyList(notifyList)
res.success()
}
module.exports = {
pushEmail,
getSupportEmailList,
getUserEmailList,
updateUserEmailList,
removeUserEmail,
getNotifyList,
updateNotifyList
}

View File

@ -13,7 +13,7 @@ const updateSSH = async ({ res, request }) => {
else
sshRecord.splice(idx, 1, record)
writeSSHRecord(sshRecord)
console.log('新增凭证:', host)
consola.info('新增凭证:', host)
res.success({ data: '保存成功' })
}
@ -23,7 +23,7 @@ const removeSSH = async ({ res, request }) => {
let idx = sshRecord.findIndex(item => item.host === host)
if(idx === -1) return res.fail({ msg: '凭证不存在' })
sshRecord.splice(idx, 1)
console.log('移除凭证:', host)
consola.info('移除凭证:', host)
writeSSHRecord(sshRecord)
res.success({ data: '移除成功' })
}
@ -32,7 +32,7 @@ const existSSH = async ({ res, request }) => {
let { body: { host } } = request
let sshRecord = readSSHRecord()
let idx = sshRecord.findIndex(item => item.host === host)
console.log('查询凭证:', host)
consola.info('查询凭证:', host)
if(idx === -1) return res.success({ data: false }) // host不存在
res.success({ data: true }) // 存在
}
@ -42,7 +42,7 @@ const getCommand = async ({ res, request }) => {
if(!host) return res.fail({ data: false, msg: '参数错误' })
let sshRecord = readSSHRecord()
let record = sshRecord.find(item => item.host === host)
console.log('查询登录后执行的指令:', host)
consola.info('查询登录后执行的指令:', host)
if(!record) return res.fail({ data: false, msg: 'host not found' }) // host不存在
const { command } = record
if(!command) return res.success({ data: false }) // command不存在

View File

@ -1,5 +1,5 @@
const jwt = require('jsonwebtoken')
const { getNetIPInfo, readKey, writeKey, RSADecrypt, AESEncrypt, SHA1Encrypt } = require('../utils')
const { getNetIPInfo, readKey, writeKey, RSADecrypt, AESEncrypt, SHA1Encrypt, sendEmailToConfList, getNotifySwByType } = require('../utils')
const getpublicKey = ({ res }) => {
let { publicKey: data } = readKey()
@ -7,34 +7,59 @@ const getpublicKey = ({ res }) => {
res.success({ data })
}
const generateTokenAndRecordIP = async (clientIp) => {
console.log('密码校验成功, 准备生成token')
let { commonKey, jwtExpires } = readKey()
let token = jwt.sign({ date: Date.now() }, commonKey, { expiresIn: jwtExpires }) // 生成token
token = AESEncrypt(token) // 对称加密token后再传输给前端
console.log('aes对称加密token', token)
// 记录客户端登录IP用于判断是否异地(只保留最近10条)
const localNetIPInfo = await getNetIPInfo(clientIp)
global.loginRecord.unshift(localNetIPInfo)
if(global.loginRecord.length > 10) global.loginRecord = global.loginRecord.slice(0, 10)
return { token, jwtExpires }
}
let timer = null
const allowErrCount = 5 // 允许错误的次数
const forbidTimer = 60 * 5 // 禁止登录时间
let loginErrCount = 0 // 每一轮的登录错误次数
let loginErrTotal = 0 // 总的登录错误次数
let loginCountDown = forbidTimer
let forbidLogin = false
const login = async ({ res, request }) => {
let { body: { ciphertext }, ip: clientIp } = request
let { body: { ciphertext, jwtExpires }, ip: clientIp } = request
if(!ciphertext) return res.fail({ msg: '参数错误' })
if(forbidLogin) return res.fail({ msg: `禁止登录! 倒计时[${ loginCountDown }s]后尝试登录或重启面板服务` })
loginErrCount++
loginErrTotal++
if(loginErrCount >= allowErrCount) {
const { ip, country, city } = await getNetIPInfo(clientIp)
// 发送通知&禁止登录
let sw = getNotifySwByType('err_login')
if(sw) sendEmailToConfList('登录错误提醒', `重新登录次数: ${ loginErrTotal }<br/>地点:${ country + city }<br/>IP: ${ ip }`)
forbidLogin = true
loginErrCount = 0
// forbidTimer秒后解禁
setTimeout(() => {
forbidLogin = false
}, loginCountDown * 1000)
// 计算登录倒计时
timer = setInterval(() => {
if(loginCountDown <= 0){
clearInterval(timer)
loginCountDown = forbidTimer
return
}
loginCountDown--
}, 1000)
}
// 登录流程
try {
console.log('ciphertext', ciphertext)
// console.log('ciphertext', ciphertext)
let password = RSADecrypt(ciphertext)
// console.log('Decrypt解密password:', password)
let { pwd } = readKey()
if(password === 'admin' && pwd === 'admin') {
const { token, jwtExpires } = await generateTokenAndRecordIP(clientIp)
const token = await beforeLoginHandler(clientIp, jwtExpires)
return res.success({ data: { token, jwtExpires }, msg: '登录成功,请及时修改默认密码' })
}
password = SHA1Encrypt(password)
if(password !== pwd) return res.fail({ msg: '密码错误' })
const { token, jwtExpires } = await generateTokenAndRecordIP(clientIp)
const token = await beforeLoginHandler(clientIp, jwtExpires)
return res.success({ data: { token, jwtExpires }, msg: '登录成功' })
} catch (error) {
console.log('解密失败:', error)
@ -42,6 +67,28 @@ const login = async ({ res, request }) => {
}
}
const beforeLoginHandler = async (clientIp, jwtExpires) => {
loginErrCount = loginErrTotal = 0 // 登录成功, 清空错误次数
// consola.success('登录成功, 准备生成token', new Date())
// 生产token
let { commonKey } = readKey()
let token = jwt.sign({ date: Date.now() }, commonKey, { expiresIn: jwtExpires }) // 生成token
token = AESEncrypt(token) // 对称加密token后再传输给前端
// 记录客户端登录IP(用于判断是否异地且只保留最近10条)
const clientIPInfo = await getNetIPInfo(clientIp)
const { ip, country, city } = clientIPInfo || {}
// 邮件登录通知
let sw = getNotifySwByType('login')
if(sw) sendEmailToConfList('登录提醒', `地点:${ country + city }<br/>IP: ${ ip }`)
global.loginRecord.unshift(clientIPInfo)
if(global.loginRecord.length > 10) global.loginRecord = global.loginRecord.slice(0, 10)
return token
}
const updatePwd = async ({ res, request }) => {
let { body: { oldPwd, newPwd } } = request
let rsaOldPwd = RSADecrypt(oldPwd)
@ -49,9 +96,13 @@ const updatePwd = async ({ res, request }) => {
let keyObj = readKey()
if(oldPwd !== keyObj.pwd) return res.fail({ data: false, msg: '旧密码校验失败' })
// 旧密钥校验通过,加密保存新密码
newPwd = SHA1Encrypt(RSADecrypt(newPwd))
newPwd = RSADecrypt(newPwd) === 'admin' ? 'admin' : SHA1Encrypt(RSADecrypt(newPwd))
keyObj.pwd = newPwd
writeKey(keyObj)
let sw = getNotifySwByType('updatePwd')
if(sw) sendEmailToConfList('密码修改提醒', '面板登录密码已更改')
res.success({ data: true, msg: 'success' })
}

View File

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

0
server/app/logs/.gitkeep Normal file
View File

View File

@ -1,5 +1,10 @@
const consola = require('consola')
global.consola = consola
const { httpServer, httpsServer, clientHttpServer } = require('./server')
const initLocal = require('./init')
const scheduleJob = require('./schedule')
scheduleJob()
initLocal()

View File

@ -5,12 +5,11 @@ let whitePath = [
'/login',
'/get-pub-pem'
].map(item => (apiPrefix + item))
console.log('路由白名单:', whitePath)
consola.info('路由白名单:', whitePath)
const useAuth = async ({ request, res }, next) => {
const { path, headers: { token } } = request
console.log('path: ', path)
// console.log('token: ', token)
consola.info('verify path: ', path)
if(whitePath.includes(path)) return next()
if(!token) return res.fail({ msg: '未登录', status: 403 })
// 验证token

View File

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

View File

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

View File

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

View File

@ -1,58 +1,58 @@
const log4js = require('log4js')
const { outDir, flag } = require('../config').logConfig
log4js.configure({
appenders: {
// 控制台输出
out: {
type: 'stdout',
layout: {
type: 'colored'
}
},
// 保存日志文件
cheese: {
type: 'file',
maxLogSize: 512*1024, // unit: bytes 1KB = 1024bytes
filename: `${ outDir }/receive.log`
}
},
categories: {
default: {
appenders: [ 'out', 'cheese' ], // 配置
level: 'info' // 只输出info以上级别的日志
}
}
// pm2: true
})
const logger = log4js.getLogger()
const useLog = () => {
return async (ctx, next) => {
const { method, path, origin, query, body, headers, ip } = ctx.request
const data = {
method,
path,
origin,
query,
body,
ip,
headers
}
await next() // 等待路由处理完成,再开始记录日志
// 是否记录日志
if (flag) {
const { status, params } = ctx
data.status = status
data.params = params
data.result = ctx.body || 'no content'
if (String(status).startsWith(4) || String(status).startsWith(5))
logger.error(JSON.stringify(data))
else
logger.info(JSON.stringify(data))
}
}
}
const log4js = require('log4js')
const { outDir, recordLog } = require('../config').logConfig
log4js.configure({
appenders: {
// 控制台输出
out: {
type: 'stdout',
layout: {
type: 'colored'
}
},
// 保存日志文件
cheese: {
type: 'file',
maxLogSize: 512*1024, // unit: bytes 1KB = 1024bytes
filename: `${ outDir }/receive.log`
}
},
categories: {
default: {
appenders: [ 'out', 'cheese' ], // 配置
level: 'info' // 只输出info以上级别的日志
}
}
// pm2: true
})
const logger = log4js.getLogger()
const useLog = () => {
return async (ctx, next) => {
const { method, path, origin, query, body, headers, ip } = ctx.request
const data = {
method,
path,
origin,
query,
body,
ip,
headers
}
await next() // 等待路由处理完成,再开始记录日志
// 是否记录日志
if (recordLog) {
const { status, params } = ctx
data.status = status
data.params = params
data.result = ctx.body || 'no content'
if (String(status).startsWith(4) || String(status).startsWith(5))
logger.error(JSON.stringify(data))
else
logger.info(JSON.stringify(data))
}
}
}
module.exports = useLog()

View File

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

View File

@ -1,13 +1,13 @@
const router = require('../router')
// 路由中间件
const useRoutes = router.routes()
// 优化错误提示中间件
// 原先如果请求方法错误响应404
// 使用该中间件后请求方法错误会提示405 Method Not Allowed【get list ✔200 post /list ❌405】
const useAllowedMethods = router.allowedMethods()
module.exports = {
useRoutes,
useAllowedMethods
}
const router = require('../router')
// 路由中间件
const useRoutes = router.routes()
// 优化错误提示中间件
// 原先如果请求方法错误响应404
// 使用该中间件后请求方法错误会提示405 Method Not Allowed【get list ✔200 post /list ❌405】
const useAllowedMethods = router.allowedMethods()
module.exports = {
useRoutes,
useAllowedMethods
}

View File

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

View File

@ -1,9 +1,10 @@
const { updateSSH, removeSSH, existSSH, getCommand } = require('../controller/ssh-info')
const { getHostList, saveHost, updateHost, removeHost, updateHostSort } = require('../controller/host-info')
const { updateSSH, removeSSH, existSSH, getCommand } = require('../controller/ssh')
const { getHostList, saveHost, updateHost, removeHost, updateHostSort } = require('../controller/host')
const { login, getpublicKey, updatePwd, getLoginRecord } = require('../controller/user')
const { getSupportEmailList, getUserEmailList, updateUserEmailList, removeUserEmail, pushEmail, getNotifyList, updateNotifyList } = require('../controller/notify')
const { getGroupList, addGroupList, updateGroupList, removeGroup } = require('../controller/group')
// 路由统一管理
const routes = [
const ssh = [
{
method: 'post',
path: '/update-ssh',
@ -23,7 +24,9 @@ const routes = [
method: 'get',
path: '/command',
controller: getCommand
},
}
]
const host = [
{
method: 'get',
path: '/host-list',
@ -48,7 +51,9 @@ const routes = [
method: 'put',
path: '/host-sort',
controller: updateHostSort
},
}
]
const user = [
{
method: 'get',
path: '/get-pub-pem',
@ -70,5 +75,65 @@ const routes = [
controller: getLoginRecord
}
]
const notify = [
{
method: 'get',
path: '/support-email',
controller: getSupportEmailList
},
{
method: 'get',
path: '/user-email',
controller: getUserEmailList
},
{
method: 'post',
path: '/push-email',
controller: pushEmail
},
{
method: 'post',
path: '/user-email',
controller: updateUserEmailList
},
{
method: 'delete',
path: '/user-email/:email',
controller: removeUserEmail
},
{
method: 'get',
path: '/notify',
controller: getNotifyList
},
{
method: 'put',
path: '/notify',
controller: updateNotifyList
}
]
module.exports = routes
const group = [
{
method: 'get',
path: '/group',
controller: getGroupList
},
{
method: 'post',
path: '/group',
controller: addGroupList
},
{
method: 'delete',
path: '/group/:id',
controller: removeGroup
},
{
method: 'put',
path: '/group/:id',
controller: updateGroupList
}
]
module.exports = [].concat(ssh, host, user, notify, group)

View File

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

View File

@ -0,0 +1,7 @@
const offlineInspect = require('./offline-inspect')
const expiredNotify = require('./expired-notify')
module.exports = () => {
offlineInspect()
expiredNotify()
}

View File

@ -0,0 +1,39 @@
const schedule = require('node-schedule')
const { clientPort } = require('../config')
const { readHostList, sendEmailToConfList, getNotifySwByType, formatTimestamp, isProd } = require('../utils')
const testConnectAsync = require('../utils/test-connect')
let sendNotifyRecord = new Map()
const offlineJob = () => {
let sw = getNotifySwByType('host_offline')
if(!sw) return
consola.info('=====开始检测服务器状态=====', new Date())
for (const item of readHostList()) {
const { host, name } = item
// consola.info('start inpect:', host, name )
testConnectAsync({
port: clientPort ,
host: `http://${ host }`,
timeout: 3000,
retryTimes: 20 // 尝试重连次数
})
.then(() => {
// consola.success('测试连接成功:', host, name)
})
.catch((error) => {
consola.error('测试连接失败: ', host, name)
// 当前小时是否发送过通知
let curHourIsSend = sendNotifyRecord.has(host) && (sendNotifyRecord.get(host).sendTime === formatTimestamp(Date.now(), 'hour'))
if(curHourIsSend) return consola.info('当前小时已发送过通知: ', sendNotifyRecord.get(host).sendTime)
sendEmailToConfList('服务器离线提醒', `别名: ${ name }<br/>IP: ${ host }<br/>错误信息:${ error.message }`)
.then(() => {
sendNotifyRecord.set(host, { 'sendTime': formatTimestamp(Date.now(), 'hour') })
})
})
}
}
module.exports = () => {
if(!isProd()) return consola.info('本地开发不检测服务器离线状态')
schedule.scheduleJob('0 0/5 12 1/1 * ?', offlineJob)
}

View File

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

View File

View File

@ -9,7 +9,7 @@ let clientSockets = {}, clientsData = {}
function getClientsInfo(socketId) {
let hostList = readHostList()
hostList
.map(({ host }) => {
.map(({ host, name }) => {
let clientSocket = ClientIO(`http://${ host }:${ clientPort }`, {
path: '/client/os-info',
forceNew: true,
@ -21,13 +21,14 @@ function getClientsInfo(socketId) {
clientSockets[socketId].push(clientSocket)
return {
host,
name,
clientSocket
}
})
.map(({ host, clientSocket }) => {
.map(({ host, name, clientSocket }) => {
clientSocket
.on('connect', () => {
console.log('client connect success:', host)
consola.success('client connect success:', host, name)
clientSocket.on('client_data', (osData) => {
clientsData[host] = osData
})
@ -36,11 +37,11 @@ function getClientsInfo(socketId) {
})
})
.on('connect_error', (error) => {
console.log('client connect fail:', host, error.message)
consola.error('client connect fail:', host, name, error.message)
clientsData[host] = null
})
.on('disconnect', () => {
console.log('client connect disconnect:', host)
consola.info('client connect disconnect:', host, name)
clientsData[host] = null
})
})
@ -68,7 +69,7 @@ module.exports = (httpServer) => {
// 收集web端连接的id
clientSockets[socket.id] = []
console.log('client连接socketId: ', socket.id, 'clients-socket已连接数: ', Object.keys(clientSockets).length)
consola.info('client连接socketId: ', socket.id, 'clients-socket已连接数: ', Object.keys(clientSockets).length)
// 获取客户端数据
getClientsInfo(socket.id)
@ -89,7 +90,7 @@ module.exports = (httpServer) => {
// 当web端与服务端断开连接时, 服务端与每个客户端的socket也应该断开连接
clientSockets[socket.id].forEach(socket => socket.close && socket.close())
delete clientSockets[socket.id]
console.log('断开socketId: ', socket.id, 'clients-socket剩余连接数: ', Object.keys(clientSockets).length)
consola.info('断开socketId: ', socket.id, 'clients-socket剩余连接数: ', Object.keys(clientSockets).length)
})
})
})

View File

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

View File

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

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

@ -0,0 +1,256 @@
const { Server } = require('socket.io')
const SFTPClient = require('ssh2-sftp-client')
const rawPath = require('path')
const fs = require('fs')
const { readSSHRecord, verifyAuth, RSADecrypt, AESDecrypt } = require('../utils')
const { sftpCacheDir } = require('../config')
const CryptoJS = require('crypto-js')
function clearDir(path, rmSelf = false) {
let files = []
if(!fs.existsSync(path)) return consola.info('clearDir: 目标文件夹不存在')
files = fs.readdirSync(path)
files.forEach((file) => {
let curPath = path + '/' + file
if(fs.statSync(curPath).isDirectory()){
clearDir(curPath) //递归删除文件夹
fs.rmdirSync(curPath) // 删除文件夹
} else {
fs.unlinkSync(curPath) //删除文件
}
})
if(rmSelf) fs.rmdirSync(path)
consola.success('clearDir: 已清空缓存文件')
}
const pipeStream = (path, writeStream) => {
// console.log('path', path)
return new Promise(resolve => {
const readStream = fs.createReadStream(path)
readStream.on('end', () => {
fs.unlinkSync(path) // 删除已写入切片
resolve()
})
readStream.pipe(writeStream)
})
}
function listenInput(sftpClient, socket) {
socket.on('open_dir', async (path) => {
const exists = await sftpClient.exists(path)
if(!exists) return socket.emit('not_exists_dir', '目录不存在或当前不可访问')
try {
let dirLs = await sftpClient.list(path)
socket.emit('dir_ls', dirLs)
} catch (error) {
consola.error('open_dir Error', error.message)
socket.emit('sftp_error', error.message)
}
})
socket.on('rm_dir', async (path) => {
const exists = await sftpClient.exists(path)
if(!exists) return socket.emit('not_exists_dir', '目录不存在或当前不可访问')
try {
let res = await sftpClient.rmdir(path, true) // 递归删除
socket.emit('rm_success', res)
} catch (error) {
consola.error('rm_dir Error', error.message)
socket.emit('sftp_error', error.message)
}
})
socket.on('rm_file', async (path) => {
const exists = await sftpClient.exists(path)
if(!exists) return socket.emit('not_exists_dir', '文件不存在或当前不可访问')
try {
let res = await sftpClient.delete(path)
socket.emit('rm_success', res)
} catch (error) {
consola.error('rm_file Error', error.message)
socket.emit('sftp_error', error.message)
}
})
// socket.on('down_dir', async (path) => {
// const exists = await sftpClient.exists(path)
// if(!exists) return socket.emit('not_exists_dir', '文件夹不存在或当前不可访问')
// let res = await sftpClient.downloadDir(path, sftpCacheDir)
// socket.emit('down_dir_success', res)
// })
socket.on('down_file', async ({ path, name, size, target = 'down' }) => {
// target: down or preview
const exists = await sftpClient.exists(path)
if(!exists) return socket.emit('not_exists_dir', '文件不存在或当前不可访问')
try {
const localPath = rawPath.join(sftpCacheDir, name)
let timer = null
let res = await sftpClient.fastGet(path, localPath, {
step: step => {
if(timer) return
timer = setTimeout(() => {
const percent = Math.ceil((step / size) * 100) // 下载进度为服务器下载到服务端的进度,前端无需*2
console.log(`从服务器下载进度:${ percent }%`)
socket.emit('down_file_progress', percent)
timer = null
}, 200)
}
})
consola.success('sftp下载成功: ', res)
let buffer = fs.readFileSync(localPath)
let data = { buffer, name }
switch(target) {
case 'down':
socket.emit('down_file_success', data)
break
case 'preview':
socket.emit('preview_file_success', data)
break
}
fs.unlinkSync(localPath) //删除文件
} catch (error) {
consola.error('down_file Error', error.message)
socket.emit('sftp_error', error.message)
}
})
// socket.on('up_file', async ({ targetPath, fullPath, name, file }) => {
// console.log({ targetPath, fullPath, name, file })
// const exists = await sftpClient.exists(targetPath)
// if(!exists) return socket.emit('not_exists_dir', '文件夹不存在或当前不可访问')
// try {
// const localPath = rawPath.join(sftpCacheDir, name)
// fs.writeFileSync(localPath, file)
// let res = await sftpClient.fastPut(localPath, fullPath)
// consola.success('sftp上传成功: ', res)
// socket.emit('up_file_success', res)
// } catch (error) {
// consola.error('up_file Error', error.message)
// socket.emit('sftp_error', error.message)
// }
// })
/** 分片上传 */
// 1. 创建本地缓存文件夹
let md5List = []
socket.on('create_cache_dir', async ({ targetPath, name }) => {
// console.log({ targetPath, name })
const exists = await sftpClient.exists(targetPath)
if(!exists) return socket.emit('not_exists_dir', '文件夹不存在或当前不可访问')
md5List = []
const localPath = rawPath.join(sftpCacheDir, name)
if(fs.existsSync(localPath)) clearDir(localPath) // 清空目录
fs.mkdirSync(localPath, { recursive: true })
console.log('================create_cache_success================')
socket.emit('create_cache_success')
})
socket.on('up_file_slice', async ({ name, sliceFile, fileIndex }) => {
// console.log('up_file_slice:', fileIndex, name)
try {
let md5 = `${ fileIndex }.${ CryptoJS.MD5(fileIndex+name).toString() }`
const localPath = rawPath.join(sftpCacheDir, name, md5)
md5List.push(localPath)
fs.writeFileSync(localPath, sliceFile)
socket.emit('up_file_slice_success', md5)
} catch (error) {
consola.error('up_file_slice Error', error.message)
socket.emit('up_file_slice_fail', error.message)
}
})
socket.on('up_file_slice_over', async ({ name, fullPath, range, size }) => {
const resultDirPath = rawPath.join(sftpCacheDir, name)
const resultFilePath = rawPath.join(sftpCacheDir, name, name)
try {
console.log('md5List: ', md5List)
const arr = md5List.map((chunkFilePath, index) => {
return pipeStream(
chunkFilePath,
// 指定位置创建可写流
fs.createWriteStream(resultFilePath, {
start: index * range,
end: (index + 1) * range
})
)
})
md5List = []
await Promise.all(arr)
let timer = null
let res = await sftpClient.fastPut(resultFilePath, fullPath, {
step: step => {
if(timer) return
timer = setTimeout(() => {
const percent = Math.ceil((step / size) * 100)
console.log(`上传服务器进度:${ percent }%`)
socket.emit('up_file_progress', percent)
timer = null
}, 200)
}
})
consola.success('sftp上传成功: ', res)
socket.emit('up_file_success', res)
clearDir(resultDirPath, true) // 传服务器后移除文件夹及其文件
} catch (error) {
consola.error('sftp上传失败: ', error.message)
socket.emit('sftp_error', error.message)
clearDir(resultDirPath, true) // 传服务器后移除文件夹及其文件
}
})
}
module.exports = (httpServer) => {
const serverIo = new Server(httpServer, {
path: '/sftp',
cors: {
origin: '*'
}
})
serverIo.on('connection', (socket) => {
// 前者兼容nginx反代, 后者兼容nodejs自身服务
let clientIp = socket.handshake.headers['x-forwarded-for'] || socket.handshake.address
let sftpClient = new SFTPClient()
consola.success('terminal websocket 已连接')
socket.on('create', ({ host: ip, token }) => {
const { code } = verifyAuth(token, clientIp)
if(code !== 1) {
socket.emit('token_verify_fail')
socket.disconnect()
return
}
const sshRecord = readSSHRecord()
let loginInfo = sshRecord.find(item => item.host === ip)
if(!sshRecord.some(item => item.host === ip)) return socket.emit('create_fail', `未找到【${ ip }】凭证`)
let { type, host, port, username, randomKey } = loginInfo
// 解密放到try里面防止报错【公私钥必须配对, 否则需要重新添加服务器密钥】
randomKey = AESDecrypt(randomKey) // 先对称解密key
randomKey = RSADecrypt(randomKey) // 再非对称解密key
loginInfo[type] = AESDecrypt(loginInfo[type], randomKey) // 对称解密ssh密钥
consola.info('准备连接Sftp', host)
const authInfo = { host, port, username, [type]: loginInfo[type] }
sftpClient.connect(authInfo)
.then(() => {
consola.success('连接Sftp成功', host)
return sftpClient.list('/')
})
.then((rootLs) => {
// 普通文件-、目录文件d、链接文件l
socket.emit('root_ls', rootLs) // 先返回根目录
listenInput(sftpClient, socket) // 监听前端请求
})
.catch((err) => {
consola.error('创建Sftp失败:', err.message)
socket.emit('create_fail', err.message)
})
})
socket.on('disconnect', async () => {
sftpClient.end()
.then(() => {
consola.info('sftp连接断开')
})
.catch((error) => {
consola.info('sftp断开连接失败:', error.message)
})
.finally(() => {
sftpClient = null
const cacheDir = rawPath.join(sftpCacheDir)
clearDir(cacheDir)
})
})
})
}

View File

@ -1,25 +1,29 @@
const { Server } = require('socket.io')
const { Client: Client } = require('ssh2')
const { Client: SSHClient } = require('ssh2')
const { readSSHRecord, verifyAuth, RSADecrypt, AESDecrypt } = require('../utils')
function createTerminal(socket, vps) {
vps.shell({ term: 'xterm-color' }, (err, stream) => {
function createTerminal(socket, sshClient) {
sshClient.shell({ term: 'xterm-color' }, (err, stream) => {
if (err) return socket.emit('output', err.toString())
// 终端输出
stream
.on('data', (data) => {
socket.emit('output', data.toString())
})
.on('close', () => {
console.log('关闭终端')
vps.end()
consola.info('关闭终端')
sshClient.end()
})
// web端输入
socket.on('input', key => {
if(vps._sock.writable === false) return console.log('终端连接已关闭')
if(sshClient._sock.writable === false) return consola.info('终端连接已关闭')
stream.write(key)
})
socket.emit('connect_terminal')
socket.emit('connect_terminal') // 已连接终端web端可以执行指令了
// 监听按键重置终端大小
socket.on('resize', ({ rows, cols }) => {
consola.info('更改tty终端行&列: ', { rows, cols })
stream.setWindow(rows, cols)
})
})
@ -29,14 +33,14 @@ module.exports = (httpServer) => {
const serverIo = new Server(httpServer, {
path: '/terminal',
cors: {
origin: '*'
origin: '*' // 'http://localhost:8080'
}
})
serverIo.on('connection', (socket) => {
// 前者兼容nginx反代, 后者兼容nodejs自身服务
let clientIp = socket.handshake.headers['x-forwarded-for'] || socket.handshake.address
let vps = new Client()
console.log('terminal websocket 已连接')
let sshClient = new SSHClient()
consola.success('terminal websocket 已连接')
socket.on('create', ({ host: ip, token }) => {
const { code } = verifyAuth(token, clientIp)
@ -45,7 +49,6 @@ module.exports = (httpServer) => {
socket.disconnect()
return
}
// console.log('code:', code)
const sshRecord = readSSHRecord()
let loginInfo = sshRecord.find(item => item.host === ip)
if(!sshRecord.some(item => item.host === ip)) return socket.emit('create_fail', `未找到【${ ip }】凭证`)
@ -55,38 +58,30 @@ module.exports = (httpServer) => {
randomKey = AESDecrypt(randomKey) // 先对称解密key
randomKey = RSADecrypt(randomKey) // 再非对称解密key
loginInfo[type] = AESDecrypt(loginInfo[type], randomKey) // 对称解密ssh密钥
console.log('准备连接服务器:', host)
vps
consola.info('准备连接终端:', host)
const authInfo = { host, port, username, [type]: loginInfo[type] }
sshClient
.on('ready', () => {
console.log('已连接到服务器', host)
socket.emit('connect_success', `已连接到服务器${ host }`)
createTerminal(socket, vps)
consola.success('已连接到终端', host)
socket.emit('connect_success', `已连接到终端${ host }`)
createTerminal(socket, sshClient)
})
.on('error', (err) => {
console.log('连接失败:', err.level)
consola.error('连接终端失败:', err.level)
socket.emit('connect_fail', err.message)
})
.connect({
type: 'privateKey',
host,
port,
username,
[type]: loginInfo[type]
// debug: (info) => {
// console.log(info)
// }
})
.connect(authInfo)
} catch (err) {
console.log('创建失败:', err.message)
consola.error('创建终端失败:', err.message)
socket.emit('create_fail', err.message)
}
})
socket.on('disconnect', (reason) => {
console.log('终端连接断开:', reason)
vps.end()
vps.destroy()
vps = null
consola.info('终端连接断开:', reason)
sshClient.end()
sshClient.destroy()
sshClient = null
})
})
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

File diff suppressed because one or more lines are too long

Binary file not shown.

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

View File

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

View File

View File

@ -0,0 +1,38 @@
# host-list.json
> 存储服务器基本信息
# key.json
> 用于加密的密钥相关
# ssh-record.json
> ssh密钥记录(加密存储)
# email.json
> 邮件配置
- port: 587 --> secure: false
```json
// Gmail调试不通过, 暂缓
{
"name": "Google邮箱",
"target": "google",
"host": "smtp.gmail.com",
"port": 465,
"secure": true,
"tls": {
"rejectUnauthorized": false
}
}
```
# notify.json
> 通知配置
# group.json
> 服务器分组配置

View File

@ -0,0 +1,36 @@
{
"support": [
{
"name": "QQ邮箱",
"target": "qq",
"host": "smtp.qq.com",
"port": 465,
"secure": true,
"tls": {
"rejectUnauthorized": false
}
},
{
"name": "网易126",
"target": "wangyi126",
"host": "smtp.126.com",
"port": 465,
"secure": true,
"tls": {
"rejectUnauthorized": false
}
},
{
"name": "网易163",
"target": "wangyi163",
"host": "smtp.163.com",
"port": 465,
"secure": true,
"tls": {
"rejectUnauthorized": false
}
}
],
"user": [
]
}

View File

@ -0,0 +1,7 @@
[
{
"id": "default",
"name": "默认分组",
"index": 0
}
]

View File

@ -0,0 +1,2 @@
[
]

View File

@ -1,6 +1,5 @@
{
"pwd": "admin",
"jwtExpires": "1h",
"commonKey": "",
"publicKey": "",
"privateKey": ""

View File

@ -0,0 +1,22 @@
[
{
"type": "login",
"desc": "登录面板提醒",
"sw": true
},
{
"type": "err_login",
"desc": "登录错误提醒(连续5次)",
"sw": true
},
{
"type": "updatePwd",
"desc": "修改密码提醒",
"sw": true
},
{
"type": "host_offline",
"desc": "客户端离线提醒(每小时最多发送一次提醒)",
"sw": true
}
]

View File

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

View File

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

60
server/app/utils/email.js Normal file
View File

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

View File

@ -0,0 +1,44 @@
const CryptoJS = require('crypto-js')
const rawCrypto = require('crypto')
const NodeRSA = require('node-rsa')
const { readKey } = require('./storage.js')
// rsa非对称 私钥解密
const RSADecrypt = (ciphertext) => {
if(!ciphertext) return
let { privateKey } = readKey()
privateKey = AESDecrypt(privateKey) // 先解密私钥
const rsakey = new NodeRSA(privateKey)
rsakey.setOptions({ encryptionScheme: 'pkcs1' }) // Must Set It When Frontend Use jsencrypt
const plaintext = rsakey.decrypt(ciphertext, 'utf8')
return plaintext
}
// aes对称 加密(default commonKey)
const AESEncrypt = (text, key) => {
if(!text) return
let { commonKey } = readKey()
let ciphertext = CryptoJS.AES.encrypt(text, key || commonKey).toString()
return ciphertext
}
// aes对称 解密(default commonKey)
const AESDecrypt = (ciphertext, key) => {
if(!ciphertext) return
let { commonKey } = readKey()
let bytes = CryptoJS.AES.decrypt(ciphertext, key || commonKey)
let originalText = bytes.toString(CryptoJS.enc.Utf8)
return originalText
}
// sha1 加密(不可逆)
const SHA1Encrypt = (clearText) => {
return rawCrypto.createHash('sha1').update(clearText).digest('hex')
}
module.exports = {
RSADecrypt,
AESEncrypt,
AESDecrypt,
SHA1Encrypt
}

View File

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

139
server/app/utils/storage.js Normal file
View File

@ -0,0 +1,139 @@
const fs = require('fs')
const { sshRecordPath, hostListPath, keyPath, emailPath, notifyPath, groupPath } = require('../config')
const readSSHRecord = () => {
let list
try {
list = JSON.parse(fs.readFileSync(sshRecordPath, 'utf8'))
} catch (error) {
consola.error('读取ssh-record错误, 即将重置ssh列表: ', error)
writeSSHRecord([])
}
return list || []
}
const writeSSHRecord = (record = []) => {
fs.writeFileSync(sshRecordPath, JSON.stringify(record, null, 2))
}
const readHostList = () => {
let list
try {
list = JSON.parse(fs.readFileSync(hostListPath, 'utf8'))
} catch (error) {
consola.error('读取host-list错误, 即将重置host列表: ', error)
writeHostList([])
}
return list || []
}
const writeHostList = (record = []) => {
fs.writeFileSync(hostListPath, JSON.stringify(record, null, 2))
}
const readKey = () => {
let keyObj = JSON.parse(fs.readFileSync(keyPath, 'utf8'))
return keyObj
}
const writeKey = (keyObj = {}) => {
fs.writeFileSync(keyPath, JSON.stringify(keyObj, null, 2))
}
const readEmailJson = () => {
let emailJson = {}
try {
emailJson = JSON.parse(fs.readFileSync(emailPath, 'utf8'))
} catch (error) {
consola.error('读取email.json错误: ', error)
}
return emailJson
}
const readSupportEmailList = () => {
let supportEmailList = []
try {
supportEmailList = readEmailJson().support
} catch (error) {
consola.error('读取email support错误: ', error)
}
return supportEmailList
}
const readUserEmailList = () => {
let configEmailList = []
try {
configEmailList = readEmailJson().user
} catch (error) {
consola.error('读取email config错误: ', error)
}
return configEmailList
}
const writeUserEmailList = (user) => {
let support = readSupportEmailList()
const emailJson = { support, user }
try {
fs.writeFileSync(emailPath, JSON.stringify(emailJson, null, 2))
return { code: 0 }
} catch (error) {
return { code: -1, msg: error.message || error }
}
}
const readNotifyList = () => {
let notifyList = []
try {
notifyList = JSON.parse(fs.readFileSync(notifyPath, 'utf8'))
} catch (error) {
consola.error('读取notify list错误: ', error)
}
return notifyList
}
const getNotifySwByType = (type) => {
if(!type) throw Error('missing params: type')
try {
let { sw } = readNotifyList().find((item) => item.type === type)
return sw
} catch (error) {
consola.error(`通知类型[${ type }]不存在`)
return false
}
}
const writeNotifyList = (notifyList) => {
fs.writeFileSync(notifyPath, JSON.stringify(notifyList, null, 2))
}
const readGroupList = () => {
let list
try {
list = JSON.parse(fs.readFileSync(groupPath, 'utf8'))
} catch (error) {
consola.error('读取group-list错误, 即将重置group列表: ', error)
writeSSHRecord([])
}
return list || []
}
const writeGroupList = (list = []) => {
fs.writeFileSync(groupPath, JSON.stringify(list, null, 2))
}
module.exports = {
readSSHRecord,
writeSSHRecord,
readHostList,
writeHostList,
readKey,
writeKey,
readSupportEmailList,
readUserEmailList,
writeUserEmailList,
readNotifyList,
getNotifySwByType,
writeNotifyList,
readGroupList,
writeGroupList
}

View File

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

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

@ -0,0 +1,144 @@
const axios = require('axios')
const request = axios.create({ timeout: 3000 })
// 为空时请求本地IP
const getNetIPInfo = async (searchIp = '') => {
searchIp = searchIp.replace(/::ffff:/g, '') || '' // fix: nginx反代
if(['::ffff:', '::1'].includes(searchIp)) searchIp = '127.0.0.1'
try {
let date = Date.now()
let ipUrls = [
// 45次/分钟&支持中文(无限制)
`http://ip-api.com/json/${ searchIp }?lang=zh-CN`,
// 10000次/月&支持中文(依赖IP计算调用次数)
`http://ipwho.is/${ searchIp }?lang=zh-CN`,
// 1500次/天(依赖密钥, 超出自行注册)
`https://api.ipdata.co/${ searchIp }?api-key=c6d4d04d5f11f2cd0839ee03c47c58621d74e361c945b5c1b4f668f3`,
// 50000/月(依赖密钥, 超出自行注册)
`https://ipinfo.io/${ searchIp }/json?token=41c48b54f6d78f`,
// 1000次/天(依赖密钥, 超出自行注册)
`https://api.ipgeolocation.io/ipgeo?apiKey=105fc2c7e8864ec08b98e1ad4e8cbc6d&ip=${ searchIp }`,
// 1000次/天(依赖IP计算调用次数)
`https://ipapi.co${ searchIp ? `/${ searchIp }` : '' }/json`,
// 国内IP138提供(无限制)
`https://sp1.baidu.com/8aQDcjqpAAV3otqbppnN2DJv/api.php?query=${ searchIp }&resource_id=5809`
]
let result = await Promise.allSettled(ipUrls.map(url => request.get(url)))
let [ipApi, ipwho, ipdata, ipinfo, ipgeolocation, ipApi01, ip138] = result
let searchResult = []
if(ipApi.status === 'fulfilled') {
let { query: ip, country, regionName, city } = ipApi.value?.data || {}
searchResult.push({ ip, country, city: `${ regionName } ${ city }`, date })
}
if(ipwho.status === 'fulfilled') {
let { ip, country, region: regionName, city } = ipwho.value?.data || {}
searchResult.push({ ip, country, city: `${ regionName } ${ city }`, date })
}
if(ipdata.status === 'fulfilled') {
let { ip, country_name: country, region: regionName, city } = ipdata.value?.data || {}
searchResult.push({ ip, country, city: `${ regionName } ${ city }`, date })
}
if(ipinfo.status === 'fulfilled') {
let { ip, country, region: regionName, city } = ipinfo.value?.data || {}
searchResult.push({ ip, country, city: `${ regionName } ${ city }`, date })
}
if(ipgeolocation.status === 'fulfilled') {
let { ip, country_name: country, state_prov: regionName, city } = ipgeolocation.value?.data || {}
searchResult.push({ ip, country, city: `${ regionName } ${ city }`, date })
}
if(ipApi01.status === 'fulfilled') {
let { ip, country_name: country, region: regionName, city } = ipApi01.value?.data || {}
searchResult.push({ ip, country, city: `${ regionName } ${ city }`, date })
}
if(ip138.status === 'fulfilled') {
let [res] = ip138.value?.data?.data || []
let { origip: ip, location: country, city = '', regionName = '' } = res || {}
searchResult.push({ ip, country, city: `${ regionName } ${ city }`, date })
}
console.log(searchResult)
let validInfo = searchResult.find(item => Boolean(item.country))
consola.info('查询IP信息', validInfo)
return validInfo || { ip: '获取IP信息API出错,请排查或更新API', country: '未知', city: '未知', date }
} catch (error) {
consola.error('getIpInfo Error: ', error)
return {
ip: '未知',
country: '未知',
city: '未知',
error
}
}
}
const throwError = ({ status = 500, msg = 'defalut error' } = {}) => {
const err = new Error(msg)
err.status = status // 主动抛错
throw err
}
const isIP = (ip = '') => {
const isIPv4 = /^((25[0-5]|2[0-4]\d|[01]?\d\d?)\.){3}(25[0-5]|2[0-4]\d|[01]?\d\d?)$/
const isIPv6 = /^([\da-fA-F]{1,4}:){7}[\da-fA-F]{1,4}|:((:[\dafAF]1,4)1,6|:)|:((:[\dafAF]1,4)1,6|:)|^[\da-fA-F]{1,4}:((:[\da-fA-F]{1,4}){1,5}|:)|([\dafAF]1,4:)2((:[\dafAF]1,4)1,4|:)|([\dafAF]1,4:)2((:[\dafAF]1,4)1,4|:)|^([\da-fA-F]{1,4}:){3}((:[\da-fA-F]{1,4}){1,3}|:)|([\dafAF]1,4:)4((:[\dafAF]1,4)1,2|:)|([\dafAF]1,4:)4((:[\dafAF]1,4)1,2|:)|^([\da-fA-F]{1,4}:){5}:([\da-fA-F]{1,4})?|([\dafAF]1,4:)6:|([\dafAF]1,4:)6:/
return isIPv4.test(ip) || isIPv6.test(ip)
}
const randomStr = (len) =>{
len = len || 16
let str = 'ABCDEFGHJKMNPQRSTWXYZabcdefhijkmnprstwxyz2345678',
a = str.length,
res = ''
for (let i = 0; i < len; i++) res += str.charAt(Math.floor(Math.random() * a))
return res
}
// 获取UTC-x时间
const getUTCDate = (num = 8) => {
let date = new Date()
let now_utc = Date.UTC(date.getUTCFullYear(), date.getUTCMonth(),
date.getUTCDate(), date.getUTCHours() + num,
date.getUTCMinutes(), date.getUTCSeconds())
return new Date(now_utc)
}
const formatTimestamp = (timestamp = Date.now(), format = 'time') => {
if(typeof(timestamp) !== 'number') return '--'
let date = new Date(timestamp)
let padZero = (num) => String(num).padStart(2, '0')
let year = date.getFullYear()
let mounth = padZero(date.getMonth() + 1)
let day = padZero(date.getDate())
let hours = padZero(date.getHours())
let minute = padZero(date.getMinutes())
let second = padZero(date.getSeconds())
let weekday = ['周日', '周一', '周二', '周三', '周四', '周五', '周六' ]
let week = weekday[date.getDay()]
switch (format) {
case 'date':
return `${ year }-${ mounth }-${ day }`
case 'week':
return `${ year }-${ mounth }-${ day } ${ week }`
case 'hour':
return `${ year }-${ mounth }-${ day } ${ hours }`
case 'time':
return `${ year }-${ mounth }-${ day } ${ hours }:${ minute }:${ second }`
default:
return `${ year }-${ mounth }-${ day } ${ hours }:${ minute }:${ second }`
}
}
module.exports = {
getNetIPInfo,
throwError,
isIP,
randomStr,
getUTCDate,
formatTimestamp
}

View File

@ -0,0 +1,42 @@
const { AESDecrypt } = require('./encrypt')
const { readKey } = require('./storage')
const jwt = require('jsonwebtoken')
const enumLoginCode = {
SUCCESS: 1,
EXPIRES: -1,
ERROR_TOKEN: -2
}
// 校验token与登录IP
const verifyAuth = (token, clientIp) =>{
if(['::ffff:', '::1'].includes(clientIp)) clientIp = '127.0.0.1'
token = AESDecrypt(token) // 先aes解密
const { commonKey } = readKey()
try {
const { exp } = jwt.verify(token, commonKey)
if(Date.now() > (exp * 1000)) return { code: -1, msg: 'token expires' } // 过期
let lastLoginIp = global.loginRecord[0] ? global.loginRecord[0].ip : ''
consola.info('校验客户端IP', clientIp)
consola.info('最后登录的IP', lastLoginIp)
// 判断: (生产环境)clientIp与上次登录成功IP不一致
if(isProd() && (!lastLoginIp || !clientIp || !clientIp.includes(lastLoginIp))) {
return { code: enumLoginCode.EXPIRES, msg: '登录IP发生变化, 需重新登录' } // IP与上次登录访问的不一致
}
return { code: enumLoginCode.SUCCESS, msg: 'success' } // 验证成功
} catch (error) {
return { code: enumLoginCode.ERROR_TOKEN, msg: error } // token错误, 验证失败
}
}
const isProd = () => {
const EXEC_ENV = process.env.EXEC_ENV || 'production'
return EXEC_ENV === 'production'
}
module.exports = {
verifyAuth,
isProd
}

View File

@ -1,6 +1,6 @@
{
"name": "easynode-server",
"version": "1.1.0",
"version": "0.0.1",
"description": "easynode-server",
"bin": "./bin/www",
"pkg": {
@ -27,9 +27,10 @@
"dependencies": {
"@koa/cors": "^3.1.0",
"axios": "^0.21.4",
"consola": "^2.15.3",
"cross-env": "^7.0.3",
"crypto-js": "^4.1.1",
"global": "^4.4.0",
"is-ip": "^4.0.0",
"jsonwebtoken": "^8.5.1",
"koa": "^2.13.1",
"koa-body": "^4.2.0",
@ -44,12 +45,13 @@
"node-os-utils": "^1.3.6",
"node-rsa": "^1.1.1",
"node-schedule": "^2.1.0",
"nodemailer": "^6.7.5",
"socket.io": "^4.4.1",
"socket.io-client": "^4.5.1",
"ssh2": "^1.10.0"
"ssh2": "^1.10.0",
"ssh2-sftp-client": "^9.0.1"
},
"devDependencies": {
"cross-env": "^7.0.3",
"eslint": "^7.32.0",
"nodemon": "^2.0.15",
"pkg": "5.6"

View File

@ -358,6 +358,11 @@ buffer-equal-constant-time@1.0.1:
resolved "https://registry.yarnpkg.com/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz#f8e71132f7ffe6e01a5c9697a4c6f3e48d5cc819"
integrity sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==
buffer-from@^1.0.0:
version "1.1.2"
resolved "https://registry.yarnpkg.com/buffer-from/-/buffer-from-1.1.2.tgz#2b146a6fd72e80b4f55d255f35ed59a3a9a41bd5"
integrity sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==
buffer@^5.5.0:
version "5.7.1"
resolved "https://registry.yarnpkg.com/buffer/-/buffer-5.7.1.tgz#ba62e7c13133053582197160851a8f648e99eed0"
@ -544,6 +549,16 @@ concat-map@0.0.1:
resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b"
integrity sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=
concat-stream@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/concat-stream/-/concat-stream-2.0.0.tgz#414cf5af790a48c60ab9be4527d56d5e41133cb1"
integrity sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==
dependencies:
buffer-from "^1.0.0"
inherits "^2.0.3"
readable-stream "^3.0.2"
typedarray "^0.0.6"
configstore@^5.0.1:
version "5.0.1"
resolved "https://registry.yarnpkg.com/configstore/-/configstore-5.0.1.tgz#d365021b5df4b98cdd187d6a3b0e3f6a7cc5ed96"
@ -556,6 +571,11 @@ configstore@^5.0.1:
write-file-atomic "^3.0.0"
xdg-basedir "^4.0.0"
consola@^2.15.3:
version "2.15.3"
resolved "https://registry.yarnpkg.com/consola/-/consola-2.15.3.tgz#2e11f98d6a4be71ff72e0bdf07bd23e12cb61550"
integrity sha512-9vAdYbHj6x2fLKC4+oPH0kFzY/orMZyG2Aj+kNylHxKGJ/Ed4dpNyAQYwJOdqO4zdM7XpVHmyejQDcQHrnuXbw==
console-control-strings@^1.0.0, console-control-strings@~1.1.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/console-control-strings/-/console-control-strings-1.1.0.tgz#3d7cf4464db6446ea644bf4b39507f9851008e8e"
@ -826,6 +846,11 @@ enquirer@^2.3.5:
dependencies:
ansi-colors "^4.1.1"
err-code@^2.0.2:
version "2.0.3"
resolved "https://registry.yarnpkg.com/err-code/-/err-code-2.0.3.tgz#23c2f3b756ffdfc608d30e27c9a941024807e7f9"
integrity sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==
escalade@^3.1.1:
version "3.1.1"
resolved "https://registry.yarnpkg.com/escalade/-/escalade-3.1.1.tgz#d8cfdc7000965c5a0174b4a82eaa5c0552742e40"
@ -1421,11 +1446,6 @@ into-stream@^6.0.0:
from2 "^2.3.0"
p-is-promise "^3.0.0"
ip-regex@^5.0.0:
version "5.0.0"
resolved "https://registry.yarnpkg.com/ip-regex/-/ip-regex-5.0.0.tgz#cd313b2ae9c80c07bd3851e12bf4fa4dc5480632"
integrity sha512-fOCG6lhoKKakwv+C6KdsOnGvgXnmgfmp0myi3bcNwj3qfwPAxRKWEuFhvEFF7ceYIz6+1jRZ+yguLFAmUNPEfw==
is-binary-path@~2.1.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/is-binary-path/-/is-binary-path-2.1.0.tgz#ea1f7f3b80f064236e83470f86c09c254fb45b09"
@ -1486,13 +1506,6 @@ is-installed-globally@^0.4.0:
global-dirs "^3.0.0"
is-path-inside "^3.0.2"
is-ip@^4.0.0:
version "4.0.0"
resolved "https://registry.yarnpkg.com/is-ip/-/is-ip-4.0.0.tgz#8e9eae12056bf46edafad19054dcc3666a324b3a"
integrity sha512-4B4XA2HEIm/PY+OSpeMBXr8pGWBYbXuHgjMAqrwbLO3CPTCAd9ArEJzBUKGZtk9viY6+aSfadGnWyjY3ydYZkw==
dependencies:
ip-regex "^5.0.0"
is-nan@^1.3.2:
version "1.3.2"
resolved "https://registry.yarnpkg.com/is-nan/-/is-nan-1.3.2.tgz#043a54adea31748b55b6cd4e09aadafa69bd9e1d"
@ -1954,6 +1967,11 @@ nan@^2.15.0:
resolved "https://registry.yarnpkg.com/nan/-/nan-2.15.0.tgz#3f34a473ff18e15c1b5626b62903b5ad6e665fee"
integrity sha512-8ZtvEnA2c5aYCZYd1cvgdnU6cqwixRoYg70xPLWUws5ORTa/lnw+u4amixRS/Ac5U5mQVgp9pnlSUnbNWFaWZQ==
nan@^2.16.0:
version "2.16.0"
resolved "https://registry.yarnpkg.com/nan/-/nan-2.16.0.tgz#664f43e45460fb98faf00edca0bb0d7b8dce7916"
integrity sha512-UdAqHyFngu7TfQKsCBgAA6pWDkT8MAO7d0jyOecVhN5354xbLqdn8mV9Tat9gepAupm0bt2DbeaSC8vS52MuFA==
napi-build-utils@^1.0.1:
version "1.0.2"
resolved "https://registry.yarnpkg.com/napi-build-utils/-/napi-build-utils-1.0.2.tgz#b1fddc0b2c46e380a0b7a76f984dd47c41a13806"
@ -2004,6 +2022,11 @@ node-schedule@^2.1.0:
long-timeout "0.1.1"
sorted-array-functions "^1.3.0"
nodemailer@^6.7.5:
version "6.7.5"
resolved "https://registry.yarnpkg.com/nodemailer/-/nodemailer-6.7.5.tgz#b30b1566f5fa2249f7bd49ced4c58bec6b25915e"
integrity sha512-6VtMpwhsrixq1HDYSBBHvW0GwiWawE75dS3oal48VqRhUvKJNnKnJo2RI/bCVQubj1vgrgscMNW4DHaD6xtMCg==
nodemon@^2.0.15:
version "2.0.16"
resolved "https://registry.yarnpkg.com/nodemon/-/nodemon-2.0.16.tgz#d71b31bfdb226c25de34afea53486c8ef225fdef"
@ -2278,6 +2301,14 @@ progress@^2.0.0, progress@^2.0.3:
resolved "https://registry.yarnpkg.com/progress/-/progress-2.0.3.tgz#7e8cf8d8f5b8f239c1bc68beb4eb78567d572ef8"
integrity sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==
promise-retry@^2.0.1:
version "2.0.1"
resolved "https://registry.yarnpkg.com/promise-retry/-/promise-retry-2.0.1.tgz#ff747a13620ab57ba688f5fc67855410c370da22"
integrity sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==
dependencies:
err-code "^2.0.2"
retry "^0.12.0"
pstree.remy@^1.1.8:
version "1.1.8"
resolved "https://registry.yarnpkg.com/pstree.remy/-/pstree.remy-1.1.8.tgz#c242224f4a67c21f686839bbdb4ac282b8373d3a"
@ -2348,7 +2379,7 @@ readable-stream@^2.0.0, readable-stream@^2.0.6, readable-stream@^2.1.4:
string_decoder "~1.1.1"
util-deprecate "~1.0.1"
readable-stream@^3.1.1, readable-stream@^3.4.0, readable-stream@^3.6.0:
readable-stream@^3.0.2, readable-stream@^3.1.1, readable-stream@^3.4.0, readable-stream@^3.6.0:
version "3.6.0"
resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-3.6.0.tgz#337bbda3adc0706bd3e024426a286d4b4b2c9198"
integrity sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA==
@ -2422,6 +2453,11 @@ responselike@^1.0.2:
dependencies:
lowercase-keys "^1.0.0"
retry@^0.12.0:
version "0.12.0"
resolved "https://registry.yarnpkg.com/retry/-/retry-0.12.0.tgz#1b42a6266a21f07421d1b0b54b7dc167b01c013b"
integrity sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==
reusify@^1.0.4:
version "1.0.4"
resolved "https://registry.yarnpkg.com/reusify/-/reusify-1.0.4.tgz#90da382b1e126efc02146e90845a88db12925d76"
@ -2613,6 +2649,15 @@ sprintf-js@~1.0.2:
resolved "https://registry.yarnpkg.com/sprintf-js/-/sprintf-js-1.0.3.tgz#04e6926f662895354f3dd015203633b857297e2c"
integrity sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw=
ssh2-sftp-client@^9.0.1:
version "9.0.1"
resolved "https://registry.yarnpkg.com/ssh2-sftp-client/-/ssh2-sftp-client-9.0.1.tgz#0938ce12a8c07cf309de688028b0f97c7568bc0b"
integrity sha512-P8D7cDzSPJj3GKdTPSpK4rmPIJDFQagavaHax3KXgWciLoDM5czAGEU2OP4XlS5xDiIgHS1l6x9285Vs8kTxPA==
dependencies:
concat-stream "^2.0.0"
promise-retry "^2.0.1"
ssh2 "^1.11.0"
ssh2@^1.10.0:
version "1.10.0"
resolved "https://registry.yarnpkg.com/ssh2/-/ssh2-1.10.0.tgz#e05d870dfc8e83bc918a2ffb3dcbd4d523472dee"
@ -2624,6 +2669,17 @@ ssh2@^1.10.0:
cpu-features "~0.0.4"
nan "^2.15.0"
ssh2@^1.11.0:
version "1.11.0"
resolved "https://registry.yarnpkg.com/ssh2/-/ssh2-1.11.0.tgz#ce60186216971e12f6deb553dcf82322498fe2e4"
integrity sha512-nfg0wZWGSsfUe/IBJkXVll3PEZ//YH2guww+mP88gTpuSU4FtZN7zu9JoeTGOyCNx2dTDtT9fOpWwlzyj4uOOw==
dependencies:
asn1 "^0.2.4"
bcrypt-pbkdf "^1.0.2"
optionalDependencies:
cpu-features "~0.0.4"
nan "^2.16.0"
statuses@2.0.1, statuses@^2.0.1:
version "2.0.1"
resolved "https://registry.yarnpkg.com/statuses/-/statuses-2.0.1.tgz#55cb000ccf1d48728bd23c685a063998cf1a1b63"
@ -2725,11 +2781,6 @@ supports-preserve-symlinks-flag@^1.0.0:
resolved "https://registry.yarnpkg.com/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz#6eda4bd344a3c94aea376d4cc31bc77311039e09"
integrity sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==
systeminformation@^5.11.16:
version "5.11.16"
resolved "https://registry.yarnpkg.com/systeminformation/-/systeminformation-5.11.16.tgz#5f6fda2447fafe204bd2ab543475f1ffa8c14a85"
integrity sha512-/a1VfP9WELKLT330yhAHJ4lWCXRYynel1kMMHKc/qdzCgDt3BIcMlo+3tKcTiRHFefjV3fz4AvqMx7dGO/72zw==
table@^6.0.9:
version "6.8.0"
resolved "https://registry.yarnpkg.com/table/-/table-6.8.0.tgz#87e28f14fa4321c3377ba286f07b79b281a3b3ca"
@ -2862,6 +2913,11 @@ typedarray-to-buffer@^3.1.5:
dependencies:
is-typedarray "^1.0.0"
typedarray@^0.0.6:
version "0.0.6"
resolved "https://registry.yarnpkg.com/typedarray/-/typedarray-0.0.6.tgz#867ac74e3864187b1d3d47d996a78ec5c8830777"
integrity sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==
undefsafe@^2.0.5:
version "2.0.5"
resolved "https://registry.yarnpkg.com/undefsafe/-/undefsafe-2.0.5.tgz#38733b9327bdcd226db889fb723a6efd162e6e2c"