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
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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[]
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
@@ -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}`
|
||||
}
|
||||
Reference in new issue
Block a user