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

+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}`
}