cc-haha/desktop/electron/services/systemProxyBridge.ts
2026-07-16 17:33:33 +08:00

682 lines
22 KiB
TypeScript

import http from 'node:http'
import https from 'node:https'
import net from 'node:net'
import tls from 'node:tls'
import { lookup } from 'node:dns/promises'
import type { Duplex } from 'node:stream'
export const SYSTEM_PROXY_BRIDGE_HOST = '127.0.0.1'
const CONNECT_TIMEOUT_MS = 10_000
const MAX_BUFFERED_REQUEST_BYTES = 32 * 1024 * 1024
export type SystemProxyRule = {
type: 'direct' | 'http' | 'https' | 'socks4' | 'socks5'
host?: string
port?: number
}
export type SystemProxyBridgeLike = {
start(): Promise<string>
stop(): Promise<void>
}
type ProxyEndpoint = Required<Pick<SystemProxyRule, 'host' | 'port'>>
type OutgoingRequestOptions = http.RequestOptions & { servername?: string }
export function parseSystemProxyRules(rules: string | undefined): SystemProxyRule[] {
if (!rules?.trim()) return [{ type: 'direct' }]
const parsed: SystemProxyRule[] = []
for (const rawRule of rules.split(';')) {
const rule = rawRule.trim()
if (!rule) continue
if (/^DIRECT$/i.test(rule)) {
parsed.push({ type: 'direct' })
continue
}
const match = rule.match(/^(PROXY|HTTPS|SOCKS|SOCKS4|SOCKS5)\s+(.+)$/i)
if (!match) continue
const endpoint = parseEndpoint(match[2]!)
if (!endpoint) continue
const kind = match[1]!.toUpperCase()
parsed.push({
type: kind === 'PROXY'
? 'http'
: kind === 'HTTPS'
? 'https'
: kind === 'SOCKS4'
? 'socks4'
: kind === 'SOCKS5'
? 'socks5'
: 'socks4',
...endpoint,
})
}
return parsed
}
export class SystemProxyBridge implements SystemProxyBridgeLike {
private server: http.Server | null = null
private startPromise: Promise<string> | null = null
private lifecycleGeneration = 0
private readonly clientSockets = new Set<net.Socket>()
private readonly outboundSockets = new Set<Duplex>()
constructor(private readonly resolveSystemProxy: (url: string) => Promise<string>) {}
start(): Promise<string> {
if (this.startPromise) return this.startPromise
const generation = ++this.lifecycleGeneration
this.startPromise = this.startOnce(generation)
return this.startPromise
}
async stop(): Promise<void> {
++this.lifecycleGeneration
const startPromise = this.startPromise
this.startPromise = null
const server = this.server
this.server = null
const closing = server?.listening
? new Promise<void>(resolve => server.close(() => resolve()))
: Promise.resolve()
for (const socket of this.clientSockets) socket.destroy()
for (const socket of this.outboundSockets) socket.destroy()
await closing
await startPromise?.catch(() => {})
}
private async startOnce(generation: number): Promise<string> {
const server = http.createServer((request, response) => {
void this.handleHttpRequest(request, response)
})
server.on('connect', (request, clientSocket, head) => {
void this.handleConnect(request, clientSocket, head)
})
server.on('clientError', (_error, socket) => {
socket.end('HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n')
})
server.on('connection', socket => {
this.clientSockets.add(socket)
socket.once('close', () => this.clientSockets.delete(socket))
})
this.server = server
try {
await new Promise<void>((resolve, reject) => {
server.once('error', reject)
server.listen(0, SYSTEM_PROXY_BRIDGE_HOST, () => {
server.off('error', reject)
resolve()
})
})
} catch (error) {
if (this.server === server) this.server = null
throw error
}
if (generation !== this.lifecycleGeneration || this.server !== server) {
await closeServer(server)
throw new Error('System proxy bridge startup was stopped')
}
const address = server.address()
if (!address || typeof address === 'string') {
if (this.server === server) this.server = null
await closeServer(server)
throw new Error('Could not resolve system proxy bridge port')
}
return `http://${SYSTEM_PROXY_BRIDGE_HOST}:${address.port}`
}
private async handleHttpRequest(
request: http.IncomingMessage,
response: http.ServerResponse,
): Promise<void> {
try {
const target = resolveHttpTarget(request)
if (target.protocol !== 'http:') {
response.writeHead(400, { Connection: 'close' })
response.end('HTTPS proxy requests must use CONNECT')
return
}
const rules = await this.resolveRules(target)
const method = request.method ?? 'GET'
const headers = sanitizeProxyRequestHeaders(request.headers)
const onSocket = (socket: Duplex) => this.trackOutboundSocket(socket)
// Model calls are usually POSTs. Never replay them after bytes may have reached a provider;
// only explicitly safe methods may retry a later PAC route before any response is received.
const upstreamResponse = isReplaySafeMethod(method)
? await requestUsingRules(
rules,
target,
method,
headers,
await readRequestBody(request),
onSocket,
)
: await requestStreamingUsingRule(
await selectReachableRule(rules, target),
target,
method,
headers,
request,
onSocket,
)
response.writeHead(upstreamResponse.statusCode ?? 502, upstreamResponse.statusMessage, upstreamResponse.headers)
upstreamResponse.pipe(response)
} catch (error) {
if (!response.headersSent) response.writeHead(502, { Connection: 'close' })
response.end(`System proxy bridge failed: ${error instanceof Error ? error.message : String(error)}`)
}
}
private async handleConnect(
request: http.IncomingMessage,
clientSocket: Duplex,
head: Buffer,
): Promise<void> {
try {
const endpoint = parseEndpoint(request.url ?? '', 443)
if (!endpoint) throw new Error('Invalid CONNECT target')
const target = new URL(`https://${formatAuthority(endpoint.host, endpoint.port)}/`)
const rules = await this.resolveRules(target)
const route = await connectTunnelUsingRules(rules, endpoint.host, endpoint.port)
this.trackOutboundSocket(route.socket)
clientSocket.write('HTTP/1.1 200 Connection Established\r\n\r\n')
if (head.length > 0) route.socket.write(head)
route.socket.pipe(clientSocket)
clientSocket.pipe(route.socket)
const closeBoth = () => {
route.socket.destroy()
clientSocket.destroy()
}
route.socket.on('error', closeBoth)
clientSocket.on('error', closeBoth)
} catch (error) {
const authenticationRequired = error instanceof ProxyAuthenticationRequiredError
clientSocket.end(`${authenticationRequired
? 'HTTP/1.1 407 Proxy Authentication Required'
: 'HTTP/1.1 502 Bad Gateway'}\r\nConnection: close\r\nContent-Type: text/plain\r\n\r\n${error instanceof Error ? error.message : String(error)}`)
}
}
private async resolveRules(target: URL): Promise<SystemProxyRule[]> {
if (isLoopbackHostname(target.hostname)) return [{ type: 'direct' }]
return parseSystemProxyRules(await this.resolveSystemProxy(target.href))
}
private trackOutboundSocket(socket: Duplex): void {
this.outboundSockets.add(socket)
socket.once('close', () => this.outboundSockets.delete(socket))
}
}
function resolveHttpTarget(request: http.IncomingMessage): URL {
const rawUrl = request.url ?? ''
if (/^https?:\/\//i.test(rawUrl)) return new URL(rawUrl)
const host = request.headers.host
if (!host) throw new Error('Proxy request is missing Host header')
return new URL(rawUrl || '/', `http://${host}`)
}
export function sanitizeProxyRequestHeaders(headers: http.IncomingHttpHeaders): http.IncomingHttpHeaders {
const sanitized = { ...headers }
const connectionTokens = String(headers.connection ?? '')
.split(',')
.map(token => token.trim().toLowerCase())
.filter(Boolean)
for (const name of [
...connectionTokens,
'connection',
'keep-alive',
'proxy-authenticate',
'proxy-authorization',
'proxy-connection',
'te',
'trailer',
'transfer-encoding',
'upgrade',
]) {
delete sanitized[name]
}
return sanitized
}
function isReplaySafeMethod(method: string): boolean {
return ['GET', 'HEAD', 'OPTIONS'].includes(method.toUpperCase())
}
async function requestUsingRules(
rules: SystemProxyRule[],
target: URL,
method: string,
headers: http.IncomingHttpHeaders,
body: Buffer,
onSocket: (socket: Duplex) => void,
): Promise<http.IncomingMessage> {
const errors: string[] = []
for (const rule of rules) {
try {
return await requestUsingRule(rule, target, method, headers, body, onSocket)
} catch (error) {
errors.push(`${rule.type}: ${error instanceof Error ? error.message : String(error)}`)
}
}
throw new Error(`No system proxy route succeeded (${errors.join('; ')})`)
}
async function requestUsingRule(
rule: SystemProxyRule,
target: URL,
method: string,
headers: http.IncomingHttpHeaders,
body: Buffer,
onSocket: (socket: Duplex) => void,
): Promise<http.IncomingMessage> {
const outgoing = await createRuleRequest(rule, target, method, headers, onSocket)
return await performHttpRequest(outgoing.transport, outgoing.options, body, onSocket)
}
async function requestStreamingUsingRule(
rule: SystemProxyRule,
target: URL,
method: string,
headers: http.IncomingHttpHeaders,
body: http.IncomingMessage,
onSocket: (socket: Duplex) => void,
): Promise<http.IncomingMessage> {
const outgoing = await createRuleRequest(rule, target, method, headers, onSocket)
return await new Promise((resolve, reject) => {
const request = outgoing.transport.request(outgoing.options, resolve)
request.once('socket', onSocket)
request.once('error', reject)
body.once('error', error => request.destroy(error))
body.pipe(request)
})
}
async function createRuleRequest(
rule: SystemProxyRule,
target: URL,
method: string,
headers: http.IncomingHttpHeaders,
onSocket: (socket: Duplex) => void,
): Promise<{ transport: typeof http | typeof https, options: OutgoingRequestOptions }> {
const outgoingHeaders = { ...headers, connection: 'close' }
if (rule.type === 'direct') {
return {
transport: http,
options: {
method,
host: target.hostname,
port: targetPort(target),
path: `${target.pathname}${target.search}`,
headers: outgoingHeaders,
agent: false,
},
}
}
const endpoint = requireEndpoint(rule)
if (rule.type === 'http' || rule.type === 'https') {
return {
transport: rule.type === 'https' ? https : http,
options: {
method,
host: endpoint.host,
port: endpoint.port,
path: target.href,
headers: outgoingHeaders,
agent: false,
servername: net.isIP(endpoint.host) ? undefined : endpoint.host,
},
}
}
const socket = rule.type === 'socks4'
? await connectSocks4(endpoint, target.hostname, targetPort(target))
: await connectSocks5(endpoint, target.hostname, targetPort(target))
onSocket(socket)
return {
transport: http,
options: {
method,
host: target.hostname,
port: targetPort(target),
path: `${target.pathname}${target.search}`,
headers: outgoingHeaders,
agent: new SingleSocketAgent(socket),
},
}
}
async function selectReachableRule(rules: SystemProxyRule[], target: URL): Promise<SystemProxyRule> {
const errors: string[] = []
for (const rule of rules) {
let socket: Duplex | null = null
try {
if (rule.type === 'direct') {
socket = await connectTcp(target.hostname, targetPort(target))
} else {
const endpoint = requireEndpoint(rule)
socket = rule.type === 'http' || rule.type === 'https'
? await connectProxyEndpoint(endpoint, rule.type === 'https')
: rule.type === 'socks4'
? await connectSocks4(endpoint, target.hostname, targetPort(target))
: await connectSocks5(endpoint, target.hostname, targetPort(target))
}
socket.destroy()
return rule
} catch (error) {
socket?.destroy()
errors.push(`${rule.type}: ${error instanceof Error ? error.message : String(error)}`)
}
}
throw new Error(`No system proxy route succeeded (${errors.join('; ')})`)
}
function performHttpRequest(
transport: typeof http | typeof https,
options: OutgoingRequestOptions,
body: Buffer,
onSocket: (socket: Duplex) => void,
): Promise<http.IncomingMessage> {
return new Promise((resolve, reject) => {
const request = transport.request(options, resolve)
request.once('socket', onSocket)
request.once('error', reject)
request.end(body)
})
}
async function readRequestBody(request: http.IncomingMessage): Promise<Buffer> {
const chunks: Buffer[] = []
let total = 0
for await (const chunk of request) {
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
total += buffer.length
if (total > MAX_BUFFERED_REQUEST_BYTES) {
throw new Error(`proxy request body exceeds ${MAX_BUFFERED_REQUEST_BYTES} bytes`)
}
chunks.push(buffer)
}
return Buffer.concat(chunks, total)
}
class SingleSocketAgent extends http.Agent {
private claimed = false
constructor(private readonly socket: net.Socket) {
super({ keepAlive: false })
}
override createConnection(): net.Socket {
if (this.claimed) throw new Error('System proxy route socket was already claimed')
this.claimed = true
return this.socket
}
}
function closeServer(server: http.Server): Promise<void> {
if (!server.listening) return Promise.resolve()
return new Promise(resolve => server.close(() => resolve()))
}
async function connectTunnelUsingRules(
rules: SystemProxyRule[],
targetHost: string,
targetPortNumber: number,
): Promise<{ socket: Duplex }> {
const errors: string[] = []
for (const rule of rules) {
try {
if (rule.type === 'direct') {
return { socket: await connectTcp(targetHost, targetPortNumber) }
}
const endpoint = requireEndpoint(rule)
if (rule.type === 'http' || rule.type === 'https') {
return {
socket: await establishHttpProxyTunnel(
endpoint,
rule.type === 'https',
targetHost,
targetPortNumber,
),
}
}
return {
socket: rule.type === 'socks4'
? await connectSocks4(endpoint, targetHost, targetPortNumber)
: await connectSocks5(endpoint, targetHost, targetPortNumber),
}
} catch (error) {
if (error instanceof ProxyAuthenticationRequiredError) throw error
errors.push(`${rule.type}: ${error instanceof Error ? error.message : String(error)}`)
}
}
throw new Error(`No system proxy route succeeded (${errors.join('; ')})`)
}
async function establishHttpProxyTunnel(
endpoint: ProxyEndpoint,
secure: boolean,
targetHost: string,
targetPortNumber: number,
): Promise<Duplex> {
const socket = await connectProxyEndpoint(endpoint, secure)
try {
const authority = formatAuthority(targetHost, targetPortNumber)
socket.write(
`CONNECT ${authority} HTTP/1.1\r\nHost: ${authority}\r\nProxy-Connection: keep-alive\r\n\r\n`,
)
const header = await readUntil(socket, Buffer.from('\r\n\r\n'), 64 * 1024)
const statusLine = header.toString('latin1').split('\r\n', 1)[0] ?? ''
const status = Number(statusLine.match(/^HTTP\/\d(?:\.\d)?\s+(\d{3})\b/i)?.[1])
if (status === 407) {
throw new ProxyAuthenticationRequiredError('HTTP proxy requires authentication')
}
if (status < 200 || status >= 300) {
throw new Error(`HTTP proxy CONNECT returned ${Number.isFinite(status) ? status : 'an invalid response'}`)
}
return socket
} catch (error) {
socket.destroy()
throw error
}
}
function connectProxyEndpoint(endpoint: ProxyEndpoint, secure: boolean): Promise<Duplex> {
return secure ? connectTls(endpoint.host, endpoint.port) : connectTcp(endpoint.host, endpoint.port)
}
function connectTcp(host: string, port: number): Promise<net.Socket> {
return new Promise((resolve, reject) => {
const socket = net.connect({ host, port })
const timer = setTimeout(() => socket.destroy(new Error('connection timed out')), CONNECT_TIMEOUT_MS)
socket.once('connect', () => {
clearTimeout(timer)
resolve(socket)
})
socket.once('error', error => {
clearTimeout(timer)
reject(error)
})
})
}
function connectTls(host: string, port: number): Promise<tls.TLSSocket> {
return new Promise((resolve, reject) => {
const socket = tls.connect({ host, port, servername: net.isIP(host) ? undefined : host })
const timer = setTimeout(() => socket.destroy(new Error('connection timed out')), CONNECT_TIMEOUT_MS)
socket.once('secureConnect', () => {
clearTimeout(timer)
resolve(socket)
})
socket.once('error', error => {
clearTimeout(timer)
reject(error)
})
})
}
async function connectSocks5(
endpoint: ProxyEndpoint,
targetHost: string,
targetPortNumber: number,
): Promise<net.Socket> {
const socket = await connectTcp(endpoint.host, endpoint.port)
try {
socket.write(Buffer.from([0x05, 0x01, 0x00]))
const greeting = await readExactly(socket, 2)
if (greeting[0] !== 0x05 || greeting[1] !== 0x00) throw new Error('SOCKS5 proxy rejected no-authentication mode')
const host = Buffer.from(targetHost)
if (host.length > 255) throw new Error('SOCKS5 target hostname is too long')
const port = Buffer.allocUnsafe(2)
port.writeUInt16BE(targetPortNumber)
socket.write(Buffer.concat([Buffer.from([0x05, 0x01, 0x00, 0x03, host.length]), host, port]))
const response = await readExactly(socket, 4)
if (response[0] !== 0x05 || response[1] !== 0x00) throw new Error(`SOCKS5 connect failed with code ${response[1]}`)
const addressLength = response[3] === 0x01
? 4
: response[3] === 0x04
? 16
: response[3] === 0x03
? (await readExactly(socket, 1))[0]!
: 0
if (!addressLength) throw new Error('SOCKS5 proxy returned an invalid address type')
await readExactly(socket, addressLength + 2)
return socket
} catch (error) {
socket.destroy()
throw error
}
}
async function connectSocks4(
endpoint: ProxyEndpoint,
targetHost: string,
targetPortNumber: number,
): Promise<net.Socket> {
const socket = await connectTcp(endpoint.host, endpoint.port)
try {
const port = Buffer.allocUnsafe(2)
port.writeUInt16BE(targetPortNumber)
const address = net.isIPv4(targetHost)
? targetHost
: (await lookup(targetHost, { family: 4 })).address
const octets = address.split('.').map(Number)
if (octets.length !== 4 || octets.some(octet => !Number.isInteger(octet) || octet < 0 || octet > 255)) {
throw new Error('SOCKS4 target did not resolve to IPv4')
}
socket.write(Buffer.concat([
Buffer.from([0x04, 0x01]),
port,
Buffer.from(octets),
Buffer.from([0x00]),
]))
const response = await readExactly(socket, 8)
if (response[1] !== 0x5a) throw new Error(`SOCKS4 connect failed with code ${response[1]}`)
return socket
} catch (error) {
socket.destroy()
throw error
}
}
function readExactly(socket: net.Socket, length: number): Promise<Buffer> {
return readFromSocket(socket, buffer => buffer.length >= length ? length : null)
}
function readUntil(socket: Duplex, marker: Buffer, maxBytes: number): Promise<Buffer> {
return readFromSocket(socket, buffer => {
const index = buffer.indexOf(marker)
if (index >= 0) return index + marker.length
if (buffer.length > maxBytes) throw new Error('proxy response headers are too large')
return null
})
}
function readFromSocket(
socket: Duplex,
completeLength: (buffer: Buffer) => number | null,
): Promise<Buffer> {
return new Promise((resolve, reject) => {
let buffered = Buffer.alloc(0)
const cleanup = () => {
socket.off('data', onData)
socket.off('error', onError)
socket.off('close', onClose)
clearTimeout(timer)
}
const onData = (chunk: Buffer) => {
buffered = Buffer.concat([buffered, chunk])
let length: number | null
try {
length = completeLength(buffered)
} catch (error) {
cleanup()
reject(error)
return
}
if (length === null) return
cleanup()
if (buffered.length > length) socket.unshift(buffered.subarray(length))
resolve(buffered.subarray(0, length))
}
const onError = (error: Error) => {
cleanup()
reject(error)
}
const onClose = () => {
cleanup()
reject(new Error('proxy connection closed during handshake'))
}
const timer = setTimeout(() => {
cleanup()
reject(new Error('proxy handshake timed out'))
}, CONNECT_TIMEOUT_MS)
socket.on('data', onData)
socket.once('error', onError)
socket.once('close', onClose)
})
}
function parseEndpoint(value: string, defaultPort?: number): ProxyEndpoint | null {
const trimmed = value.trim()
if (!trimmed) return null
try {
const url = new URL(`tcp://${trimmed}`)
const port = url.port ? Number(url.port) : defaultPort
if (!url.hostname || !port || !Number.isInteger(port) || port < 1 || port > 65535) return null
return { host: stripIpv6Brackets(url.hostname), port }
} catch {
return null
}
}
function requireEndpoint(rule: SystemProxyRule): ProxyEndpoint {
if (!rule.host || !rule.port) throw new Error(`Invalid ${rule.type} proxy endpoint`)
return { host: rule.host, port: rule.port }
}
function targetPort(url: URL): number {
return url.port ? Number(url.port) : url.protocol === 'https:' ? 443 : 80
}
function isLoopbackHostname(hostname: string): boolean {
const normalized = stripIpv6Brackets(hostname).toLowerCase()
return normalized === 'localhost' || normalized === '127.0.0.1' || normalized === '::1'
}
function stripIpv6Brackets(hostname: string): string {
return hostname.startsWith('[') && hostname.endsWith(']') ? hostname.slice(1, -1) : hostname
}
function formatAuthority(host: string, port: number): string {
return `${host.includes(':') ? `[${host}]` : host}:${port}`
}
class ProxyAuthenticationRequiredError extends Error {}