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