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:
OpsXShell Dev committed 2026-04-25 05:14:27 +08:00
1 parent 3f636b1b0e
commit 07c4a5b14f
15 files changed
+757 -23

No files matched your search

+1 -1
View File
@@ -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 计划 |
---
+1
View File
@@ -10,6 +10,7 @@
- 📑 **多标签页** - 类浏览器标签管理,连接池复用,会话恢复
- 🎨 **现代 UI** - 多主题系统 (Dark/Light/System),命令面板,命令片段库
- ⌨️ **快捷键自定义** - 可重映射所有快捷键,实时冲突检测
- 🛡️ **代理支持** - HTTP/SOCKS5 代理,按会话独立配置
- 🔒 **安全可靠** - AES-256-GCM 加密存储,PBKDF2 600K 迭代密钥派生
- 🌐 **跨平台** - 支持 Windows、macOS、Linux
+1 -1
View File
@@ -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 提前完成
+15 -13
View File
@@ -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+** | — |
+17 -3
View File
@@ -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)
+243 -2
View File
@@ -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<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}`))
})
})
}
+2 -1
View File
@@ -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 {
+15
View File
@@ -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[]
}
+149 -2
View File
@@ -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
)}
</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 */}
<div className="border-t border-dark-700 pt-4 grid grid-cols-2 gap-3">
<div>
+1
View File
@@ -142,6 +142,7 @@ export const usePoolStore = create<PoolState>((set, get) => ({
password: config.password,
privateKey: config.privateKey,
passphrase: config.passphrase,
proxy: config.proxy,
})
console.log('>>> sshConnect result:', result)
+1
View File
@@ -27,6 +27,7 @@ export async function getSessionConfig(sessionId: string): Promise<ConnectionCon
privateKey: session.privateKey,
passphrase: session.passphrase,
authType: session.authType,
proxy: session.proxy,
}
} catch (error) {
logger.error('Failed to get session config:', error)
+7
View File
@@ -17,6 +17,13 @@ export interface Session {
password?: string
privateKey?: string
passphrase?: string
proxy?: {
type: 'none' | 'http' | 'socks5'
host: string
port: number
username?: string
password?: string
}
group?: string
tags?: string[]
}
+7
View File
@@ -4,5 +4,12 @@
export { createLogger, logger, LogLevel } from './logger'
export { handleError, createError, ErrorCode, withErrorHandling } from './error-handler'
export {
getDefaultProxyConfig,
isProxyConfigured,
validateProxyConfig,
parseProxyUrl,
buildProxyUrl,
} from './proxy'
// export { validators } from './validators'
// export { formatters } from './formatters'
+94
View File
@@ -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}`
}
+203
View File
@@ -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('')
})
})
})