feat: 新增代理服务

This commit is contained in:
chaoszhu
2025-08-03 12:52:33 +08:00
parent c123ee6edd
commit 4be269c332
25 changed files with 1097 additions and 882 deletions

View File

@@ -21,6 +21,7 @@ module.exports = {
aiConfigDBPath: path.join(process.cwd(),'app/db/ai-config.db'),
chatHistoryDBPath: path.join(process.cwd(),'app/db/chat-history.db'),
favoriteSftpDBPath: path.join(process.cwd(),'app/db/favorite-sftp.db'),
proxyDBPath: path.join(process.cwd(),'app/db/proxy.db'),
apiPrefix: '/api/v1',
logConfig: {
outDir: path.join(process.cwd(),'./app/db/logs'),

View File

@@ -94,7 +94,8 @@ async function importHost({ res, request }) {
} else {
let extraFiels = {
expired: null, expiredNotify: false, group: 'default', consoleUrl: '', remark: '',
authType: 'privateKey', password: '', privateKey: '', credential: '', command: ''
authType: 'privateKey', password: '', privateKey: '', credential: '', command: '',
proxyType: '', jumpHosts: [], proxyServer: ''
}
newHostList = newHostList.map((item, index) => {
item.port = Number(item.port) || 0

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,117 @@
const { ProxyDB, HostListDB } = require('../utils/db-class')
const proxyDB = new ProxyDB().getInstance()
const hostListDB = new HostListDB().getInstance()
const getProxyList = async ({ res }) => {
try {
let data = await proxyDB.findAsync({})
data = data.map(item => ({ ...item, id: item._id }))
data?.sort((a, b) => new Date(b.createTime || 0) - new Date(a.createTime || 0))
res.success({ data })
} catch (error) {
res.fail({ data: false, msg: '获取代理列表失败' })
}
}
const addProxy = async ({ res, request }) => {
try {
let { body: { type, name, host, port, username, password } } = request
if (!type || !name || !host || !port) {
return res.fail({ data: false, msg: '参数错误:类型、名称、主机、端口为必填项' })
}
// 验证端口号
const portNum = Number(port)
if (!Number.isInteger(portNum) || portNum < 1 || portNum > 65535) {
return res.fail({ data: false, msg: '端口号必须是1-65535之间的整数' })
}
let record = {
type,
name,
host,
port: portNum,
username: username || '',
password: password || '',
createTime: new Date().toISOString()
}
await proxyDB.insertAsync(record)
res.success({ data: '添加成功' })
} catch (error) {
res.fail({ data: false, msg: '添加代理失败' })
}
}
const updateProxy = async ({ res, request }) => {
try {
let { params: { id } } = request
let { body: { type, name, host, port, username, password } } = request
if (!id || !type || !name || !host || !port) {
return res.fail({ data: false, msg: '参数错误ID、类型、名称、主机、端口为必填项' })
}
// 验证端口号
const portNum = Number(port)
if (!Number.isInteger(portNum) || portNum < 1 || portNum > 65535) {
return res.fail({ data: false, msg: '端口号必须是1-65535之间的整数' })
}
let target = await proxyDB.findOneAsync({ _id: id })
if (!target) {
return res.fail({ data: false, msg: `代理ID ${ id } 不存在` })
}
await proxyDB.updateAsync(
{ _id: id },
{
$set: {
type,
name,
host,
port: portNum,
username: username || '',
password: password || '',
updateTime: new Date().toISOString()
}
}
)
res.success({ data: '修改成功' })
} catch (error) {
res.fail({ data: false, msg: '修改代理失败' })
}
}
const removeProxy = async ({ res, request }) => {
try {
let { params: { id } } = request
if (!id) {
return res.fail({ data: false, msg: '参数错误缺少代理ID' })
}
let target = await proxyDB.findOneAsync({ _id: id })
if (!target) {
return res.fail({ data: false, msg: `代理ID ${ id } 不存在` })
}
await proxyDB.removeAsync({ _id: id })
// 删除代理后将所有使用该代理的实例的proxyServer设置为空
await hostListDB.updateAsync({ proxyServer: id }, { $set: { proxyServer: '' } })
res.success({ data: '删除成功' })
} catch (error) {
res.fail({ data: false, msg: '删除代理失败' })
}
}
module.exports = {
getProxyList,
addProxy,
updateProxy,
removeProxy
}

View File

@@ -8,6 +8,7 @@ const { getScriptGroupList, addScriptGroup, removeScriptGroup, updateScriptGroup
const { getOnekeyRecord, removeOnekeyRecord } = require('../controller/onekey')
const { getLog, saveIpWhiteList, removeSomeLoginRecords } = require('../controller/log')
const { getAIConfig, saveAIConfig, getAIModels, getChatHistory, saveChatHistory, removeChatHistory } = require('../controller/chat')
const { getProxyList, addProxy, updateProxy, removeProxy } = require('../controller/proxy')
const ssh = [
{
@@ -305,4 +306,40 @@ const aiConfig = [
controller: removeChatHistory
}
]
module.exports = [].concat(ssh, host, user, notify, group, scripts, scriptGroup, onekey, log, aiConfig)
const proxy = [
{
method: 'get',
path: '/proxy',
controller: getProxyList
},
{
method: 'post',
path: '/proxy',
controller: addProxy
},
{
method: 'put',
path: '/proxy/:id',
controller: updateProxy
},
{
method: 'delete',
path: '/proxy/:id',
controller: removeProxy
}
]
module.exports = [].concat(
ssh,
host,
user,
notify,
group,
scripts,
scriptGroup,
onekey,
log,
aiConfig,
proxy
)

View File

@@ -4,7 +4,6 @@ const http = require('http')
const { httpPort } = require('./config')
const middlewares = require('./middlewares')
const wsTerminal = require('./socket/terminal')
const wsTerminalSingleWindow = require('./socket/terminal-single-window')
const wsSftpV2 = require('./socket/sftp-v2')
const wsDocker = require('./socket/docker')
const wsOnekey = require('./socket/onekey')
@@ -25,7 +24,6 @@ const httpServer = () => {
function serverHandler(app, server) {
app.proxy = true // 用于nginx反代时获取真实客户端ip
wsTerminal(server) // 终端
wsTerminalSingleWindow(server) // 终端-单窗口
wsSftpV2(server) // sftp-v2
wsDocker(server) // docker
wsOnekey(server) // 一键指令

View File

@@ -1,4 +1,3 @@
const rawPath = require('path')
const { Server } = require('socket.io')
const { Client: SSHClient } = require('ssh2')
const { sendNoticeAsync } = require('../utils/notify')
@@ -6,8 +5,7 @@ const { verifyAuthSync } = require('../utils/verify-auth')
const { shellThrottle } = require('../utils/tools')
const { isAllowedIp } = require('../utils/tools')
const { HostListDB, OnekeyDB } = require('../utils/db-class')
const { getConnectionOptions } = require('./terminal')
const decryptAndExecuteAsync = require('../utils/decrypt-file')
const { getConnectionOptions, handleProxyAndJumpHostConnection } = require('./terminal')
const hostListDB = new HostListDB().getInstance()
const onekeyDB = new OnekeyDB().getInstance()
@@ -126,27 +124,35 @@ module.exports = (httpServer) => {
if (!targetHostsInfo.length) return socket.emit('create_fail', `未找到【${ hostIds }】服务器信息`)
// 查找 hostInfo -> 并发执行
socket.emit('ready')
// 获取跳板机连接函数
let { connectByJumpHosts = null } = (await decryptAndExecuteAsync(rawPath.join(__dirname, 'plus.js'))) || {}
let execPromise = targetHostsInfo.map((hostInfo, index) => {
// eslint-disable-next-line no-async-promise-executor
return new Promise(async (resolve, reject) => {
setTimeout(() => reject('执行超时'), timeout * 1000)
let { host, port, jumpHosts } = hostInfo
let { host, port } = hostInfo
let curRes = { command, host, port, name: hostInfo.name, result: '', status: execStatusEnum.connecting, startDate: Date.now() + index }
execResult.push(curRes)
let jumpSshClients = []
try {
let { authInfo: targetConnectionOptions } = await getConnectionOptions(hostInfo._id)
// 处理跳板机连接
let jumpHostResult = connectByJumpHosts && (await connectByJumpHosts(jumpHosts, targetConnectionOptions.host, targetConnectionOptions.port, socket))
if (jumpHostResult) {
targetConnectionOptions.sock = jumpHostResult.sock
jumpSshClients = jumpHostResult.sshClients
jumpSshClientsPool.push(jumpSshClients)
consola.success('Onekey跳板机连接成功')
// 使用通用的代理和跳板机连接处理函数
try {
const result = await handleProxyAndJumpHostConnection({
hostInfo,
targetConnectionOptions,
socket: null, // Onekey不需要发送terminal_print_info事件
logPrefix: 'Onekey '
})
jumpSshClients = result.jumpSshClients
if (jumpSshClients.length > 0) {
jumpSshClientsPool.push(jumpSshClients)
}
} catch (proxyError) {
curRes.status = execStatusEnum.connectFail
curRes.result += `代理连接失败: ${ proxyError.message }`
socket.emit('output', execResult)
resolve(curRes)
return
}
consola.info('准备连接终端执行一次性指令:', host)

File diff suppressed because one or more lines are too long

View File

@@ -6,8 +6,7 @@ const { sftpCacheDir } = require('../config')
const { verifyAuthSync } = require('../utils/verify-auth')
const { isAllowedIp } = require('../utils/tools')
const { HostListDB, FavoriteSftpDB } = require('../utils/db-class')
const { getConnectionOptions } = require('./terminal')
const decryptAndExecuteAsync = require('../utils/decrypt-file')
const { getConnectionOptions, handleProxyAndJumpHostConnection } = require('./terminal')
const hostListDB = new HostListDB().getInstance()
const favoriteSftpDB = new FavoriteSftpDB().getInstance()
const { Client: SSHClient } = require('ssh2')
@@ -1355,15 +1354,21 @@ module.exports = (httpServer) => {
const targetHostInfo = await hostListDB.findOneAsync({ _id: hostId })
if (!targetHostInfo) throw new Error(`Host with ID ${ hostId } not found`)
let { connectByJumpHosts = null } = (await decryptAndExecuteAsync(rawPath.join(__dirname, 'plus.js'))) || {}
let { authType, host, port, username, jumpHosts } = targetHostInfo
let { authType, host, port, username } = targetHostInfo
let { authInfo: targetConnectionOptions } = await getConnectionOptions(hostId)
let jumpHostResult = connectByJumpHosts && (await connectByJumpHosts(jumpHosts, targetConnectionOptions.host, targetConnectionOptions.port, socket))
if (jumpHostResult) {
targetConnectionOptions.sock = jumpHostResult.sock
jumpSshClients = jumpHostResult.sshClients
consola.success('sftp-v2 跳板机连接成功')
// 使用通用的代理和跳板机连接处理函数
try {
const result = await handleProxyAndJumpHostConnection({
hostInfo: targetHostInfo,
targetConnectionOptions,
socket: null, // SFTP不需要发送terminal_print_info事件
logPrefix: 'SFTP '
})
jumpSshClients = result.jumpSshClients
} catch (proxyError) {
socket.emit('connect_fail', `代理连接失败: ${ proxyError.message }`)
return
}
consola.info('准备连接sftp-v2 面板:', host)
@@ -1406,17 +1411,17 @@ module.exports = (httpServer) => {
})
// 添加未处理异常捕获
sftpClient.client.on('timeout', () => {
consola.warn('SSH连接超时')
try {
socket.emit('shell_connection_error', {
message: 'SSH连接超时',
code: 'CONNECTION_TIMEOUT'
})
} catch (emitError) {
consola.error('发送超时事件失败:', emitError.message)
}
})
// sftpClient.client.on('timeout', () => {
// consola.warn('SSH连接超时')
// try {
// socket.emit('shell_connection_error', {
// message: 'SSH连接超时',
// code: 'CONNECTION_TIMEOUT'
// })
// } catch (emitError) {
// consola.error('发送超时事件失败:', emitError.message)
// }
// })
sftpClient.client.on('keyboard-interactive', function (name, instructions, instructionsLang, prompts, finish) {
finish([targetConnectionOptions[authType]])

View File

@@ -1,22 +0,0 @@
const path = require('path')
const { Server } = require('socket.io')
const decryptAndExecuteAsync = require('../utils/decrypt-file')
async function createServerIo(serverIo) {
let { createSingleWindowServerIo = null } = (await decryptAndExecuteAsync(path.join(__dirname, 'plus.js'))) || {}
if (!createSingleWindowServerIo) {
consola.info('单窗口模式功能未解锁')
return
}
createSingleWindowServerIo(serverIo)
}
module.exports = (httpServer) => {
const serverIo = new Server(httpServer, {
path: '/terminal-single-window',
cors: {
origin: '*'
}
})
createServerIo(serverIo)
}

View File

@@ -1,233 +1,347 @@
const path = require('path')
const { Server } = require('socket.io')
const { Client: SSHClient } = require('ssh2')
const { verifyAuthSync } = require('../utils/verify-auth')
const { sendNoticeAsync } = require('../utils/notify')
const { isAllowedIp, ping } = require('../utils/tools')
const { AESDecryptAsync } = require('../utils/encrypt')
const { HostListDB, CredentialsDB } = require('../utils/db-class')
const decryptAndExecuteAsync = require('../utils/decrypt-file')
const hostListDB = new HostListDB().getInstance()
const credentialsDB = new CredentialsDB().getInstance()
async function getConnectionOptions(hostId) {
const hostInfo = await hostListDB.findOneAsync({ _id: hostId })
if (!hostInfo) throw new Error(`Host with ID ${ hostId } not found`)
let { authType, host, port, username, name } = hostInfo
let authInfo = { host, port, username }
try {
if (authType === 'credential') {
let credentialId = await AESDecryptAsync(hostInfo[authType])
const sshRecord = await credentialsDB.findOneAsync({ _id: credentialId })
authInfo.authType = sshRecord.authType
authInfo[authInfo.authType] = await AESDecryptAsync(sshRecord[authInfo.authType])
} else {
authInfo[authType] = await AESDecryptAsync(hostInfo[authType])
}
return { authInfo, name }
} catch (err) {
throw new Error(`解密认证信息失败: ${ err.message }`)
}
}
function createInteractiveShell(socket, targetSSHClient) {
return new Promise((resolve, reject) => {
// 检查SSH客户端连接状态
if (!targetSSHClient || !targetSSHClient._sock || !targetSSHClient._sock.writable) {
const errorMsg = 'SSH客户端连接已断开无法创建交互式终端'
consola.error(errorMsg)
socket.emit('terminal_connect_fail', errorMsg)
return reject(new Error(errorMsg))
}
try {
targetSSHClient.shell({ term: 'xterm-color' }, (err, stream) => {
if (err) {
consola.error('创建交互式终端失败:', err.message)
socket.emit('terminal_connect_fail', err.message)
return reject(err)
}
resolve(stream)
stream
.on('data', (data) => {
socket.emit('output', data.toString())
})
.on('close', () => {
consola.info('交互终端已关闭')
targetSSHClient.end()
})
.on('error', (streamErr) => {
consola.error('终端流错误:', streamErr.message)
socket.emit('terminal_connect_fail', streamErr.message)
})
socket.emit('terminal_connect_shell_success') // 已连接终端web端可以执行指令了
})
} catch (shellError) {
consola.error('调用shell方法失败:', shellError.message)
socket.emit('terminal_connect_fail', shellError.message)
reject(shellError)
}
})
}
async function createTerminal(hostId, socket, targetSSHClient, isInteractiveShell = true) {
consola.info(`准备创建${ isInteractiveShell ? '交互式' : '非交互式' }终端:${ hostId }`)
return new Promise(async (resolve) => {
const targetHostInfo = await hostListDB.findOneAsync({ _id: hostId })
if (!targetHostInfo) return socket.emit('create_fail', `查找hostId【${ hostId }】凭证信息失败`)
let { connectByJumpHosts = null } = (await decryptAndExecuteAsync(path.join(__dirname, 'plus.js'))) || {}
let { authType, host, port, username, name, jumpHosts } = targetHostInfo
try {
let { authInfo: targetConnectionOptions } = await getConnectionOptions(hostId)
let jumpHostResult = connectByJumpHosts && (await connectByJumpHosts(jumpHosts, targetConnectionOptions.host, targetConnectionOptions.port, socket))
let jumpSshClients = []
if (jumpHostResult) {
targetConnectionOptions.sock = jumpHostResult.sock
jumpSshClients = jumpHostResult.sshClients
}
socket.emit('terminal_print_info', `准备连接目标终端: ${ name } - ${ host }`)
socket.emit('terminal_print_info', `连接信息: ssh ${ username }@${ host } -p ${ port } -> ${ authType }`)
consola.info('准备连接目标终端:', host)
consola.log('连接信息', { username, port, authType })
let closeNoticeFlag = false // 避免重复发送通知
targetSSHClient
.on('ready', async () => {
consola.success('终端连接成功:', host)
if (isInteractiveShell) {
sendNoticeAsync('host_login', '终端登录', `别名: ${ name } \n IP${ host } \n 端口:${ port } \n 状态: 登录成功`)
socket.emit('terminal_print_info', `终端连接成功: ${ name } - ${ host }`)
socket.emit('terminal_connect_success', `终端连接成功:${ host }`)
try {
let stream = await createInteractiveShell(socket, targetSSHClient)
resolve({ stream, jumpSshClients })
} catch (shellError) {
consola.error('创建交互式终端失败:', host, shellError.message)
// 连接已经成功但创建shell失败需要清理连接
targetSSHClient.end()
jumpSshClients?.forEach(sshClient => sshClient && sshClient.end())
}
} else {
resolve({ jumpSshClients })
}
})
.on('close', (err) => {
if (closeNoticeFlag) return closeNoticeFlag = false
const closeReason = err ? '发生错误导致连接断开' : '正常断开连接'
consola.info(`终端连接断开(${ closeReason }): ${ host }`)
socket.emit('terminal_connect_close', { reason: closeReason })
})
.on('error', (err) => {
closeNoticeFlag = true
sendNoticeAsync('host_login', '终端登录', `别名: ${ name } \n IP${ host } \n 端口:${ port } \n 状态: 登录失败`)
consola.error('连接终端失败:', host, err.message)
socket.emit('terminal_connect_fail', err.message)
})
.on('keyboard-interactive', function (name, instructions, instructionsLang, prompts, finish) {
finish([targetConnectionOptions[authType]])
})
.connect({
tryKeyboard: true,
...targetConnectionOptions
// debug: (info) => console.log(info)
})
} catch (err) {
consola.error('创建终端失败: ', host, err.message)
socket.emit('terminal_create_fail', err.message)
}
})
}
function createServerIo(serverIo) {
let connectionCount = 0
serverIo.on('connection', (socket) => {
connectionCount++
consola.success(`terminal websocket 已连接 - 当前连接数: ${ connectionCount }`)
let requestIP = socket.handshake.headers['x-forwarded-for'] || socket.handshake.address
if (!isAllowedIp(requestIP)) {
socket.emit('ip_forbidden', 'IP地址不在白名单中')
socket.disconnect()
return
}
consola.success('terminal websocket 已连接')
let targetSSHClient = null
let jumpSshClients = []
socket.on('ws_terminal', async ({ hostId, token }) => {
try {
const { code } = await verifyAuthSync(token, requestIP)
if (code !== 1) {
socket.emit('token_verify_fail')
socket.disconnect()
return
}
targetSSHClient = new SSHClient()
let result = await createTerminal(hostId, socket, targetSSHClient, true)
// 如果创建终端失败result可能为undefined
if (!result) {
consola.error('创建终端失败,未返回结果')
return
}
let { stream = null, jumpSshClients: jumpSshClientsFromCreate } = result
jumpSshClients = jumpSshClientsFromCreate || []
const listenerInput = (key) => {
if (!targetSSHClient || !targetSSHClient._sock || !targetSSHClient._sock.writable) {
consola.info('终端连接已关闭,禁止输入')
return
}
stream && stream.write(key)
}
const resizeShell = ({ rows, cols }) => {
stream && stream.setWindow(rows, cols)
}
socket.on('input', listenerInput)
socket.on('resize', resizeShell)
} catch (error) {
consola.error('ws_terminal事件处理失败:', error.message)
socket.emit('terminal_connect_fail', `连接失败: ${ error.message }`)
}
})
socket.on('get_ping', async (ip) => {
try {
socket.emit('ping_data', await ping(ip, 2500))
} catch (error) {
socket.emit('ping_data', { success: false, msg: error.message })
}
})
socket.on('disconnect', (reason) => {
connectionCount--
targetSSHClient && targetSSHClient.end()
jumpSshClients?.forEach(sshClient => sshClient && sshClient.end())
targetSSHClient = null
jumpSshClients = null
consola.info(`终端socket连接断开: ${ reason } - 当前连接数: ${ connectionCount }`)
})
})
}
module.exports = (httpServer) => {
const serverIo = new Server(httpServer, {
path: '/terminal',
cors: {
origin: '*'
}
})
createServerIo(serverIo)
}
module.exports.getConnectionOptions = getConnectionOptions
module.exports.createTerminal = createTerminal
module.exports.createServerIo = createServerIo
const path = require('path')
const { Server } = require('socket.io')
const { Client: SSHClient } = require('ssh2')
const { verifyAuthSync } = require('../utils/verify-auth')
const { sendNoticeAsync } = require('../utils/notify')
const { isAllowedIp, ping } = require('../utils/tools')
const { AESDecryptAsync } = require('../utils/encrypt')
const { HostListDB, CredentialsDB, ProxyDB } = require('../utils/db-class')
const decryptAndExecuteAsync = require('../utils/decrypt-file')
const hostListDB = new HostListDB().getInstance()
const credentialsDB = new CredentialsDB().getInstance()
const proxyDB = new ProxyDB().getInstance()
async function getConnectionOptions(hostId) {
const hostInfo = await hostListDB.findOneAsync({ _id: hostId })
if (!hostInfo) throw new Error(`Host with ID ${ hostId } not found`)
let { authType, host, port, username, name } = hostInfo
let authInfo = { host, port, username }
try {
if (authType === 'credential') {
let credentialId = await AESDecryptAsync(hostInfo[authType])
const sshRecord = await credentialsDB.findOneAsync({ _id: credentialId })
authInfo.authType = sshRecord.authType
authInfo[authInfo.authType] = await AESDecryptAsync(sshRecord[authInfo.authType])
} else {
authInfo[authType] = await AESDecryptAsync(hostInfo[authType])
}
return { authInfo, name }
} catch (err) {
throw new Error(`解密认证信息失败: ${ err.message }`)
}
}
function createInteractiveShell(socket, targetSSHClient) {
return new Promise((resolve, reject) => {
// 检查SSH客户端连接状态
if (!targetSSHClient || !targetSSHClient._sock || !targetSSHClient._sock.writable) {
const errorMsg = 'SSH客户端连接已断开无法创建交互式终端'
consola.error(errorMsg)
socket.emit('terminal_connect_fail', errorMsg)
return reject(new Error(errorMsg))
}
try {
targetSSHClient.shell({ term: 'xterm-color' }, (err, stream) => {
if (err) {
consola.error('创建交互式终端失败:', err.message)
socket.emit('terminal_connect_fail', err.message)
return reject(err)
}
resolve(stream)
stream
.on('data', (data) => {
socket.emit('output', data.toString())
})
.on('close', () => {
consola.info('交互终端已关闭')
targetSSHClient.end()
})
.on('error', (streamErr) => {
consola.error('终端流错误:', streamErr.message)
socket.emit('terminal_connect_fail', streamErr.message)
})
socket.emit('terminal_connect_shell_success') // 已连接终端web端可以执行指令了
})
} catch (shellError) {
consola.error('调用shell方法失败:', shellError.message)
socket.emit('terminal_connect_fail', shellError.message)
reject(shellError)
}
})
}
// 获取代理配置信息
async function getProxyConfig(proxyId) {
if (!proxyId) return null
try {
const proxyInfo = await proxyDB.findOneAsync({ _id: proxyId })
if (!proxyInfo) {
throw new Error(`代理配置 ID ${ proxyId } 未找到`)
}
return {
id: proxyInfo._id,
name: proxyInfo.name,
type: proxyInfo.type, // 'socks5' 或 'http'
host: proxyInfo.host,
port: proxyInfo.port,
username: proxyInfo.username || '',
password: proxyInfo.password || ''
}
} catch (error) {
consola.error('获取代理配置失败:', error.message)
throw error
}
}
// 通用的代理和跳板机连接处理函数
async function handleProxyAndJumpHostConnection(options) {
const {
hostInfo,
targetConnectionOptions,
socket,
logPrefix = ''
} = options
const { proxyType, proxyServer, jumpHosts, host } = hostInfo
let jumpSshClients = []
try {
// 代理连接
if (proxyType === 'proxyServer' && proxyServer) {
const proxyConfig = await getProxyConfig(proxyServer)
if (proxyConfig) {
const logMsg = `${ logPrefix }使用代理服务器: ${ proxyConfig.name } (${ proxyConfig.type.toUpperCase() }) - ${ proxyConfig.host }:${ proxyConfig.port }`
consola.info(logMsg)
// 向前端发送代理信息如果socket存在且有对应方法
if (socket && socket.emit) {
if (typeof socket.emit === 'function') {
try {
socket.emit('terminal_print_info', `使用代理服务器: ${ proxyConfig.name } (${ proxyConfig.type.toUpperCase() }) - ${ proxyConfig.host }:${ proxyConfig.port }`)
} catch (emitError) {
// 忽略emit错误因为不同socket可能有不同的事件
}
}
}
let proxySocket
if (proxyConfig.type === 'socks5') {
const { createSocks5Connection = null } = (await decryptAndExecuteAsync(path.join(__dirname, 'plus.js'))) || {}
if (!createSocks5Connection) throw new Error('Plus功能解锁失败: createSocks5Connection')
proxySocket = await createSocks5Connection(proxyConfig, targetConnectionOptions.host, targetConnectionOptions.port)
} else if (proxyConfig.type === 'http') {
const { createHttpConnection = null } = (await decryptAndExecuteAsync(path.join(__dirname, 'plus.js'))) || {}
if (!createHttpConnection) throw new Error('Plus功能解锁失败: createHttpConnection')
proxySocket = await createHttpConnection(proxyConfig, targetConnectionOptions.host, targetConnectionOptions.port)
} else {
throw new Error(`不支持的代理类型: ${ proxyConfig.type }`)
}
targetConnectionOptions.sock = proxySocket
consola.success(`${ logPrefix }代理连接建立成功: ${ host }`)
// 向前端发送成功信息
if (socket && socket.emit && typeof socket.emit === 'function') {
try {
socket.emit('terminal_print_info', '代理连接建立成功,准备通过代理连接目标服务器')
} catch (emitError) {
// 忽略emit错误
}
}
}
}
// 跳板机连接
else if (proxyType === 'jumpHosts' && Array.isArray(jumpHosts) && jumpHosts.length > 0) {
const { connectByJumpHosts = null } = (await decryptAndExecuteAsync(path.join(__dirname, 'plus.js'))) || {}
if (!connectByJumpHosts) throw new Error('Plus功能解锁失败: connectByJumpHosts')
const jumpHostResult = await connectByJumpHosts(jumpHosts, targetConnectionOptions.host, targetConnectionOptions.port, socket)
if (jumpHostResult) {
targetConnectionOptions.sock = jumpHostResult.sock
jumpSshClients = jumpHostResult.sshClients
consola.success(`${ logPrefix }跳板机连接成功`)
}
}
return {
targetConnectionOptions,
jumpSshClients
}
} catch (error) {
consola.error(`${ logPrefix }连接失败:`, error.message)
throw error
}
}
async function createTerminal(hostId, socket, targetSSHClient, isInteractiveShell = true) {
consola.info(`准备创建${ isInteractiveShell ? '交互式' : '非交互式' }终端:${ hostId }`)
return new Promise(async (resolve) => {
const targetHostInfo = await hostListDB.findOneAsync({ _id: hostId })
if (!targetHostInfo) return socket.emit('create_fail', `查找hostId【${ hostId }】凭证信息失败`)
let { authType, host, port, username, name } = targetHostInfo
try {
let { authInfo: targetConnectionOptions } = await getConnectionOptions(hostId)
// 使用通用的代理和跳板机连接处理函数
let jumpSshClients = []
try {
const result = await handleProxyAndJumpHostConnection({
hostInfo: targetHostInfo,
targetConnectionOptions,
socket,
logPrefix: 'Terminal '
})
jumpSshClients = result.jumpSshClients
} catch (proxyError) {
socket.emit('terminal_connect_fail', `代理连接失败: ${ proxyError.message }`)
return
}
socket.emit('terminal_print_info', `准备连接目标终端: ${ name } - ${ host }`)
socket.emit('terminal_print_info', `连接信息: ssh ${ username }@${ host } -p ${ port } -> ${ authType }`)
consola.info('准备连接目标终端:', host)
consola.log('连接信息', { username, port, authType })
let closeNoticeFlag = false // 避免重复发送通知
targetSSHClient
.on('ready', async () => {
consola.success('终端连接成功:', host)
if (isInteractiveShell) {
sendNoticeAsync('host_login', '终端登录', `别名: ${ name } \n IP${ host } \n 端口:${ port } \n 状态: 登录成功`)
socket.emit('terminal_print_info', `终端连接成功: ${ name } - ${ host }`)
socket.emit('terminal_connect_success', `终端连接成功:${ host }`)
try {
let stream = await createInteractiveShell(socket, targetSSHClient)
resolve({ stream, jumpSshClients })
} catch (shellError) {
consola.error('创建交互式终端失败:', host, shellError.message)
// 连接已经成功但创建shell失败需要清理连接
targetSSHClient.end()
jumpSshClients?.forEach(sshClient => sshClient && sshClient.end())
}
} else {
resolve({ jumpSshClients })
}
})
.on('close', (err) => {
if (closeNoticeFlag) return closeNoticeFlag = false
const closeReason = err ? '发生错误导致连接断开' : '正常断开连接'
consola.info(`终端连接断开(${ closeReason }): ${ host }`)
socket.emit('terminal_connect_close', { reason: closeReason })
})
.on('error', (err) => {
closeNoticeFlag = true
sendNoticeAsync('host_login', '终端登录', `别名: ${ name } \n IP${ host } \n 端口:${ port } \n 状态: 登录失败`)
consola.error('连接终端失败:', host, err.message)
socket.emit('terminal_connect_fail', err.message)
})
.on('keyboard-interactive', function (name, instructions, instructionsLang, prompts, finish) {
finish([targetConnectionOptions[authType]])
})
.connect({
tryKeyboard: true,
...targetConnectionOptions
// debug: (info) => console.log(info)
})
} catch (err) {
consola.error('创建终端失败: ', host, err.message)
socket.emit('terminal_create_fail', err.message)
}
})
}
function createServerIo(serverIo) {
let connectionCount = 0
serverIo.on('connection', (socket) => {
connectionCount++
consola.success(`terminal websocket 已连接 - 当前连接数: ${ connectionCount }`)
let requestIP = socket.handshake.headers['x-forwarded-for'] || socket.handshake.address
if (!isAllowedIp(requestIP)) {
socket.emit('ip_forbidden', 'IP地址不在白名单中')
socket.disconnect()
return
}
consola.success('terminal websocket 已连接')
let targetSSHClient = null
let jumpSshClients = []
socket.on('ws_terminal', async ({ hostId, token }) => {
try {
const { code } = await verifyAuthSync(token, requestIP)
if (code !== 1) {
socket.emit('token_verify_fail')
socket.disconnect()
return
}
targetSSHClient = new SSHClient()
let result = await createTerminal(hostId, socket, targetSSHClient, true)
// 如果创建终端失败result可能为undefined
if (!result) {
consola.error('创建终端失败,未返回结果')
return
}
let { stream = null, jumpSshClients: jumpSshClientsFromCreate } = result
jumpSshClients = jumpSshClientsFromCreate || []
const listenerInput = (key) => {
if (!targetSSHClient || !targetSSHClient._sock || !targetSSHClient._sock.writable) {
consola.info('终端连接已关闭,禁止输入')
return
}
stream && stream.write(key)
}
const resizeShell = ({ rows, cols }) => {
stream && stream.setWindow(rows, cols)
}
socket.on('input', listenerInput)
socket.on('resize', resizeShell)
} catch (error) {
consola.error('ws_terminal事件处理失败:', error.message)
socket.emit('terminal_connect_fail', `连接失败: ${ error.message }`)
}
})
socket.on('get_ping', async (ip) => {
try {
socket.emit('ping_data', await ping(ip, 2500))
} catch (error) {
socket.emit('ping_data', { success: false, msg: error.message })
}
})
socket.on('disconnect', (reason) => {
connectionCount--
targetSSHClient && targetSSHClient.end()
jumpSshClients?.forEach(sshClient => sshClient && sshClient.end())
targetSSHClient = null
jumpSshClients = null
consola.info(`终端socket连接断开: ${ reason } - 当前连接数: ${ connectionCount }`)
})
})
}
module.exports = (httpServer) => {
const serverIo = new Server(httpServer, {
path: '/terminal',
cors: {
origin: '*'
}
})
createServerIo(serverIo)
}
module.exports.getConnectionOptions = getConnectionOptions
module.exports.createTerminal = createTerminal
module.exports.createServerIo = createServerIo
module.exports.getProxyConfig = getProxyConfig
module.exports.handleProxyAndJumpHostConnection = handleProxyAndJumpHostConnection

View File

@@ -13,7 +13,8 @@ const {
plusDBPath,
aiConfigDBPath,
chatHistoryDBPath,
favoriteSftpDBPath
favoriteSftpDBPath,
proxyDBPath
} = require('../config')
module.exports.KeyDB = class KeyDB {
@@ -178,3 +179,14 @@ module.exports.FavoriteSftpDB = class FavoriteSftpDB {
return FavoriteSftpDB.instance
}
}
module.exports.ProxyDB = class ProxyDB {
constructor() {
if (!ProxyDB.instance) {
ProxyDB.instance = new Datastore({ filename: proxyDBPath, autoload: true })
}
}
getInstance() {
return ProxyDB.instance
}
}

View File

@@ -1 +1 @@
U2FsdGVkX188iPUyO1f2xahkUzOdBH0ifn8ZV/KNKGkTSDSDEhT8qEioUui8+nM/p4NQJ2LAbVqK4fmyuG1rUy8VHbFBJGxEWCa5cgFemgxRWpZE2K9lWr1sopjfzsyN1hGfdi2oWZE8/x1MMw6Rc1K6guFYBuj4LGuGS/yp8wUpGe4Ifb5d/W81Y/YHqoH/WRUJc8V3EbnlHgweY7Yx6NcuRMHaqMVdL7P/1do4Xig4N5zqHdPmZE6gp2tAKh8WwpzpLgTqlXEEUFQk0KL07lYf7PNVKudsD2G5s1VYrhVhvR+1jeSh7AwLa4pABXg8bUo53SC4CCgSC57GLqldmnqJP8Kqr8ULSEdFVL0uLS4ogTALgXrrTvzr3hXAccIQSRLihD2BDK0MuDNcLpOXbYI6KQ6L4Z1/cT8VOteV7OMtJMC3EZDUroOwYS1oeToIQoZFHYKIyqlyThLEkb9zZBRDBvLisirGGvTh52bzw1BKE5IZznAe1A5N0n9U2eawqIUDJMlIRE4LEPuSzkNPDV6v3IiNsb3c7JO7t06WQMIs4Nene2v0SDwYbbZgBhfgEu+GkKwXh16w1txX/juJ09gfTj6vZiJNRk4bOGZJsPWujQhOjncjbYRq7dhZA1/0gjOQppX312g/yIznIYFKkl5LF868hptbFu6WgTbclqwLiUKX0D+Oe25mcMIDXdhjWeZZoEIXg5FFv1PaE5Qrj35Uowo4MzP6aVYw2zn9WlxGzbHPtAFCTKChcOuSV7RYuRETR8OIHyNcOlNBSbJ5KlALb2XS5XEtluhEF/G2iljXSvlACNHLz9EZ3E4Q9XubvMW5YBecdcW3XHipbusKZCA542BKxadQYQGGWryZ/wQ=
U2FsdGVkX19ypJFyeFxNF22xekv1FOR0XT1ofsw1MQ8fs/xIpP4rByqxDo2e7/E4w3sSaVpT3IUt5jH0alt4GeryiM3wfedmo+4eBiw2qTTBW0tQHCbh2tIIxJWPv3NdaMCv0fEzJNJf2CrhgrRSv9RpUpDYYojpg50wnKjL2Fiw37mGw9uqHowmgqoyxO14Otjbh9Sf2Y9DqoYr4SF/uvxPGeFpBO3op/+8gnqgN3r0mmF8DLn73m5a7NANNe6IuHXCwfu3DY9q9xgI6OEp2ecW/rxXijGRllMmVkdSKeG0xzSeV7q1PF+qpvEo9xz8FdVBOW4Te6G/hDpwA3WI0cwKY5nOs+aY4J8KNmtpsQnUMbjm9w5OjCrKOBzyQ6JIYbT6b7vP4jyF6FLOR0DJJ3JRP1OGbDLZhoWkXtNB7Mwa85KegKVx3Hg2eEsEug93jQrymm++YtFvoEbdAD5AeA8NX/qJRVqloOikv4d/mCy1BJaAQE4tAHNIldwMCvTarUKrYSxjdzO0sZmRuE4PhwjIGIN5dK8/GYR9vFfnGuNhM+w0gRJr1VDK+/6XnMp0PmQMx7MYO4UE2A4zulIsxhWxYRhzvmePPWf5rOIPnSl8dYx/otqgs6LY+FDkMWa99itdFFhCZxjapUJ2917ofYuAH8Y76QliRVgZ9HOPgpJOr/A02dt3c+Kp/p9cu6vAuGJ29l9JDEr1d35+/hRvvR6SEWYoalFEjkyEl4Y1cuxyNasAxkpxFlN9/ROl1D3qDk1L/lci4YC1aXlyW8jLyNthIXFMHWTiawA7xEV0iFiSMjDkpIpv+1/V8T+b7djl13/ALfgw/Zw7rNDKNqj8vaYLNO2T7epyFeESWE8EY9Q=

View File

@@ -1,6 +1,6 @@
{
"name": "server",
"version": "3.2.0",
"version": "3.3.0",
"description": "easynode-server",
"bin": "./bin/www",
"scripts": {
@@ -48,6 +48,7 @@
"qrcode": "^1.5.4",
"socket.io": "^4.7.5",
"socket.io-client": "^4.7.5",
"socks": "^2.8.6",
"speakeasy": "^2.0.0",
"ssh2": "^1.15.0",
"ssh2-sftp-client": "^10.0.3"

View File

@@ -1,6 +1,6 @@
{
"name": "web",
"version": "3.2.0",
"version": "3.3.0",
"description": "easynode-web",
"private": true,
"scripts": {

View File

@@ -168,5 +168,17 @@ export default {
},
removeSomeLoginRecords() {
return axios({ url: '/remove-some-login-records', method: 'delete' })
},
getProxyList() {
return axios({ url: '/proxy', method: 'get' })
},
addProxy(data) {
return axios({ url: '/proxy', method: 'post', data })
},
updateProxy(id, data) {
return axios({ url: `/proxy/${ id }`, method: 'put', data })
},
removeProxy(id) {
return axios({ url: `/proxy/${ id }`, method: 'delete' })
}
}

View File

@@ -94,8 +94,9 @@ const basicFeatures = [
// Plus版专属功能列表
const plusFeatures = [
'AI Chat对话组件',
'服务器跳板机功能,支持任意数量服务器的连续跳板',
'批量修改实例配置(优化版)',
'服务器代理&跳板机功能',
'终端单窗口模式',
'批量修改实例配置',
'脚本库批量导出导入',
'脚本库分组与终端功能栏支持',
'终端功能栏docker容器管理',

View File

@@ -11,6 +11,7 @@ const useStore = defineStore('global', {
sshList: [],
scriptList: [],
scriptGroupList: [],
proxyList: [],
localScriptList: [],
user: localStorage.getItem('user') || null,
token: localStorage.getItem('token') || sessionStorage.getItem('token') || null,
@@ -76,6 +77,7 @@ const useStore = defineStore('global', {
await this.getScriptList()
await this.getScriptGroupList()
await this.getPlusInfo()
await this.getProxyList()
this.getAIConfig()
this.getChatHistory()
},
@@ -117,6 +119,10 @@ const useStore = defineStore('global', {
const { data: localScriptList } = await $api.getLocalScriptList()
this.$patch({ localScriptList })
},
async getProxyList() {
const { data: proxyList } = await $api.getProxyList()
this.$patch({ proxyList })
},
async getPlusInfo() {
const { data: plusInfo = {} } = await $api.getPlusInfo()
if (plusInfo?.expiryDate) {

View File

@@ -440,25 +440,6 @@ const handleRemoveAll = async () => {
})
}
onActivated(async () => {
await nextTick()
const { hostIds, execClientInstallScript } = route.query
if (!hostIds) return
if (execClientInstallScript === 'true') {
let clientInstallScript = 'curl -o- https://git.221022.xyz/https://raw.githubusercontent.com/chaos-zhu/easynode/main/client/easynode-client-install.sh | bash\n'
console.log(hostIds.split(','))
createExecShell(hostIds.split(','), clientInstallScript, 300)
// $messageBox.confirm(`准备安装客户端服务监控应用:${ host }`, 'Warning', {
// confirmButtonText: '确定',
// cancelButtonText: '取消',
// type: 'warning'
// })
// .then(async () => {
// let clientInstallScript = 'curl -o- https://git.221022.xyz/https://raw.githubusercontent.com/chaos-zhu/easynode/main/client/easynode-client-install.sh | bash\n'
// createExecShell([host,], clientInstallScript, 300)
// })
}
})
</script>
<style lang="scss" scoped>

View File

@@ -175,7 +175,83 @@
</el-option>
</el-select>
</el-form-item>
<PlusSupportTip>
<el-form-item
key="proxyType"
label="代理类型"
prop="proxyType"
>
<el-radio-group v-model="hostForm.proxyType" :disabled="!isPlusActive">
<el-radio value="">不使用代理</el-radio>
<el-radio value="proxyServer">代理服务</el-radio>
<el-radio value="jumpHosts">跳板机</el-radio>
</el-radio-group>
</el-form-item>
</PlusSupportTip>
<el-form-item
v-if="hostForm.proxyType === 'jumpHosts'"
key="jumpHosts"
prop="jumpHosts"
label="跳板机"
>
<el-select
v-model="hostForm.jumpHosts"
placeholder="支持多选,跳板机连接顺序从前到后"
multiple
:disabled="!isPlusActive"
>
<template #empty>
<div class="empty_text">
<span>无可用跳板机器</span>
</div>
</template>
<el-option
v-for="item in confHostList"
:key="item.id"
:label="item.name"
:value="item.id"
>
<div class="select_wrap">
<span>{{ item.name }}</span>
</div>
</el-option>
</el-select>
</el-form-item>
<el-form-item
v-if="hostForm.proxyType === 'proxyServer'"
key="proxyServer"
prop="proxyServer"
label="代理服务"
>
<el-select
v-model="hostForm.proxyServer"
placeholder=""
:disabled="!isPlusActive"
>
<template #empty>
<div class="empty_text">
<span>无可用代理服务,</span>
<el-button type="primary" link @click="toProxy">
去添加
</el-button>
</div>
</template>
<el-option
v-for="item in proxyList"
:key="item.id"
:label="item.name"
:value="item.id"
>
<div class="select_warp">
<span>{{ item.name }}</span>
<span class="auth_type_text">
{{ item.type === 'socks5' ? 'SOCKS5' : 'HTTP' }}
</span>
</div>
</el-option>
</el-select>
</el-form-item>
<!-- <el-form-item
key="jumpHosts"
prop="jumpHosts"
label="跳板机"
@@ -204,7 +280,7 @@
</el-option>
</el-select>
</PlusSupportTip>
</el-form-item>
</el-form-item> -->
<el-form-item key="command" prop="command" label="登录指令">
<el-input
v-model="hostForm.command"
@@ -327,16 +403,18 @@ const formField = {
consoleUrl: '',
remark: '',
command: '',
jumpHosts: []
proxyType: '', // , jumpHosts, proxyServer
jumpHosts: [],
proxyServer: ''
}
let hostForm = ref({ ...formField })
let privateKeyRef = ref(null)
let formRef = ref(null)
const hostForm = ref({ ...formField })
const privateKeyRef = ref(null)
const formRef = ref(null)
let isBatchModify = computed(() => props.isBatchModify)
let batchHosts = computed(() => props.batchHosts)
let defaultData = computed(() => props.defaultData)
const isBatchModify = computed(() => props.isBatchModify)
const batchHosts = computed(() => props.batchHosts)
const defaultData = computed(() => props.defaultData)
const rules = computed(() => {
return {
group: { required: !isBatchModify.value, message: '选择一个分组' },
@@ -364,12 +442,11 @@ const title = computed(() => {
return isBatchModify.value ? '批量修改实例' : (defaultData.value ? '修改实例' : '添加实例')
})
let groupList = computed(() => $store.groupList)
let sshList = computed(() => $store.sshList)
let hostList = computed(() => $store.hostList)
let confHostList = computed(() => {
return hostList.value?.filter(item => item.isConfig)
})
const groupList = computed(() => $store.groupList)
const sshList = computed(() => $store.sshList)
const hostList = computed(() => $store.hostList)
const confHostList = computed(() => hostList.value?.filter(item => item.isConfig))
const proxyList = computed(() => $store.proxyList)
const setDefaultData = () => {
if (!defaultData.value) {
@@ -388,7 +465,7 @@ const setDefaultData = () => {
const setBatchDefaultData = () => {
if (!isBatchModify.value) return
Object.assign(hostForm.value, { ...formField }, { group: '', port: '', username: '', authType: '', jumpHosts: [] })
Object.assign(hostForm.value, { ...formField }, { group: '', port: '', username: '', authType: '', proxyType: '', jumpHosts: [], proxyServer: '' })
}
const handleOpen = async () => {
if (isBatchModify.value) {
@@ -438,6 +515,11 @@ const userSearch = (keyword, cb) => {
cb(res)
}
const toProxy = () => {
visible.value = false
$router.push({ path: '/setting', query: { tabKey: 'proxy' } })
}
const toCredentials = () => {
visible.value = false
$router.push({ path: '/credentials' })

View File

@@ -12,7 +12,6 @@
<el-dropdown-item @click="handleBatchSSH">连接终端</el-dropdown-item>
<el-dropdown-item @click="handleBatchModify">批量修改</el-dropdown-item>
<el-dropdown-item @click="handleBatchRemove">批量删除</el-dropdown-item>
<el-dropdown-item @click="handleBatchOnekey">安装客户端</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
@@ -148,13 +147,6 @@ let handleUpdateHost = (defaultData) => {
updateHostData.value = defaultData
}
let handleBatchOnekey = async () => {
collectSelectHost()
if (!selectHosts.value.length) return $message.warning('请选择要批量操作的实例')
let ids = selectHosts.value.map(item => item.id).join(',')
$router.push({ path: '/onekey', query: { hostIds: ids, execClientInstallScript: 'true' } })
}
let handleBatchExport = () => {
collectSelectHost()
if (!selectHosts.value.length) return $message.warning('请选择要批量操作的实例')

View File

@@ -0,0 +1,343 @@
<template>
<div class="proxy-container">
<div class="operation-bar">
<PlusSupportTip>
<el-button
type="primary"
:icon="Plus"
:disabled="!isPlusActive"
@click="handleAdd"
>
添加代理
</el-button>
</PlusSupportTip>
</div>
<el-table
v-loading="loading"
:data="proxyList"
stripe
style="width: 100%"
empty-text="暂无代理数据"
>
<el-table-column prop="type" label="类型">
<template #default="{ row }">
<el-tag :type="row.type === 'socks5' ? 'success' : 'primary'">
{{ row.type === 'socks5' ? 'SOCKS5' : 'HTTP' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="name" label="名称" />
<el-table-column prop="host" label="主机" />
<el-table-column prop="port" label="端口" />
<el-table-column prop="username" label="用户名">
<template #default="{ row }">
<span>{{ row.username || '--' }}</span>
</template>
</el-table-column>
<el-table-column prop="password" label="密码">
<template #default="{ row }">
<span @click="handleShowPassword(row)">{{ row.displayPassword || '--' }}</span>
</template>
</el-table-column>
<el-table-column prop="createTime" label="创建时间">
<template #default="{ row }">
<span>{{ dayjs(row.createTime).format('YYYY-MM-DD HH:mm:ss') }}</span>
</template>
</el-table-column>
<el-table-column label="操作" width="250" fixed="right">
<template #default="{ row }">
<el-button type="primary" @click="handleEdit(row)">
编辑
</el-button>
<el-button type="success" @click="handleClone(row)">
克隆
</el-button>
<el-button type="danger" @click="handleDelete(row)">
删除
</el-button>
</template>
</el-table-column>
</el-table>
<el-dialog
v-model="dialogVisible"
:title="dialogTitle"
width="500px"
:close-on-click-modal="false"
:close-on-press-escape="false"
>
<el-form
ref="formRef"
:model="formData"
:rules="rules"
label-width="80px"
label-suffix=""
>
<el-form-item label="类型" prop="type">
<el-select
v-model="formData.type"
placeholder="请选择代理类型"
style="width: 100%"
clearable
>
<el-option label="HTTP" value="http" />
<el-option label="SOCKS5" value="socks5" />
</el-select>
</el-form-item>
<el-form-item label="名称" prop="name">
<el-input
v-model.trim="formData.name"
placeholder="请输入代理名称"
maxlength="50"
show-word-limit
/>
</el-form-item>
<el-form-item label="主机" prop="host">
<el-input
v-model.trim="formData.host"
placeholder="请输入主机地址"
/>
</el-form-item>
<el-form-item label="端口" prop="port">
<el-input
v-model.number="formData.port"
placeholder="请输入端口号"
type="number"
:min="1"
:max="65535"
/>
</el-form-item>
<el-form-item label="用户名" prop="username">
<el-input
v-model.trim="formData.username"
placeholder="可选,请输入用户名"
maxlength="100"
/>
</el-form-item>
<el-form-item label="密码" prop="password">
<el-input
v-model.trim="formData.password"
placeholder="可选,请输入密码"
maxlength="200"
/>
</el-form-item>
</el-form>
<template #footer>
<div class="dialog-footer">
<el-button @click="handleCancel">取消</el-button>
<el-button type="primary" :loading="submitLoading" @click="handleSubmit">确定</el-button>
</div>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, reactive, getCurrentInstance, computed } from 'vue'
import { Plus } from '@element-plus/icons-vue'
import dayjs from 'dayjs'
import PlusSupportTip from '@/components/common/PlusSupportTip.vue'
const { proxy: { $api, $message, $messageBox, $store } } = getCurrentInstance()
const loading = ref(false)
const submitLoading = ref(false)
const dialogVisible = ref(false)
const dialogTitle = ref('添加代理')
const formRef = ref(null)
const currentEditId = ref(null)
const proxyList = computed(() => {
if (Array.isArray($store.proxyList)) {
return $store.proxyList.map(item => {
item.displayPassword = formatPassword(item.password)
return item
})
}
return []
})
const isPlusActive = computed(() => $store.isPlusActive)
// 表单数据
const formData = reactive({
type: 'socks5',
name: '',
host: '',
port: '',
username: '',
password: ''
})
// 表单验证规则
const rules = reactive({
type: [
{ required: true, message: '请选择代理类型', trigger: 'change' },
],
name: [
{ required: true, message: '请输入代理名称', trigger: 'blur' },
{ min: 1, max: 50, message: '名称长度应在 1 到 50 个字符之间', trigger: 'blur' },
],
host: [
{ required: true, message: '请输入主机地址', trigger: 'blur' },
],
port: [
{ required: true, message: '请输入端口号', trigger: 'blur' },
{ type: 'number', min: 1, max: 65535, message: '端口号必须是1-65535之间的整数', trigger: 'blur' },
]
})
// 格式化密码显示
const formatPassword = (password) => {
if (!password) return '-'
if (password.length <= 6) {
return '*'.repeat(password.length)
}
const start = password.slice(0, 3)
const end = password.slice(-3)
const middle = '*'.repeat(password.length - 6)
return start + middle + end
}
const resetForm = () => {
Object.assign(formData, {
type: 'socks5',
name: '',
host: '',
port: '',
username: '',
password: ''
})
currentEditId.value = null
formRef.value?.clearValidate()
}
const handleAdd = () => {
resetForm()
dialogTitle.value = '添加代理'
dialogVisible.value = true
}
const handleEdit = (row) => {
resetForm()
Object.assign(formData, {
type: row.type,
name: row.name,
host: row.host,
port: row.port,
username: row.username || '',
password: row.password || ''
})
currentEditId.value = row.id
dialogTitle.value = '编辑代理'
dialogVisible.value = true
}
const handleDelete = async (row) => {
try {
await $messageBox.confirm(
`确定要删除代理"${ row.name }"吗?`,
'删除确认',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}
)
await $api.removeProxy(row.id)
$message.success('删除成功')
await $store.getProxyList()
await $store.getHostList() // 后台会移除所有使用该代理的实例的proxyServer字段
} catch (error) {
// 如果是用户取消操作,不显示错误
if (error === 'cancel') {
return
}
console.error('删除代理失败:', error)
$message.error('删除代理失败')
}
}
const handleClone = async (row) => {
try {
// 构造克隆数据
const cloneData = {
type: row.type,
name: `${ row.name }_克隆`,
host: row.host,
port: row.port,
username: row.username || '',
password: row.password || ''
}
// 直接调用新增接口
await $api.addProxy(cloneData)
$message.success('克隆成功')
// 刷新代理列表
await $store.getProxyList()
} catch (error) {
console.error('克隆代理失败:', error)
$message.error('克隆代理失败')
}
}
const handleCancel = () => {
dialogVisible.value = false
resetForm()
}
const handleSubmit = async () => {
try {
const valid = await formRef.value.validate()
if (!valid) return
submitLoading.value = true
if (currentEditId.value) {
// 编辑
await $api.updateProxy(currentEditId.value, formData)
$message.success('修改成功')
} else {
// 新增
await $api.addProxy(formData)
$message.success('添加成功')
}
dialogVisible.value = false
resetForm()
await $store.getProxyList()
} catch (error) {
console.error('操作失败:', error)
$message.error('操作失败')
} finally {
submitLoading.value = false
}
}
const handleShowPassword = (row) => {
row.displayPassword = row.password
}
</script>
<style lang="scss" scoped>
.proxy-container {
.operation-bar {
margin-bottom: 20px;
display: flex;
justify-content: flex-end;
}
}
.dialog-footer {
display: flex;
justify-content: flex-end;
gap: 10px;
}
</style>

View File

@@ -13,6 +13,9 @@
<el-tab-pane label="通知配置" name="notify-config">
<NotifyConfig />
</el-tab-pane>
<el-tab-pane label="代理服务" name="proxy">
<Proxy />
</el-tab-pane>
<el-tab-pane label="Plus激活" name="plus">
<UserPlus />
</el-tab-pane>
@@ -28,6 +31,7 @@ import Record from './components/record.vue'
import User from './components/user.vue'
import NotifyConfig from './components/notify-config.vue'
import UserPlus from './components/user-plus.vue'
import Proxy from './components/proxy.vue'
const route = useRoute()
const router = useRouter()

View File

@@ -153,7 +153,7 @@ const getCommand = async () => {
const connectIO = () => {
curStatus.value = CONNECTING
socket.value = io($serviceURI, {
path: props.isSingleWindow ? '/terminal-single-window' : '/terminal',
path: '/terminal',
forceNew: false,
reconnection: false,
reconnectionAttempts: 0
@@ -165,6 +165,7 @@ const connectIO = () => {
socket.value.emit('ws_terminal', { hostId: hostId.value, token: token.value })
socket.value.on('terminal_connect_success', () => {
socket.value.on('output', (str) => {
if (props.isSingleWindow && !isPlusActive.value) return
term.value.write(str)
terminalText.value += str
})

597
yarn.lock

File diff suppressed because it is too large Load Diff