From 07c4a5b14fd211ed6cc0fc1d67acb00dbdec128f Mon Sep 17 00:00:00 2001 From: OpsXShell Dev Date: Sat, 25 Apr 2026 05:14:27 +0800 Subject: [PATCH] =?UTF-8?q?feat(proxy):=20HTTP/SOCKS5=20=E4=BB=A3=E7=90=86?= =?UTF-8?q?=E6=94=AF=E6=8C=81=EF=BC=8C=E6=8C=89=E4=BC=9A=E8=AF=9D=E7=8B=AC?= =?UTF-8?q?=E7=AB=8B=E9=85=8D=E7=BD=AE=20|=2015=20files=20+483/-23=20|=209?= =?UTF-8?q?6/96=20tests=20passing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 功能: - 代理类型定义 (ProxyType, ProxyConfig) - 代理工具函数 (验证、解析、构建URL) + 24 测试 - SSH 连接通过 HTTP CONNECT / SOCKS5 隧道 - 数据库 proxy 列持久化 (JSON) - SessionDialog 代理配置 UI (可折叠面板) - 连接池传递 proxy 到 IPC - sessionStore 类型添加 proxy 支持 --- PROJECT_EVALUATION.md | 2 +- README.md | 1 + ROADMAP.md | 2 +- TDD_PLAN.md | 28 +-- electron/services/database-service.ts | 20 +- electron/services/ssh-service.ts | 245 ++++++++++++++++++++++++- shared/types/ipc.types.ts | 3 +- shared/types/session.types.ts | 15 ++ src/components/SessionDialog.tsx | 151 ++++++++++++++- src/stores/connection/poolStore.ts | 1 + src/stores/connection/sessionHelper.ts | 1 + src/stores/sessionStore.ts | 7 + src/utils/index.ts | 7 + src/utils/proxy.ts | 94 ++++++++++ test/utils/proxy.test.ts | 203 ++++++++++++++++++++ 15 files changed, 757 insertions(+), 23 deletions(-) create mode 100644 src/utils/proxy.ts create mode 100644 test/utils/proxy.test.ts diff --git a/PROJECT_EVALUATION.md b/PROJECT_EVALUATION.md index 6f238f4..7180d79 100644 --- a/PROJECT_EVALUATION.md +++ b/PROJECT_EVALUATION.md @@ -196,7 +196,7 @@ | 主题系统 | ✅ | 90% | Dark/Light/System | | 终端分屏 | 🚧 | 0% | v1.4.0 计划 | | i18n 国际化 | 🚧 | 5% | v1.4.0 计划 | -| 代理设置 | 🚧 | 0% | v1.4.0 计划 | +| 代理设置 | ✅ | 85% | v1.4.0: HTTP/SOCKS5 代理+按会话配置 | | 多窗口支持 | 🚧 | 0% | v1.4.0 计划 | --- diff --git a/README.md b/README.md index 1467b40..e025d3c 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ - 📑 **多标签页** - 类浏览器标签管理,连接池复用,会话恢复 - 🎨 **现代 UI** - 多主题系统 (Dark/Light/System),命令面板,命令片段库 - ⌨️ **快捷键自定义** - 可重映射所有快捷键,实时冲突检测 +- 🛡️ **代理支持** - HTTP/SOCKS5 代理,按会话独立配置 - 🔒 **安全可靠** - AES-256-GCM 加密存储,PBKDF2 600K 迭代密钥派生 - 🌐 **跨平台** - 支持 Windows、macOS、Linux diff --git a/ROADMAP.md b/ROADMAP.md index 2229e97..be76479 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -100,7 +100,7 @@ v1.5.0 安全升级 ─── 1 周 ──→ 安全 55→85, 代码 80→85 | 1 | 快捷键自定义系统 | 新增 | 3天 | 设置页快捷键 Tab,可重映射所有快捷键,冲突检测 | ✅ | | 2 | 终端分屏 | 新增 | 5天 | 水平/垂直分割标签页,可拖拽调整分割比例 | 🚧 | | 3 | i18n 国际化 | 新增 | 5天 | zh-CN + en-US,所有 UI 文字抽离到 locale 文件 | 🚧 | -| 4 | 代理设置 | 新增 | 2天 | HTTP/SOCKS5 代理配置,按会话独立设置 | 🚧 | +|| 4 | 代理设置 | 新增 | 2天 | HTTP/SOCKS5 代理配置,按会话独立设置 | ✅ | | 5 | 多窗口支持 | 新增 | 5天 | 右键标签"在新窗口打开",窗口位置记忆 | 🚧 | > **注**: Light 主题、Snippets 命令片段库已在 v1.3.0 提前完成 diff --git a/TDD_PLAN.md b/TDD_PLAN.md index 32414c2..da53000 100644 --- a/TDD_PLAN.md +++ b/TDD_PLAN.md @@ -6,22 +6,23 @@ ## 当前状态 -|| 指标 | 当前值 | 目标值 | -|------|--------|--------| -| 测试文件数 | 5 | 15+ | -| 测试用例数 | 72 | 150+ | -| 估算覆盖率 | ~15% | ≥50% | -| 核心模块覆盖 | 60% | ≥80% | +||| 指标 | 当前值 | 目标值 || +||------|--------|--------|-- +|| 测试文件数 | 6 | 15+ || +|| 测试用例数 | 96 | 150+ || +|| 估算覆盖率 | ~25% | ≥50% || +|| 核心模块覆盖 | 60% | ≥80% || ### 已有测试 -|| 测试文件 | 用例数 | 覆盖模块 | 质量 | -|----------|--------|----------|------| -| `test/stores/tabStore.test.ts` | 14 | tabStore | ✅ 完整 | -| `test/stores/connectionPool.test.ts` | 8 | poolStore | 🟡 仅接口验证 | -| `test/components/sftp/utils.test.ts` | 16 | sftp/utils | ✅ 完整 | -| `test/services/crypto.test.ts` | 11 | crypto | ✅ 完整 | -| `test/stores/keybindingStore.test.ts` | 23 | keybindingStore | ✅ 完整 | +||| 测试文件 | 用例数 | 覆盖模块 | 质量 || +||----------|--------|----------|------| +|| `test/stores/tabStore.test.ts` | 14 | tabStore | ✅ 完整 || +|| `test/stores/connectionPool.test.ts` | 8 | poolStore | 🟡 仅接口验证 || +|| `test/components/sftp/utils.test.ts` | 16 | sftp/utils | ✅ 完整 || +|| `test/services/crypto.test.ts` | 11 | crypto | ✅ 完整 || +|| `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.3.0 | ~20% | 60% | 49 | 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-batch3 | ~45% | 70% | 143 | — | | v1.3.0-final | **≥50%** | **≥80%** | **150+** | — | diff --git a/electron/services/database-service.ts b/electron/services/database-service.ts index d36d698..ddbf3d9 100644 --- a/electron/services/database-service.ts +++ b/electron/services/database-service.ts @@ -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(` CREATE TABLE IF NOT EXISTS connection_history ( id TEXT PRIMARY KEY, @@ -218,8 +225,8 @@ export class DatabaseService { logger.info('Creating session:', session.name, session.host) const stmt = this.prepare(` - INSERT INTO sessions (id, name, host, port, username, auth_type, password, private_key_path, passphrase_encrypted, group_name, tags) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + INSERT INTO sessions (id, name, host, port, username, auth_type, password, private_key_path, passphrase_encrypted, group_name, tags, proxy) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `) const id = `session-${Date.now()}` @@ -236,7 +243,8 @@ export class DatabaseService { session.privateKey || null, this.encryptField(session.passphrase), 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() @@ -259,6 +267,7 @@ export class DatabaseService { password: this.decryptField(s.password as string | null) || undefined, privateKey: s.private_key_path as string | 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, 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, privateKey: session.private_key_path as string | 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, tags: session.tags ? JSON.parse(session.tags as string) as string[] : [], } @@ -334,6 +344,10 @@ export class DatabaseService { fields.push('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') values.push(id) diff --git a/electron/services/ssh-service.ts b/electron/services/ssh-service.ts index b6cba5a..2e3ce1d 100644 --- a/electron/services/ssh-service.ts +++ b/electron/services/ssh-service.ts @@ -5,6 +5,7 @@ import * as net from 'net' import * as fs from 'fs' import * as os from 'os' import * as path from 'path' +import type { ProxyConfig } from '../../shared/types/session.types' const logger = createLogger('SSHService') @@ -39,6 +40,7 @@ export interface SSHConfig { agent?: string readyTimeout?: number keepaliveInterval?: number + proxy?: ProxyConfig } export interface Connection { @@ -141,8 +143,7 @@ export class SSHService { return } - debug('>>> Starting SSH client.connect...') - client.connect({ + const sshConnectOpts = { host: config.host, port: config.port || 22, username: config.username, @@ -152,6 +153,246 @@ export class SSHService { agent: config.agent, readyTimeout: config.readyTimeout || 20000, 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 { + 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 { + 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 { + 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 = { + 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}`)) }) }) } diff --git a/shared/types/ipc.types.ts b/shared/types/ipc.types.ts index 1bbaf19..d97cdfc 100644 --- a/shared/types/ipc.types.ts +++ b/shared/types/ipc.types.ts @@ -3,7 +3,7 @@ * 定义所有 IPC 请求/响应的类型 */ -import type { Session } from './session.types' +import type { Session, ProxyConfig } from './session.types' // ============== SSH IPC ============== @@ -14,6 +14,7 @@ export interface SSHConnectRequest { password?: string privateKey?: string passphrase?: string + proxy?: ProxyConfig } export interface SSHConnectResponse { diff --git a/shared/types/session.types.ts b/shared/types/session.types.ts index 3fc8d18..d3e4c11 100644 --- a/shared/types/session.types.ts +++ b/shared/types/session.types.ts @@ -6,6 +6,18 @@ // 认证方式 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 { host: string @@ -15,6 +27,7 @@ export interface SSHConnectionConfig { privateKey?: string passphrase?: string authType?: AuthType + proxy?: ProxyConfig } // 持久化会话 @@ -28,6 +41,7 @@ export interface Session { password?: string // 加密存储 privateKey?: string // 私钥路径或内容 passphrase?: string // 私钥密码 + proxy?: ProxyConfig // 代理配置 group?: string tags?: string[] createdAt?: number @@ -44,6 +58,7 @@ export interface SessionCreateRequest { password?: string privateKey?: string passphrase?: string + proxy?: ProxyConfig group?: string tags?: string[] } diff --git a/src/components/SessionDialog.tsx b/src/components/SessionDialog.tsx index 06c65eb..2783313 100644 --- a/src/components/SessionDialog.tsx +++ b/src/components/SessionDialog.tsx @@ -1,7 +1,7 @@ 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 type { AuthType } from '../../shared/types/session.types' +import type { AuthType, ProxyType } from '../../shared/types/session.types' import { createLogger } from '../utils/logger' const logger = createLogger('SessionDialog') @@ -23,9 +23,17 @@ export default function SessionDialog({ isOpen, onClose, editSession }: SessionD password: '', privateKey: '~/.ssh/id_rsa', passphrase: '', + proxy: { + type: 'none' as ProxyType, + host: '', + port: 0, + username: '', + password: '', + }, group: 'default', tags: '', }) + const [showProxySettings, setShowProxySettings] = useState(false) const [isSubmitting, setIsSubmitting] = useState(false) // 当编辑会话时,填充表单数据 @@ -40,9 +48,15 @@ export default function SessionDialog({ isOpen, onClose, editSession }: SessionD password: editSession.password || '', privateKey: editSession.privateKey || '', 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', tags: editSession.tags?.join(', ') || '', }) + if (editSession.proxy && editSession.proxy.type !== 'none') { + setShowProxySettings(true) + } } else { // 重置表单 setFormData({ @@ -54,9 +68,11 @@ export default function SessionDialog({ isOpen, onClose, editSession }: SessionD password: '', privateKey: '~/.ssh/id_rsa', passphrase: '', + proxy: { type: 'none', host: '', port: 0, username: '', password: '' }, group: 'default', tags: '', }) + setShowProxySettings(false) } }, [editSession]) @@ -69,6 +85,16 @@ export default function SessionDialog({ isOpen, onClose, editSession }: SessionD setIsSubmitting(true) 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 = { name: formData.name, host: formData.host, @@ -78,6 +104,7 @@ export default function SessionDialog({ isOpen, onClose, editSession }: SessionD password: formData.password, privateKey: formData.authType === 'key' ? (formData.privateKey.trim() || '~/.ssh/id_rsa') : formData.privateKey, passphrase: formData.passphrase, + proxy: proxyConfig, group: formData.group, tags: formData.tags.split(',').map(t => t.trim()).filter(Boolean), } @@ -102,9 +129,11 @@ export default function SessionDialog({ isOpen, onClose, editSession }: SessionD password: '', privateKey: '', passphrase: '', + proxy: { type: 'none', host: '', port: 0, username: '', password: '' }, group: 'default', tags: '', }) + setShowProxySettings(false) } catch (error) { logger.error(isEditing ? 'Failed to update session:' : 'Failed to create session:', error) } finally { @@ -301,6 +330,124 @@ export default function SessionDialog({ isOpen, onClose, editSession }: SessionD )} + {/* Proxy Settings */} +
+ + + {showProxySettings && ( +
+ {/* Proxy Type */} +
+ {[ + { value: 'none', label: '不使用' }, + { value: 'http', label: 'HTTP' }, + { value: 'socks5', label: 'SOCKS5' }, + ].map((type) => ( + + ))} +
+ + {formData.proxy.type !== 'none' && ( + <> + {/* Proxy Host & Port */} +
+
+ + 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" + /> +
+
+ + 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" + /> +
+
+ + {/* Proxy Auth */} +
+
+ + 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" + /> +
+
+ + 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" + /> +
+
+ + )} +
+ )} +
+ {/* Group and tags */}
diff --git a/src/stores/connection/poolStore.ts b/src/stores/connection/poolStore.ts index 3b1be91..a5b8777 100644 --- a/src/stores/connection/poolStore.ts +++ b/src/stores/connection/poolStore.ts @@ -142,6 +142,7 @@ export const usePoolStore = create((set, get) => ({ password: config.password, privateKey: config.privateKey, passphrase: config.passphrase, + proxy: config.proxy, }) console.log('>>> sshConnect result:', result) diff --git a/src/stores/connection/sessionHelper.ts b/src/stores/connection/sessionHelper.ts index 4e27261..51473af 100644 --- a/src/stores/connection/sessionHelper.ts +++ b/src/stores/connection/sessionHelper.ts @@ -27,6 +27,7 @@ export async function getSessionConfig(sessionId: string): Promise 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}` +} diff --git a/test/utils/proxy.test.ts b/test/utils/proxy.test.ts new file mode 100644 index 0000000..d9e3bde --- /dev/null +++ b/test/utils/proxy.test.ts @@ -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('') + }) + }) +})