diff --git a/server/.env.template b/server/.env.template index 2aa3f6b..3b6d98b 100644 --- a/server/.env.template +++ b/server/.env.template @@ -2,4 +2,7 @@ DEBUG=1 # 访问IP限制 -allowedIPs=['127.0.0.1'] \ No newline at end of file +allowedIPs=['127.0.0.1'] + +# 激活PLUS功能的授权码 +PLUS_KEY= diff --git a/server/app/config/index.js b/server/app/config/index.js index 173d291..0c33ec7 100644 --- a/server/app/config/index.js +++ b/server/app/config/index.js @@ -17,6 +17,7 @@ module.exports = { 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'), diff --git a/server/app/controller/host.js b/server/app/controller/host.js index 193df60..a0cf747 100644 --- a/server/app/controller/host.js +++ b/server/app/controller/host.js @@ -51,7 +51,7 @@ async function updateHost({ res, request }) { hosts, id, host: newHost, name: newName, index, oldHost, expired, expiredNotify, group, consoleUrl, remark, - port, clientPort, username, authType, password, privateKey, credential, command, tempKey + port, clientPort, username, authType, password, privateKey, credential, command, tempKey, jumpHosts = [] } } = request let isBatch = Array.isArray(hosts) @@ -73,7 +73,6 @@ async function updateHost({ res, request }) { target[authType] = await AESEncryptAsync(clearSSHKey) // console.log(`${ authType }__commonKey加密存储: `, target[authType]) } - delete target._id delete target.monitorData delete target.tempKey Object.assign(oldRecord, target) @@ -85,7 +84,7 @@ async function updateHost({ res, request }) { let updateRecord = { name: newName, host: newHost, index, expired, expiredNotify, group, consoleUrl, remark, - port, clientPort, username, authType, password, privateKey, credential, command + port, clientPort, username, authType, password, privateKey, credential, command, jumpHosts } let oldRecord = await hostListDB.findOneAsync({ _id: id }) diff --git a/server/app/controller/user.js b/server/app/controller/user.js index efc8af7..fa299e2 100644 --- a/server/app/controller/user.js +++ b/server/app/controller/user.js @@ -5,9 +5,10 @@ const QRCode = require('qrcode') const { sendNoticeAsync } = require('../utils/notify') const { RSADecryptAsync, AESEncryptAsync, SHA1Encrypt } = require('../utils/encrypt') const { getNetIPInfo } = require('../utils/tools') -const { KeyDB, LogDB } = require('../utils/db-class') +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 keyDB.findOneAsync({}) @@ -164,6 +165,11 @@ const disableMFA2 = async ({ res }) => { res.success({ msg: 'success' }) } +const getPlusInfo = async ({ res }) => { + const data = await plusDB.findOneAsync({}) + res.success({ data, msg: 'success' }) +} + module.exports = { login, getpublicKey, @@ -172,5 +178,6 @@ module.exports = { getMFA2Status, getMFA2Code, enableMFA2, - disableMFA2 + disableMFA2, + getPlusInfo } diff --git a/server/app/encrypt-file.js b/server/app/encrypt-file.js new file mode 100644 index 0000000..dec211e --- /dev/null +++ b/server/app/encrypt-file.js @@ -0,0 +1,44 @@ +const fs = require('fs-extra') +const path = require('path') +const CryptoJS = require('crypto-js') +require('dotenv').config() +console.log(process.env.PLUS_DECRYPT_KEY) + +async function encryptPlusClearFiles(dir) { + try { + const files = await fs.readdir(dir) + + for (const file of files) { + const fullPath = path.join(dir, file) + const stat = await fs.stat(fullPath) + + if (stat.isDirectory()) { + await encryptPlusClearFiles(fullPath) + } else if (file === 'plus-clear.js') { + const content = await fs.readFile(fullPath, 'utf-8') + + // global.PLUS_DECRYPT_KEY + const encryptedContent = CryptoJS.AES.encrypt(content, process.env.PLUS_DECRYPT_KEY).toString() + + const newPath = path.join(path.dirname(fullPath), 'plus.js') + + await fs.writeFile(newPath, encryptedContent) + + console.log(`已加密文件: ${fullPath}`) + console.log(`生成加密文件: ${newPath}`) + } + } + } catch (error) { + console.error('加密过程出错:', error) + } +} + +const appDir = path.join(__dirname) +console.log(appDir) +// encryptPlusClearFiles(appDir) +// .then(() => { +// console.log('加密完成!') +// }) +// .catch(error => { +// console.error('程序执行出错:', error) +// }) \ No newline at end of file diff --git a/server/app/main.js b/server/app/main.js index 976ea78..f8b691f 100644 --- a/server/app/main.js +++ b/server/app/main.js @@ -1,8 +1,7 @@ -const consola = require('consola') -global.consola = consola const { httpServer } = require('./server') const initDB = require('./db') const scheduleJob = require('./schedule') +require(process.env.NODE_ENV === 'dev' ? './utils/plus-clear' : './utils/plus')() async function main() { await initDB() diff --git a/server/app/router/routes.js b/server/app/router/routes.js index 2602965..36fd7b4 100644 --- a/server/app/router/routes.js +++ b/server/app/router/routes.js @@ -1,6 +1,6 @@ const { getSSHList, addSSH, updateSSH, removeSSH, getCommand } = require('../controller/ssh') const { getHostList, addHost, updateHost, removeHost, importHost } = require('../controller/host') -const { login, getpublicKey, updatePwd, getEasynodeVersion, getMFA2Status, getMFA2Code, enableMFA2, disableMFA2 } = require('../controller/user') +const { login, getpublicKey, updatePwd, getEasynodeVersion, getMFA2Status, getMFA2Code, enableMFA2, disableMFA2, getPlusInfo } = 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') @@ -101,6 +101,11 @@ const user = [ method: 'post', path: '/mfa2-disable', controller: disableMFA2 + }, + { + method: 'get', + path: '/plus-info', + controller: getPlusInfo } ] const notify = [ diff --git a/server/app/schedule/expired-notify.js b/server/app/schedule/expired-notify.js deleted file mode 100644 index 86c5b37..0000000 --- a/server/app/schedule/expired-notify.js +++ /dev/null @@ -1,33 +0,0 @@ -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 = () => { - // 每天中午12点执行一次。 - schedule.scheduleJob('0 0 12 1/1 * ?', expiredNotifyJob) -} diff --git a/server/app/schedule/index.js b/server/app/schedule/index.js index 60c8347..275dd64 100644 --- a/server/app/schedule/index.js +++ b/server/app/schedule/index.js @@ -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) } diff --git a/server/app/socket/plus.js b/server/app/socket/plus.js new file mode 100644 index 0000000..e69de29 diff --git a/server/app/socket/terminal.js b/server/app/socket/terminal.js index 13eb9f2..6fef38e 100644 --- a/server/app/socket/terminal.js +++ b/server/app/socket/terminal.js @@ -1,16 +1,15 @@ const { Server } = require('socket.io') const { Client: SSHClient } = require('ssh2') const { verifyAuthSync } = require('../utils/verify-auth') -const { AESDecryptAsync } = require('../utils/encrypt') const { sendNoticeAsync } = require('../utils/notify') const { isAllowedIp, ping } = require('../utils/tools') -const { HostListDB, CredentialsDB } = require('../utils/db-class') +const { HostListDB } = require('../utils/db-class') +const { getConnectionOptions, connectByJumpHosts } = require(process.env.NODE_ENV === 'dev' ? './plus-clear' : './plus') const hostListDB = new HostListDB().getInstance() -const credentialsDB = new CredentialsDB().getInstance() -function createInteractiveShell(socket, sshClient) { +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()) // 终端输出 @@ -20,70 +19,38 @@ function createInteractiveShell(socket, sshClient) { }) .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 hostListDB.findAsync({}) - 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 { authType, host, port, username, name, jumpHosts } = targetHostInfo try { - // 解密放到try里面,防止报错【commonKey必须配对, 否则需要重新添加服务器密钥】 - if (authType === 'credential') { - let credentialId = await AESDecryptAsync(targetHostInfo[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(targetHostInfo[authType]) + let { authInfo: targetConnectionOptions } = await getConnectionOptions(hostId) + let jumpHostResult = await connectByJumpHosts(jumpHosts, targetConnectionOptions.host, targetConnectionOptions.port, socket) + if (jumpHostResult) { + targetConnectionOptions.sock = jumpHostResult.sock } - consola.info('准备连接终端:', host) - // targetHostInfo[targetHostInfo.authType] = await AESDecryptAsync(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 + 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) @@ -96,7 +63,7 @@ async function createTerminal(hostId, socket, sshClient) { socket.emit('connect_fail', err.message) }) .connect({ - ...authInfo + ...targetConnectionOptions // debug: (info) => console.log(info) }) @@ -123,7 +90,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) { @@ -131,16 +98,10 @@ 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 }) { @@ -155,20 +116,20 @@ module.exports = (httpServer) => { consola.info('重连终端: ', hostId) socket.off('input', listenerInput) // 取消监听,重新注册监听,操作新的stream socket.off('resize', resizeShell) - sshClient?.end() - sshClient?.destroy() - sshClient = null + targetSSHClient?.end() + targetSSHClient?.destroy() + targetSSHClient = null stream = null setTimeout(async () => { // 初始化新的SSH客户端对象 - sshClient = new SSHClient() - stream = await createTerminal(hostId, socket, sshClient) + targetSSHClient = new SSHClient() + stream = await createTerminal(hostId, socket, targetSSHClient) 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) => { diff --git a/server/app/utils/db-class.js b/server/app/utils/db-class.js index 6c11aba..e3d802b 100644 --- a/server/app/utils/db-class.js +++ b/server/app/utils/db-class.js @@ -8,7 +8,8 @@ const { groupConfDBPath, scriptsDBPath, onekeyDBPath, - logDBPath + logDBPath, + plusDBPath } = require('../config') module.exports.KeyDB = class KeyDB { @@ -117,4 +118,15 @@ module.exports.LogDB = class LogDB { 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 + } } \ No newline at end of file diff --git a/server/app/utils/plus.js b/server/app/utils/plus.js new file mode 100644 index 0000000..e69de29 diff --git a/server/app/utils/tools.js b/server/app/utils/tools.js index e190530..d531bbd 100644 --- a/server/app/utils/tools.js +++ b/server/app/utils/tools.js @@ -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, diff --git a/server/index.js b/server/index.js index 110576d..b1e4be9 100644 --- a/server/index.js +++ b/server/index.js @@ -1,2 +1,4 @@ +const consola = require('consola') +global.consola = consola require('dotenv').config() require('./app/main.js') diff --git a/server/package.json b/server/package.json index ac75cb3..02df313 100644 --- a/server/package.json +++ b/server/package.json @@ -1,58 +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", - "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" - } -} +{ + "name": "server", + "version": "3.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", + "encrypt": "node ./app/encrypt-file.js" + }, + "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", + "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" + } +}