feat(proxy): HTTP/SOCKS5 代理支持,按会话独立配置 | 15 files +483/-23 | 96/96 tests passing
功能: - 代理类型定义 (ProxyType, ProxyConfig) - 代理工具函数 (验证、解析、构建URL) + 24 测试 - SSH 连接通过 HTTP CONNECT / SOCKS5 隧道 - 数据库 proxy 列持久化 (JSON) - SessionDialog 代理配置 UI (可折叠面板) - 连接池传递 proxy 到 IPC - sessionStore 类型添加 proxy 支持
This commit is contained in:
1 parent
3f636b1b0e
commit
07c4a5b14f
15 files changed
+757
-23
No files matched your search
@@ -196,7 +196,7 @@
|
|||||||
| 主题系统 | ✅ | 90% | Dark/Light/System |
|
| 主题系统 | ✅ | 90% | Dark/Light/System |
|
||||||
| 终端分屏 | 🚧 | 0% | v1.4.0 计划 |
|
| 终端分屏 | 🚧 | 0% | v1.4.0 计划 |
|
||||||
| i18n 国际化 | 🚧 | 5% | v1.4.0 计划 |
|
| i18n 国际化 | 🚧 | 5% | v1.4.0 计划 |
|
||||||
| 代理设置 | 🚧 | 0% | v1.4.0 计划 |
|
| 代理设置 | ✅ | 85% | v1.4.0: HTTP/SOCKS5 代理+按会话配置 |
|
||||||
| 多窗口支持 | 🚧 | 0% | v1.4.0 计划 |
|
| 多窗口支持 | 🚧 | 0% | v1.4.0 计划 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
- 📑 **多标签页** - 类浏览器标签管理,连接池复用,会话恢复
|
- 📑 **多标签页** - 类浏览器标签管理,连接池复用,会话恢复
|
||||||
- 🎨 **现代 UI** - 多主题系统 (Dark/Light/System),命令面板,命令片段库
|
- 🎨 **现代 UI** - 多主题系统 (Dark/Light/System),命令面板,命令片段库
|
||||||
- ⌨️ **快捷键自定义** - 可重映射所有快捷键,实时冲突检测
|
- ⌨️ **快捷键自定义** - 可重映射所有快捷键,实时冲突检测
|
||||||
|
- 🛡️ **代理支持** - HTTP/SOCKS5 代理,按会话独立配置
|
||||||
- 🔒 **安全可靠** - AES-256-GCM 加密存储,PBKDF2 600K 迭代密钥派生
|
- 🔒 **安全可靠** - AES-256-GCM 加密存储,PBKDF2 600K 迭代密钥派生
|
||||||
- 🌐 **跨平台** - 支持 Windows、macOS、Linux
|
- 🌐 **跨平台** - 支持 Windows、macOS、Linux
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -100,7 +100,7 @@ v1.5.0 安全升级 ─── 1 周 ──→ 安全 55→85, 代码 80→85
|
|||||||
| 1 | 快捷键自定义系统 | 新增 | 3天 | 设置页快捷键 Tab,可重映射所有快捷键,冲突检测 | ✅ |
|
| 1 | 快捷键自定义系统 | 新增 | 3天 | 设置页快捷键 Tab,可重映射所有快捷键,冲突检测 | ✅ |
|
||||||
| 2 | 终端分屏 | 新增 | 5天 | 水平/垂直分割标签页,可拖拽调整分割比例 | 🚧 |
|
| 2 | 终端分屏 | 新增 | 5天 | 水平/垂直分割标签页,可拖拽调整分割比例 | 🚧 |
|
||||||
| 3 | i18n 国际化 | 新增 | 5天 | zh-CN + en-US,所有 UI 文字抽离到 locale 文件 | 🚧 |
|
| 3 | i18n 国际化 | 新增 | 5天 | zh-CN + en-US,所有 UI 文字抽离到 locale 文件 | 🚧 |
|
||||||
| 4 | 代理设置 | 新增 | 2天 | HTTP/SOCKS5 代理配置,按会话独立设置 | 🚧 |
|
|| 4 | 代理设置 | 新增 | 2天 | HTTP/SOCKS5 代理配置,按会话独立设置 | ✅ |
|
||||||
| 5 | 多窗口支持 | 新增 | 5天 | 右键标签"在新窗口打开",窗口位置记忆 | 🚧 |
|
| 5 | 多窗口支持 | 新增 | 5天 | 右键标签"在新窗口打开",窗口位置记忆 | 🚧 |
|
||||||
|
|
||||||
> **注**: Light 主题、Snippets 命令片段库已在 v1.3.0 提前完成
|
> **注**: Light 主题、Snippets 命令片段库已在 v1.3.0 提前完成
|
||||||
|
|||||||
+15
-13
@@ -6,22 +6,23 @@
|
|||||||
|
|
||||||
## 当前状态
|
## 当前状态
|
||||||
|
|
||||||
|| 指标 | 当前值 | 目标值 |
|
||| 指标 | 当前值 | 目标值 ||
|
||||||
|------|--------|--------|
|
||------|--------|--------|--
|
||||||
| 测试文件数 | 5 | 15+ |
|
|| 测试文件数 | 6 | 15+ ||
|
||||||
| 测试用例数 | 72 | 150+ |
|
|| 测试用例数 | 96 | 150+ ||
|
||||||
| 估算覆盖率 | ~15% | ≥50% |
|
|| 估算覆盖率 | ~25% | ≥50% ||
|
||||||
| 核心模块覆盖 | 60% | ≥80% |
|
|| 核心模块覆盖 | 60% | ≥80% ||
|
||||||
|
|
||||||
### 已有测试
|
### 已有测试
|
||||||
|
|
||||||
|| 测试文件 | 用例数 | 覆盖模块 | 质量 |
|
||| 测试文件 | 用例数 | 覆盖模块 | 质量 ||
|
||||||
|----------|--------|----------|------|
|
||----------|--------|----------|------|
|
||||||
| `test/stores/tabStore.test.ts` | 14 | tabStore | ✅ 完整 |
|
|| `test/stores/tabStore.test.ts` | 14 | tabStore | ✅ 完整 ||
|
||||||
| `test/stores/connectionPool.test.ts` | 8 | poolStore | 🟡 仅接口验证 |
|
|| `test/stores/connectionPool.test.ts` | 8 | poolStore | 🟡 仅接口验证 ||
|
||||||
| `test/components/sftp/utils.test.ts` | 16 | sftp/utils | ✅ 完整 |
|
|| `test/components/sftp/utils.test.ts` | 16 | sftp/utils | ✅ 完整 ||
|
||||||
| `test/services/crypto.test.ts` | 11 | crypto | ✅ 完整 |
|
|| `test/services/crypto.test.ts` | 11 | crypto | ✅ 完整 ||
|
||||||
| `test/stores/keybindingStore.test.ts` | 23 | keybindingStore | ✅ 完整 |
|
|| `test/stores/keybindingStore.test.ts` | 23 | keybindingStore | ✅ 完整 ||
|
||||||
|
|| `test/utils/proxy.test.ts` | 24 | proxy utils | ✅ 完整 ||
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -400,6 +401,7 @@ npm test -- --watch # 监听模式
|
|||||||
| v1.2.0 | ~15% | 0% | 40 | — |
|
| v1.2.0 | ~15% | 0% | 40 | — |
|
||||||
| v1.3.0 | ~20% | 60% | 49 | 2026-04-24 |
|
| v1.3.0 | ~20% | 60% | 49 | 2026-04-24 |
|
||||||
| v1.4.0-batch1 | ~25% | 60% | 72 | 2026-04-24 |
|
| v1.4.0-batch1 | ~25% | 60% | 72 | 2026-04-24 |
|
||||||
|
| v1.4.0-proxy | ~25% | 65% | 96 | 2026-04-25 |
|
||||||
| v1.3.0-batch2 | ~35% | 60% | 104 | — |
|
| v1.3.0-batch2 | ~35% | 60% | 104 | — |
|
||||||
| v1.3.0-batch3 | ~45% | 70% | 143 | — |
|
| v1.3.0-batch3 | ~45% | 70% | 143 | — |
|
||||||
| v1.3.0-final | **≥50%** | **≥80%** | **150+** | — |
|
| v1.3.0-final | **≥50%** | **≥80%** | **150+** | — |
|
||||||
@@ -126,6 +126,13 @@ export class DatabaseService {
|
|||||||
// 列可能已存在,忽略错误
|
// 列可能已存在,忽略错误
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 迁移:添加 proxy 列 (JSON 格式存储代理配置)
|
||||||
|
try {
|
||||||
|
this.db.run(`ALTER TABLE sessions ADD COLUMN proxy TEXT`)
|
||||||
|
} catch (e) {
|
||||||
|
// 列可能已存在,忽略错误
|
||||||
|
}
|
||||||
|
|
||||||
this.db.run(`
|
this.db.run(`
|
||||||
CREATE TABLE IF NOT EXISTS connection_history (
|
CREATE TABLE IF NOT EXISTS connection_history (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
@@ -218,8 +225,8 @@ export class DatabaseService {
|
|||||||
logger.info('Creating session:', session.name, session.host)
|
logger.info('Creating session:', session.name, session.host)
|
||||||
|
|
||||||
const stmt = this.prepare(`
|
const stmt = this.prepare(`
|
||||||
INSERT INTO sessions (id, name, host, port, username, auth_type, password, private_key_path, passphrase_encrypted, group_name, tags)
|
INSERT INTO sessions (id, name, host, port, username, auth_type, password, private_key_path, passphrase_encrypted, group_name, tags, proxy)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
`)
|
`)
|
||||||
|
|
||||||
const id = `session-${Date.now()}`
|
const id = `session-${Date.now()}`
|
||||||
@@ -236,7 +243,8 @@ export class DatabaseService {
|
|||||||
session.privateKey || null,
|
session.privateKey || null,
|
||||||
this.encryptField(session.passphrase),
|
this.encryptField(session.passphrase),
|
||||||
session.group || 'default',
|
session.group || 'default',
|
||||||
session.tags ? JSON.stringify(session.tags) : null
|
session.tags ? JSON.stringify(session.tags) : null,
|
||||||
|
session.proxy ? JSON.stringify(session.proxy) : null
|
||||||
)
|
)
|
||||||
|
|
||||||
this.save()
|
this.save()
|
||||||
@@ -259,6 +267,7 @@ export class DatabaseService {
|
|||||||
password: this.decryptField(s.password as string | null) || undefined,
|
password: this.decryptField(s.password as string | null) || undefined,
|
||||||
privateKey: s.private_key_path as string | undefined,
|
privateKey: s.private_key_path as string | undefined,
|
||||||
passphrase: this.decryptField(s.passphrase_encrypted as string | null) || undefined,
|
passphrase: this.decryptField(s.passphrase_encrypted as string | null) || undefined,
|
||||||
|
proxy: s.proxy ? JSON.parse(s.proxy as string) as Session['proxy'] : undefined,
|
||||||
group: s.group_name as string | undefined,
|
group: s.group_name as string | undefined,
|
||||||
tags: s.tags ? JSON.parse(s.tags as string) as string[] : [],
|
tags: s.tags ? JSON.parse(s.tags as string) as string[] : [],
|
||||||
}))
|
}))
|
||||||
@@ -281,6 +290,7 @@ export class DatabaseService {
|
|||||||
password: this.decryptField(session.password as string | null) || undefined,
|
password: this.decryptField(session.password as string | null) || undefined,
|
||||||
privateKey: session.private_key_path as string | undefined,
|
privateKey: session.private_key_path as string | undefined,
|
||||||
passphrase: this.decryptField(session.passphrase_encrypted as string | null) || undefined,
|
passphrase: this.decryptField(session.passphrase_encrypted as string | null) || undefined,
|
||||||
|
proxy: session.proxy ? JSON.parse(session.proxy as string) as Session['proxy'] : undefined,
|
||||||
group: session.group_name as string | undefined,
|
group: session.group_name as string | undefined,
|
||||||
tags: session.tags ? JSON.parse(session.tags as string) as string[] : [],
|
tags: session.tags ? JSON.parse(session.tags as string) as string[] : [],
|
||||||
}
|
}
|
||||||
@@ -334,6 +344,10 @@ export class DatabaseService {
|
|||||||
fields.push('tags = ?')
|
fields.push('tags = ?')
|
||||||
values.push(JSON.stringify(updates.tags))
|
values.push(JSON.stringify(updates.tags))
|
||||||
}
|
}
|
||||||
|
if (updates.proxy !== undefined) {
|
||||||
|
fields.push('proxy = ?')
|
||||||
|
values.push(updates.proxy ? JSON.stringify(updates.proxy) : null)
|
||||||
|
}
|
||||||
|
|
||||||
fields.push('updated_at = CURRENT_TIMESTAMP')
|
fields.push('updated_at = CURRENT_TIMESTAMP')
|
||||||
values.push(id)
|
values.push(id)
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import * as net from 'net'
|
|||||||
import * as fs from 'fs'
|
import * as fs from 'fs'
|
||||||
import * as os from 'os'
|
import * as os from 'os'
|
||||||
import * as path from 'path'
|
import * as path from 'path'
|
||||||
|
import type { ProxyConfig } from '../../shared/types/session.types'
|
||||||
|
|
||||||
const logger = createLogger('SSHService')
|
const logger = createLogger('SSHService')
|
||||||
|
|
||||||
@@ -39,6 +40,7 @@ export interface SSHConfig {
|
|||||||
agent?: string
|
agent?: string
|
||||||
readyTimeout?: number
|
readyTimeout?: number
|
||||||
keepaliveInterval?: number
|
keepaliveInterval?: number
|
||||||
|
proxy?: ProxyConfig
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Connection {
|
export interface Connection {
|
||||||
@@ -141,8 +143,7 @@ export class SSHService {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
debug('>>> Starting SSH client.connect...')
|
const sshConnectOpts = {
|
||||||
client.connect({
|
|
||||||
host: config.host,
|
host: config.host,
|
||||||
port: config.port || 22,
|
port: config.port || 22,
|
||||||
username: config.username,
|
username: config.username,
|
||||||
@@ -152,6 +153,246 @@ export class SSHService {
|
|||||||
agent: config.agent,
|
agent: config.agent,
|
||||||
readyTimeout: config.readyTimeout || 20000,
|
readyTimeout: config.readyTimeout || 20000,
|
||||||
keepaliveInterval: config.keepaliveInterval || 60000,
|
keepaliveInterval: config.keepaliveInterval || 60000,
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果配置了代理,先通过代理建立隧道
|
||||||
|
if (config.proxy && config.proxy.type !== 'none' && config.proxy.host) {
|
||||||
|
debug('>>> Connecting through proxy:', config.proxy.type, config.proxy.host, config.proxy.port)
|
||||||
|
this.connectThroughProxy(config.proxy, config.host, config.port || 22)
|
||||||
|
.then((socket) => {
|
||||||
|
debug('>>> Proxy tunnel established, starting SSH handshake')
|
||||||
|
client.connect({
|
||||||
|
...sshConnectOpts,
|
||||||
|
sock: socket,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
connection.status = 'error'
|
||||||
|
this.connections.delete(connectionId)
|
||||||
|
logger.error(`Proxy connection failed for ${connectionId}:`, err)
|
||||||
|
reject(new Error(`代理连接失败: ${err.message}`))
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
debug('>>> Starting SSH client.connect (direct)...')
|
||||||
|
client.connect(sshConnectOpts)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 通过代理建立 TCP 隧道
|
||||||
|
* 支持 HTTP CONNECT 和 SOCKS5
|
||||||
|
*/
|
||||||
|
private connectThroughProxy(
|
||||||
|
proxy: ProxyConfig,
|
||||||
|
targetHost: string,
|
||||||
|
targetPort: number
|
||||||
|
): Promise<net.Socket> {
|
||||||
|
if (proxy.type === 'http') {
|
||||||
|
return this.connectThroughHttpProxy(proxy, targetHost, targetPort)
|
||||||
|
} else if (proxy.type === 'socks5') {
|
||||||
|
return this.connectThroughSocks5Proxy(proxy, targetHost, targetPort)
|
||||||
|
}
|
||||||
|
return Promise.reject(new Error(`不支持的代理类型: ${proxy.type}`))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HTTP CONNECT 代理隧道
|
||||||
|
*/
|
||||||
|
private connectThroughHttpProxy(
|
||||||
|
proxy: ProxyConfig,
|
||||||
|
targetHost: string,
|
||||||
|
targetPort: number
|
||||||
|
): Promise<net.Socket> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const socket = new net.Socket()
|
||||||
|
const connectTimeout = setTimeout(() => {
|
||||||
|
socket.destroy()
|
||||||
|
reject(new Error('代理连接超时'))
|
||||||
|
}, 15000)
|
||||||
|
|
||||||
|
socket.connect(proxy.port, proxy.host, () => {
|
||||||
|
clearTimeout(connectTimeout)
|
||||||
|
debug('>>> Connected to HTTP proxy:', proxy.host, proxy.port)
|
||||||
|
|
||||||
|
// 构建 CONNECT 请求
|
||||||
|
let connectRequest = `CONNECT ${targetHost}:${targetPort} HTTP/1.1\r\nHost: ${targetHost}:${targetPort}\r\n`
|
||||||
|
|
||||||
|
// 代理认证
|
||||||
|
if (proxy.username) {
|
||||||
|
const credentials = Buffer.from(`${proxy.username}:${proxy.password || ''}`).toString('base64')
|
||||||
|
connectRequest += `Proxy-Authorization: Basic ${credentials}\r\n`
|
||||||
|
}
|
||||||
|
connectRequest += '\r\n'
|
||||||
|
|
||||||
|
socket.write(connectRequest)
|
||||||
|
|
||||||
|
let responseBuffer = ''
|
||||||
|
const onData = (data: Buffer) => {
|
||||||
|
responseBuffer += data.toString()
|
||||||
|
|
||||||
|
// 检查是否收到完整 HTTP 响应头
|
||||||
|
const headerEnd = responseBuffer.indexOf('\r\n\r\n')
|
||||||
|
if (headerEnd === -1) return
|
||||||
|
|
||||||
|
socket.removeListener('data', onData)
|
||||||
|
const header = responseBuffer.substring(0, headerEnd)
|
||||||
|
|
||||||
|
// 解析状态码
|
||||||
|
const statusMatch = header.match(/^HTTP\/\d\.\d\s+(\d+)\s+(.*)/)
|
||||||
|
if (!statusMatch) {
|
||||||
|
socket.destroy()
|
||||||
|
reject(new Error('代理返回无效响应'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusCode = parseInt(statusMatch[1], 10)
|
||||||
|
if (statusCode >= 200 && statusCode < 300) {
|
||||||
|
debug('>>> HTTP proxy tunnel established')
|
||||||
|
resolve(socket)
|
||||||
|
} else {
|
||||||
|
socket.destroy()
|
||||||
|
reject(new Error(`代理拒绝连接: ${statusCode} ${statusMatch[2]}`))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
socket.on('data', onData)
|
||||||
|
socket.on('error', (err) => {
|
||||||
|
clearTimeout(connectTimeout)
|
||||||
|
reject(new Error(`代理连接错误: ${err.message}`))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
socket.on('error', (err) => {
|
||||||
|
clearTimeout(connectTimeout)
|
||||||
|
reject(new Error(`无法连接代理服务器: ${err.message}`))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SOCKS5 代理隧道
|
||||||
|
*/
|
||||||
|
private connectThroughSocks5Proxy(
|
||||||
|
proxy: ProxyConfig,
|
||||||
|
targetHost: string,
|
||||||
|
targetPort: number
|
||||||
|
): Promise<net.Socket> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const socket = new net.Socket()
|
||||||
|
const connectTimeout = setTimeout(() => {
|
||||||
|
socket.destroy()
|
||||||
|
reject(new Error('SOCKS5 代理连接超时'))
|
||||||
|
}, 15000)
|
||||||
|
|
||||||
|
socket.connect(proxy.port, proxy.host, () => {
|
||||||
|
clearTimeout(connectTimeout)
|
||||||
|
debug('>>> Connected to SOCKS5 proxy:', proxy.host, proxy.port)
|
||||||
|
|
||||||
|
// SOCKS5 握手阶段 1: 认证方法协商
|
||||||
|
let authMethods: Buffer
|
||||||
|
if (proxy.username) {
|
||||||
|
// 支持无认证(0x00)和用户名密码认证(0x02)
|
||||||
|
authMethods = Buffer.from([0x05, 0x02, 0x00, 0x02])
|
||||||
|
} else {
|
||||||
|
// 仅支持无认证
|
||||||
|
authMethods = Buffer.from([0x05, 0x01, 0x00])
|
||||||
|
}
|
||||||
|
socket.write(authMethods)
|
||||||
|
|
||||||
|
const handleHandshake = (data: Buffer) => {
|
||||||
|
if (data.length < 2) {
|
||||||
|
socket.destroy()
|
||||||
|
reject(new Error('SOCKS5 握手响应无效'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const version = data[0]
|
||||||
|
const method = data[1]
|
||||||
|
|
||||||
|
if (version !== 0x05) {
|
||||||
|
socket.destroy()
|
||||||
|
reject(new Error('不是 SOCKS5 代理'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (method === 0x02 && proxy.username) {
|
||||||
|
// 用户名密码认证
|
||||||
|
socket.removeListener('data', handleHandshake)
|
||||||
|
const userBuf = Buffer.from(proxy.username)
|
||||||
|
const passBuf = Buffer.from(proxy.password || '')
|
||||||
|
const authBuf = Buffer.alloc(3 + userBuf.length + passBuf.length)
|
||||||
|
authBuf[0] = 0x01 // 认证协商版本
|
||||||
|
authBuf[1] = userBuf.length
|
||||||
|
userBuf.copy(authBuf, 2)
|
||||||
|
authBuf[2 + userBuf.length] = passBuf.length
|
||||||
|
passBuf.copy(authBuf, 3 + userBuf.length)
|
||||||
|
socket.write(authBuf)
|
||||||
|
|
||||||
|
socket.once('data', (authResp: Buffer) => {
|
||||||
|
if (authResp.length < 2 || authResp[1] !== 0x00) {
|
||||||
|
socket.destroy()
|
||||||
|
reject(new Error('SOCKS5 认证失败'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sendConnectRequest()
|
||||||
|
})
|
||||||
|
} else if (method === 0x00) {
|
||||||
|
// 无需认证
|
||||||
|
socket.removeListener('data', handleHandshake)
|
||||||
|
sendConnectRequest()
|
||||||
|
} else {
|
||||||
|
socket.destroy()
|
||||||
|
reject(new Error('SOCKS5 不支持此认证方法'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const sendConnectRequest = () => {
|
||||||
|
// SOCKS5 CONNECT 请求
|
||||||
|
const hostBuf = Buffer.from(targetHost)
|
||||||
|
// VER(1) + CMD(1) + RSV(1) + ATYP(1) + DOMAIN_LEN(1) + DOMAIN + PORT(2)
|
||||||
|
const connectBuf = Buffer.alloc(4 + 1 + hostBuf.length + 2)
|
||||||
|
connectBuf[0] = 0x05 // SOCKS5
|
||||||
|
connectBuf[1] = 0x01 // CONNECT
|
||||||
|
connectBuf[2] = 0x00 // 保留
|
||||||
|
connectBuf[3] = 0x03 // 域名类型
|
||||||
|
connectBuf[4] = hostBuf.length
|
||||||
|
hostBuf.copy(connectBuf, 5)
|
||||||
|
connectBuf.writeUInt16BE(targetPort, 5 + hostBuf.length)
|
||||||
|
socket.write(connectBuf)
|
||||||
|
|
||||||
|
socket.once('data', (connectResp: Buffer) => {
|
||||||
|
if (connectResp.length < 4 || connectResp[1] !== 0x00) {
|
||||||
|
const errorCode = connectResp.length >= 2 ? connectResp[1] : -1
|
||||||
|
const errorMessages: Record<number, string> = {
|
||||||
|
0x01: 'SOCKS5 服务器错误',
|
||||||
|
0x02: 'SOCKS5 连接不允许',
|
||||||
|
0x03: 'SOCKS5 网络不可达',
|
||||||
|
0x04: 'SOCKS5 主机不可达',
|
||||||
|
0x05: 'SOCKS5 连接拒绝',
|
||||||
|
0x06: 'SOCKS5 TTL 过期',
|
||||||
|
0x07: 'SOCKS5 不支持的命令',
|
||||||
|
0x08: 'SOCKS5 地址类型不支持',
|
||||||
|
}
|
||||||
|
socket.destroy()
|
||||||
|
reject(new Error(errorMessages[errorCode] || `SOCKS5 连接失败 (${errorCode})`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
debug('>>> SOCKS5 proxy tunnel established')
|
||||||
|
resolve(socket)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
socket.on('data', handleHandshake)
|
||||||
|
socket.on('error', (err) => {
|
||||||
|
reject(new Error(`SOCKS5 代理错误: ${err.message}`))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
socket.on('error', (err) => {
|
||||||
|
clearTimeout(connectTimeout)
|
||||||
|
reject(new Error(`无法连接 SOCKS5 代理: ${err.message}`))
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
* 定义所有 IPC 请求/响应的类型
|
* 定义所有 IPC 请求/响应的类型
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { Session } from './session.types'
|
import type { Session, ProxyConfig } from './session.types'
|
||||||
|
|
||||||
// ============== SSH IPC ==============
|
// ============== SSH IPC ==============
|
||||||
|
|
||||||
@@ -14,6 +14,7 @@ export interface SSHConnectRequest {
|
|||||||
password?: string
|
password?: string
|
||||||
privateKey?: string
|
privateKey?: string
|
||||||
passphrase?: string
|
passphrase?: string
|
||||||
|
proxy?: ProxyConfig
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SSHConnectResponse {
|
export interface SSHConnectResponse {
|
||||||
|
|||||||
@@ -6,6 +6,18 @@
|
|||||||
// 认证方式
|
// 认证方式
|
||||||
export type AuthType = 'password' | 'key' | 'agent'
|
export type AuthType = 'password' | 'key' | 'agent'
|
||||||
|
|
||||||
|
// 代理类型
|
||||||
|
export type ProxyType = 'none' | 'http' | 'socks5'
|
||||||
|
|
||||||
|
// 代理配置
|
||||||
|
export interface ProxyConfig {
|
||||||
|
type: ProxyType
|
||||||
|
host: string
|
||||||
|
port: number
|
||||||
|
username?: string
|
||||||
|
password?: string
|
||||||
|
}
|
||||||
|
|
||||||
// 会话配置 (用于快速连接)
|
// 会话配置 (用于快速连接)
|
||||||
export interface SSHConnectionConfig {
|
export interface SSHConnectionConfig {
|
||||||
host: string
|
host: string
|
||||||
@@ -15,6 +27,7 @@ export interface SSHConnectionConfig {
|
|||||||
privateKey?: string
|
privateKey?: string
|
||||||
passphrase?: string
|
passphrase?: string
|
||||||
authType?: AuthType
|
authType?: AuthType
|
||||||
|
proxy?: ProxyConfig
|
||||||
}
|
}
|
||||||
|
|
||||||
// 持久化会话
|
// 持久化会话
|
||||||
@@ -28,6 +41,7 @@ export interface Session {
|
|||||||
password?: string // 加密存储
|
password?: string // 加密存储
|
||||||
privateKey?: string // 私钥路径或内容
|
privateKey?: string // 私钥路径或内容
|
||||||
passphrase?: string // 私钥密码
|
passphrase?: string // 私钥密码
|
||||||
|
proxy?: ProxyConfig // 代理配置
|
||||||
group?: string
|
group?: string
|
||||||
tags?: string[]
|
tags?: string[]
|
||||||
createdAt?: number
|
createdAt?: number
|
||||||
@@ -44,6 +58,7 @@ export interface SessionCreateRequest {
|
|||||||
password?: string
|
password?: string
|
||||||
privateKey?: string
|
privateKey?: string
|
||||||
passphrase?: string
|
passphrase?: string
|
||||||
|
proxy?: ProxyConfig
|
||||||
group?: string
|
group?: string
|
||||||
tags?: string[]
|
tags?: string[]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { X, Server, Key, Lock, User, Globe, Tag, Folder } from 'lucide-react'
|
import { X, Server, Key, Lock, User, Globe, Tag, Folder, Shield } from 'lucide-react'
|
||||||
import { useSessionStore, Session } from '../stores'
|
import { useSessionStore, Session } from '../stores'
|
||||||
import type { AuthType } from '../../shared/types/session.types'
|
import type { AuthType, ProxyType } from '../../shared/types/session.types'
|
||||||
import { createLogger } from '../utils/logger'
|
import { createLogger } from '../utils/logger'
|
||||||
|
|
||||||
const logger = createLogger('SessionDialog')
|
const logger = createLogger('SessionDialog')
|
||||||
@@ -23,9 +23,17 @@ export default function SessionDialog({ isOpen, onClose, editSession }: SessionD
|
|||||||
password: '',
|
password: '',
|
||||||
privateKey: '~/.ssh/id_rsa',
|
privateKey: '~/.ssh/id_rsa',
|
||||||
passphrase: '',
|
passphrase: '',
|
||||||
|
proxy: {
|
||||||
|
type: 'none' as ProxyType,
|
||||||
|
host: '',
|
||||||
|
port: 0,
|
||||||
|
username: '',
|
||||||
|
password: '',
|
||||||
|
},
|
||||||
group: 'default',
|
group: 'default',
|
||||||
tags: '',
|
tags: '',
|
||||||
})
|
})
|
||||||
|
const [showProxySettings, setShowProxySettings] = useState(false)
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||||
|
|
||||||
// 当编辑会话时,填充表单数据
|
// 当编辑会话时,填充表单数据
|
||||||
@@ -40,9 +48,15 @@ export default function SessionDialog({ isOpen, onClose, editSession }: SessionD
|
|||||||
password: editSession.password || '',
|
password: editSession.password || '',
|
||||||
privateKey: editSession.privateKey || '',
|
privateKey: editSession.privateKey || '',
|
||||||
passphrase: editSession.passphrase || '',
|
passphrase: editSession.passphrase || '',
|
||||||
|
proxy: editSession.proxy
|
||||||
|
? { type: editSession.proxy.type as ProxyType, host: editSession.proxy.host, port: editSession.proxy.port, username: editSession.proxy.username || '', password: editSession.proxy.password || '' }
|
||||||
|
: { type: 'none' as ProxyType, host: '', port: 0, username: '', password: '' },
|
||||||
group: editSession.group || 'default',
|
group: editSession.group || 'default',
|
||||||
tags: editSession.tags?.join(', ') || '',
|
tags: editSession.tags?.join(', ') || '',
|
||||||
})
|
})
|
||||||
|
if (editSession.proxy && editSession.proxy.type !== 'none') {
|
||||||
|
setShowProxySettings(true)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// 重置表单
|
// 重置表单
|
||||||
setFormData({
|
setFormData({
|
||||||
@@ -54,9 +68,11 @@ export default function SessionDialog({ isOpen, onClose, editSession }: SessionD
|
|||||||
password: '',
|
password: '',
|
||||||
privateKey: '~/.ssh/id_rsa',
|
privateKey: '~/.ssh/id_rsa',
|
||||||
passphrase: '',
|
passphrase: '',
|
||||||
|
proxy: { type: 'none', host: '', port: 0, username: '', password: '' },
|
||||||
group: 'default',
|
group: 'default',
|
||||||
tags: '',
|
tags: '',
|
||||||
})
|
})
|
||||||
|
setShowProxySettings(false)
|
||||||
}
|
}
|
||||||
}, [editSession])
|
}, [editSession])
|
||||||
|
|
||||||
@@ -69,6 +85,16 @@ export default function SessionDialog({ isOpen, onClose, editSession }: SessionD
|
|||||||
setIsSubmitting(true)
|
setIsSubmitting(true)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const proxyConfig = formData.proxy.type !== 'none' && formData.proxy.host
|
||||||
|
? {
|
||||||
|
type: formData.proxy.type,
|
||||||
|
host: formData.proxy.host,
|
||||||
|
port: formData.proxy.port || (formData.proxy.type === 'http' ? 8080 : 1080),
|
||||||
|
username: formData.proxy.username || undefined,
|
||||||
|
password: formData.proxy.password || undefined,
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
|
||||||
const sessionData = {
|
const sessionData = {
|
||||||
name: formData.name,
|
name: formData.name,
|
||||||
host: formData.host,
|
host: formData.host,
|
||||||
@@ -78,6 +104,7 @@ export default function SessionDialog({ isOpen, onClose, editSession }: SessionD
|
|||||||
password: formData.password,
|
password: formData.password,
|
||||||
privateKey: formData.authType === 'key' ? (formData.privateKey.trim() || '~/.ssh/id_rsa') : formData.privateKey,
|
privateKey: formData.authType === 'key' ? (formData.privateKey.trim() || '~/.ssh/id_rsa') : formData.privateKey,
|
||||||
passphrase: formData.passphrase,
|
passphrase: formData.passphrase,
|
||||||
|
proxy: proxyConfig,
|
||||||
group: formData.group,
|
group: formData.group,
|
||||||
tags: formData.tags.split(',').map(t => t.trim()).filter(Boolean),
|
tags: formData.tags.split(',').map(t => t.trim()).filter(Boolean),
|
||||||
}
|
}
|
||||||
@@ -102,9 +129,11 @@ export default function SessionDialog({ isOpen, onClose, editSession }: SessionD
|
|||||||
password: '',
|
password: '',
|
||||||
privateKey: '',
|
privateKey: '',
|
||||||
passphrase: '',
|
passphrase: '',
|
||||||
|
proxy: { type: 'none', host: '', port: 0, username: '', password: '' },
|
||||||
group: 'default',
|
group: 'default',
|
||||||
tags: '',
|
tags: '',
|
||||||
})
|
})
|
||||||
|
setShowProxySettings(false)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(isEditing ? 'Failed to update session:' : 'Failed to create session:', error)
|
logger.error(isEditing ? 'Failed to update session:' : 'Failed to create session:', error)
|
||||||
} finally {
|
} finally {
|
||||||
@@ -301,6 +330,124 @@ export default function SessionDialog({ isOpen, onClose, editSession }: SessionD
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Proxy Settings */}
|
||||||
|
<div className="border-t border-dark-700 pt-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowProxySettings(!showProxySettings)}
|
||||||
|
className="flex items-center gap-2 text-sm font-medium text-gray-400 hover:text-gray-200 transition-colors"
|
||||||
|
>
|
||||||
|
<Shield className="w-4 h-4" />
|
||||||
|
代理设置
|
||||||
|
<span className="text-xs text-gray-600">
|
||||||
|
{formData.proxy.type !== 'none' && formData.proxy.host ? `(已配置 ${formData.proxy.type.toUpperCase()})` : '(未配置)'}
|
||||||
|
</span>
|
||||||
|
<svg
|
||||||
|
className={`w-3 h-3 transition-transform ${showProxySettings ? 'rotate-180' : ''}`}
|
||||||
|
fill="none" viewBox="0 0 24 24" stroke="currentColor"
|
||||||
|
>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{showProxySettings && (
|
||||||
|
<div className="mt-3 space-y-3 animate-fade-in">
|
||||||
|
{/* Proxy Type */}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{[
|
||||||
|
{ value: 'none', label: '不使用' },
|
||||||
|
{ value: 'http', label: 'HTTP' },
|
||||||
|
{ value: 'socks5', label: 'SOCKS5' },
|
||||||
|
].map((type) => (
|
||||||
|
<button
|
||||||
|
key={type.value}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setFormData({
|
||||||
|
...formData,
|
||||||
|
proxy: {
|
||||||
|
...formData.proxy,
|
||||||
|
type: type.value as ProxyType,
|
||||||
|
port: formData.proxy.port || (type.value === 'http' ? 8080 : 1080),
|
||||||
|
}
|
||||||
|
})}
|
||||||
|
className={`flex-1 px-3 py-1.5 rounded-lg border text-xs transition-colors ${
|
||||||
|
formData.proxy.type === type.value
|
||||||
|
? 'border-accent-blue bg-accent-blue/20 text-accent-blue'
|
||||||
|
: 'border-dark-700 hover:border-dark-600 text-gray-500'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{type.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{formData.proxy.type !== 'none' && (
|
||||||
|
<>
|
||||||
|
{/* Proxy Host & Port */}
|
||||||
|
<div className="grid grid-cols-3 gap-3">
|
||||||
|
<div className="col-span-2">
|
||||||
|
<label className="block text-xs text-gray-500 mb-1">代理地址</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={formData.proxy.host}
|
||||||
|
onChange={(e) => setFormData({
|
||||||
|
...formData,
|
||||||
|
proxy: { ...formData.proxy, host: e.target.value }
|
||||||
|
})}
|
||||||
|
placeholder="proxy.example.com"
|
||||||
|
className="w-full px-3 py-1.5 bg-dark-900 border border-dark-700 rounded-lg text-gray-200 text-sm placeholder-gray-600 focus:outline-none focus:border-accent-blue"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-gray-500 mb-1">端口</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={formData.proxy.port || ''}
|
||||||
|
onChange={(e) => setFormData({
|
||||||
|
...formData,
|
||||||
|
proxy: { ...formData.proxy, port: parseInt(e.target.value) || 0 }
|
||||||
|
})}
|
||||||
|
placeholder={formData.proxy.type === 'http' ? '8080' : '1080'}
|
||||||
|
className="w-full px-3 py-1.5 bg-dark-900 border border-dark-700 rounded-lg text-gray-200 text-sm placeholder-gray-600 focus:outline-none focus:border-accent-blue"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Proxy Auth */}
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-gray-500 mb-1">代理用户名 (可选)</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={formData.proxy.username}
|
||||||
|
onChange={(e) => setFormData({
|
||||||
|
...formData,
|
||||||
|
proxy: { ...formData.proxy, username: e.target.value }
|
||||||
|
})}
|
||||||
|
placeholder="username"
|
||||||
|
className="w-full px-3 py-1.5 bg-dark-900 border border-dark-700 rounded-lg text-gray-200 text-sm placeholder-gray-600 focus:outline-none focus:border-accent-blue"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-gray-500 mb-1">代理密码 (可选)</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={formData.proxy.password}
|
||||||
|
onChange={(e) => setFormData({
|
||||||
|
...formData,
|
||||||
|
proxy: { ...formData.proxy, password: e.target.value }
|
||||||
|
})}
|
||||||
|
placeholder="password"
|
||||||
|
className="w-full px-3 py-1.5 bg-dark-900 border border-dark-700 rounded-lg text-gray-200 text-sm placeholder-gray-600 focus:outline-none focus:border-accent-blue"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Group and tags */}
|
{/* Group and tags */}
|
||||||
<div className="border-t border-dark-700 pt-4 grid grid-cols-2 gap-3">
|
<div className="border-t border-dark-700 pt-4 grid grid-cols-2 gap-3">
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -142,6 +142,7 @@ export const usePoolStore = create<PoolState>((set, get) => ({
|
|||||||
password: config.password,
|
password: config.password,
|
||||||
privateKey: config.privateKey,
|
privateKey: config.privateKey,
|
||||||
passphrase: config.passphrase,
|
passphrase: config.passphrase,
|
||||||
|
proxy: config.proxy,
|
||||||
})
|
})
|
||||||
console.log('>>> sshConnect result:', result)
|
console.log('>>> sshConnect result:', result)
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ export async function getSessionConfig(sessionId: string): Promise<ConnectionCon
|
|||||||
privateKey: session.privateKey,
|
privateKey: session.privateKey,
|
||||||
passphrase: session.passphrase,
|
passphrase: session.passphrase,
|
||||||
authType: session.authType,
|
authType: session.authType,
|
||||||
|
proxy: session.proxy,
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Failed to get session config:', error)
|
logger.error('Failed to get session config:', error)
|
||||||
|
|||||||
@@ -17,6 +17,13 @@ export interface Session {
|
|||||||
password?: string
|
password?: string
|
||||||
privateKey?: string
|
privateKey?: string
|
||||||
passphrase?: string
|
passphrase?: string
|
||||||
|
proxy?: {
|
||||||
|
type: 'none' | 'http' | 'socks5'
|
||||||
|
host: string
|
||||||
|
port: number
|
||||||
|
username?: string
|
||||||
|
password?: string
|
||||||
|
}
|
||||||
group?: string
|
group?: string
|
||||||
tags?: string[]
|
tags?: string[]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,5 +4,12 @@
|
|||||||
|
|
||||||
export { createLogger, logger, LogLevel } from './logger'
|
export { createLogger, logger, LogLevel } from './logger'
|
||||||
export { handleError, createError, ErrorCode, withErrorHandling } from './error-handler'
|
export { handleError, createError, ErrorCode, withErrorHandling } from './error-handler'
|
||||||
|
export {
|
||||||
|
getDefaultProxyConfig,
|
||||||
|
isProxyConfigured,
|
||||||
|
validateProxyConfig,
|
||||||
|
parseProxyUrl,
|
||||||
|
buildProxyUrl,
|
||||||
|
} from './proxy'
|
||||||
// export { validators } from './validators'
|
// export { validators } from './validators'
|
||||||
// export { formatters } from './formatters'
|
// export { formatters } from './formatters'
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
/**
|
||||||
|
* Proxy Utils - 代理配置验证和工具函数
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { ProxyConfig, ProxyType } from '../../shared/types/session.types'
|
||||||
|
|
||||||
|
/** 获取默认代理配置 */
|
||||||
|
export function getDefaultProxyConfig(): ProxyConfig {
|
||||||
|
return {
|
||||||
|
type: 'none',
|
||||||
|
host: '',
|
||||||
|
port: 0,
|
||||||
|
username: '',
|
||||||
|
password: '',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 检查代理是否已配置且有效 */
|
||||||
|
export function isProxyConfigured(config?: ProxyConfig): boolean {
|
||||||
|
if (!config) return false
|
||||||
|
return config.type !== 'none' && config.host.length > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 验证代理配置 */
|
||||||
|
export function validateProxyConfig(config: ProxyConfig): { valid: boolean; error?: string } {
|
||||||
|
if (config.type === 'none') {
|
||||||
|
return { valid: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!config.host || config.host.trim().length === 0) {
|
||||||
|
return { valid: false, error: '代理 host 不能为空' }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 简单 host 验证:不允许空格
|
||||||
|
if (/\s/.test(config.host)) {
|
||||||
|
return { valid: false, error: '代理 host 不能包含空格' }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!config.port || config.port <= 0) {
|
||||||
|
return { valid: false, error: '代理 port 必须大于 0' }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.port > 65535) {
|
||||||
|
return { valid: false, error: '代理 port 不能超过 65535' }
|
||||||
|
}
|
||||||
|
|
||||||
|
return { valid: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 从 URL 解析代理配置 */
|
||||||
|
export function parseProxyUrl(url: string): ProxyConfig | null {
|
||||||
|
if (!url || url.trim().length === 0) return null
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = new URL(url)
|
||||||
|
let type: ProxyType | null = null
|
||||||
|
|
||||||
|
if (parsed.protocol === 'http:' || parsed.protocol === 'https:') {
|
||||||
|
type = 'http'
|
||||||
|
} else if (parsed.protocol === 'socks5:') {
|
||||||
|
type = 'socks5'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!type) return null
|
||||||
|
|
||||||
|
const defaultPort = type === 'http' ? 80 : 1080
|
||||||
|
|
||||||
|
return {
|
||||||
|
type,
|
||||||
|
host: parsed.hostname,
|
||||||
|
port: parsed.port ? parseInt(parsed.port, 10) : defaultPort,
|
||||||
|
username: parsed.username || '',
|
||||||
|
password: parsed.password || '',
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 构建代理 URL */
|
||||||
|
export function buildProxyUrl(config: ProxyConfig): string {
|
||||||
|
if (config.type === 'none') return ''
|
||||||
|
|
||||||
|
let auth = ''
|
||||||
|
if (config.username) {
|
||||||
|
auth = config.username
|
||||||
|
if (config.password) {
|
||||||
|
auth += `:${config.password}`
|
||||||
|
}
|
||||||
|
auth += '@'
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${config.type}://${auth}${config.host}:${config.port}`
|
||||||
|
}
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
/**
|
||||||
|
* Proxy Config Utils - Tests
|
||||||
|
* 代理配置验证和工具函数测试
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import {
|
||||||
|
validateProxyConfig,
|
||||||
|
parseProxyUrl,
|
||||||
|
buildProxyUrl,
|
||||||
|
isProxyConfigured,
|
||||||
|
getDefaultProxyConfig,
|
||||||
|
} from '../../src/utils/proxy'
|
||||||
|
import type { ProxyConfig } from '../../shared/types/session.types'
|
||||||
|
|
||||||
|
describe('Proxy Utils', () => {
|
||||||
|
describe('getDefaultProxyConfig', () => {
|
||||||
|
it('应返回默认代理配置', () => {
|
||||||
|
const config = getDefaultProxyConfig()
|
||||||
|
expect(config).toEqual({
|
||||||
|
type: 'none',
|
||||||
|
host: '',
|
||||||
|
port: 0,
|
||||||
|
username: '',
|
||||||
|
password: '',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('isProxyConfigured', () => {
|
||||||
|
it('type=none 应返回 false', () => {
|
||||||
|
expect(isProxyConfigured({ type: 'none', host: '', port: 0 })).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('type=http 有 host 应返回 true', () => {
|
||||||
|
expect(isProxyConfigured({ type: 'http', host: 'proxy.example.com', port: 8080 })).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('type=socks5 有 host 应返回 true', () => {
|
||||||
|
expect(isProxyConfigured({ type: 'socks5', host: '127.0.0.1', port: 1080 })).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('type=http 但 host 为空应返回 false', () => {
|
||||||
|
expect(isProxyConfigured({ type: 'http', host: '', port: 8080 })).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('validateProxyConfig', () => {
|
||||||
|
it('type=none 应始终有效', () => {
|
||||||
|
const result = validateProxyConfig({ type: 'none', host: '', port: 0 })
|
||||||
|
expect(result.valid).toBe(true)
|
||||||
|
expect(result.error).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('有效的 HTTP 代理配置应通过验证', () => {
|
||||||
|
const config: ProxyConfig = { type: 'http', host: 'proxy.example.com', port: 8080 }
|
||||||
|
const result = validateProxyConfig(config)
|
||||||
|
expect(result.valid).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('有效的 SOCKS5 代理配置应通过验证', () => {
|
||||||
|
const config: ProxyConfig = { type: 'socks5', host: '127.0.0.1', port: 1080 }
|
||||||
|
const result = validateProxyConfig(config)
|
||||||
|
expect(result.valid).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('host 为空应验证失败', () => {
|
||||||
|
const config: ProxyConfig = { type: 'http', host: '', port: 8080 }
|
||||||
|
const result = validateProxyConfig(config)
|
||||||
|
expect(result.valid).toBe(false)
|
||||||
|
expect(result.error).toContain('host')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('port 为 0 应验证失败', () => {
|
||||||
|
const config: ProxyConfig = { type: 'http', host: 'proxy.example.com', port: 0 }
|
||||||
|
const result = validateProxyConfig(config)
|
||||||
|
expect(result.valid).toBe(false)
|
||||||
|
expect(result.error).toContain('port')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('port 为负数应验证失败', () => {
|
||||||
|
const config: ProxyConfig = { type: 'socks5', host: '127.0.0.1', port: -1 }
|
||||||
|
const result = validateProxyConfig(config)
|
||||||
|
expect(result.valid).toBe(false)
|
||||||
|
expect(result.error).toContain('port')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('port 超过 65535 应验证失败', () => {
|
||||||
|
const config: ProxyConfig = { type: 'http', host: 'proxy.example.com', port: 70000 }
|
||||||
|
const result = validateProxyConfig(config)
|
||||||
|
expect(result.valid).toBe(false)
|
||||||
|
expect(result.error).toContain('port')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('host 含有非法字符应验证失败', () => {
|
||||||
|
const config: ProxyConfig = { type: 'http', host: 'proxy example.com', port: 8080 }
|
||||||
|
const result = validateProxyConfig(config)
|
||||||
|
expect(result.valid).toBe(false)
|
||||||
|
expect(result.error).toContain('host')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('带用户名密码的有效代理应通过验证', () => {
|
||||||
|
const config: ProxyConfig = {
|
||||||
|
type: 'http',
|
||||||
|
host: 'proxy.example.com',
|
||||||
|
port: 8080,
|
||||||
|
username: 'user',
|
||||||
|
password: 'pass',
|
||||||
|
}
|
||||||
|
const result = validateProxyConfig(config)
|
||||||
|
expect(result.valid).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('parseProxyUrl', () => {
|
||||||
|
it('应解析 HTTP 代理 URL', () => {
|
||||||
|
const result = parseProxyUrl('http://proxy.example.com:8080')
|
||||||
|
expect(result).toEqual({
|
||||||
|
type: 'http',
|
||||||
|
host: 'proxy.example.com',
|
||||||
|
port: 8080,
|
||||||
|
username: '',
|
||||||
|
password: '',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('应解析 SOCKS5 代理 URL', () => {
|
||||||
|
const result = parseProxyUrl('socks5://127.0.0.1:1080')
|
||||||
|
expect(result).toEqual({
|
||||||
|
type: 'socks5',
|
||||||
|
host: '127.0.0.1',
|
||||||
|
port: 1080,
|
||||||
|
username: '',
|
||||||
|
password: '',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('应解析带认证的代理 URL', () => {
|
||||||
|
const result = parseProxyUrl('http://user:pass@proxy.example.com:3128')
|
||||||
|
expect(result).toEqual({
|
||||||
|
type: 'http',
|
||||||
|
host: 'proxy.example.com',
|
||||||
|
port: 3128,
|
||||||
|
username: 'user',
|
||||||
|
password: 'pass',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('应解析无端口的 URL (使用默认端口)', () => {
|
||||||
|
const result = parseProxyUrl('http://proxy.example.com')
|
||||||
|
expect(result).toEqual({
|
||||||
|
type: 'http',
|
||||||
|
host: 'proxy.example.com',
|
||||||
|
port: 80,
|
||||||
|
username: '',
|
||||||
|
password: '',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('SOCKS5 无端口应使用 1080', () => {
|
||||||
|
const result = parseProxyUrl('socks5://proxy.example.com')
|
||||||
|
expect(result).toEqual({
|
||||||
|
type: 'socks5',
|
||||||
|
host: 'proxy.example.com',
|
||||||
|
port: 1080,
|
||||||
|
username: '',
|
||||||
|
password: '',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('无效 URL 应返回 null', () => {
|
||||||
|
expect(parseProxyUrl('')).toBeNull()
|
||||||
|
expect(parseProxyUrl('not-a-url')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('不支持的协议应返回 null', () => {
|
||||||
|
expect(parseProxyUrl('ftp://proxy.example.com:21')).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('buildProxyUrl', () => {
|
||||||
|
it('应构建 HTTP 代理 URL', () => {
|
||||||
|
const config: ProxyConfig = { type: 'http', host: 'proxy.example.com', port: 8080 }
|
||||||
|
expect(buildProxyUrl(config)).toBe('http://proxy.example.com:8080')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('应构建带认证的代理 URL', () => {
|
||||||
|
const config: ProxyConfig = {
|
||||||
|
type: 'socks5',
|
||||||
|
host: '127.0.0.1',
|
||||||
|
port: 1080,
|
||||||
|
username: 'user',
|
||||||
|
password: 'pass',
|
||||||
|
}
|
||||||
|
expect(buildProxyUrl(config)).toBe('socks5://user:pass@127.0.0.1:1080')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('type=none 应返回空字符串', () => {
|
||||||
|
const config: ProxyConfig = { type: 'none', host: '', port: 0 }
|
||||||
|
expect(buildProxyUrl(config)).toBe('')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in new issue
Block a user