424 lines
17 KiB
JavaScript
424 lines
17 KiB
JavaScript
import { lookup } from 'node:dns/promises'
|
|
import { connect, isIP } from 'node:net'
|
|
import { domainToASCII } from 'node:url'
|
|
|
|
export const PROXY_TIMEOUTS = Object.freeze({
|
|
connectMs: 10_000,
|
|
handshakeMs: 15_000,
|
|
responseHeaderMs: 60_000,
|
|
streamIdleMs: 120_000,
|
|
tunnelIdleMs: 30 * 60_000,
|
|
})
|
|
|
|
export class ProxyTransportError extends Error {
|
|
constructor(message, { code = 'UPSTREAM_CONNECT_FAILED', statusCode = 502, cause } = {}) {
|
|
super(message, { cause })
|
|
this.name = 'ProxyTransportError'
|
|
this.code = code
|
|
this.statusCode = statusCode
|
|
}
|
|
}
|
|
|
|
function timeoutError(stage) {
|
|
return new ProxyTransportError(`${stage} timed out`, {
|
|
code: 'PROXY_CONNECT_TIMEOUT',
|
|
statusCode: 504,
|
|
})
|
|
}
|
|
|
|
export async function connectTcp({ host, port, timeoutMs = PROXY_TIMEOUTS.connectMs, signal }) {
|
|
if (signal?.aborted) {
|
|
throw new ProxyTransportError('upstream connection was cancelled', {
|
|
code: 'REQUEST_CANCELLED', statusCode: 499,
|
|
})
|
|
}
|
|
const socket = connect({ host, port, allowHalfOpen: true })
|
|
// The socket passes through async handshake and Agent/tunnel owners. Keep a
|
|
// baseline listener so a peer reset during an ownership handoff cannot exit
|
|
// the Proxy Manager; each active owner still installs its own error handler.
|
|
socket.on('error', () => {})
|
|
try {
|
|
await new Promise((resolve, reject) => {
|
|
const cleanup = () => {
|
|
clearTimeout(timer)
|
|
socket.off('connect', connected)
|
|
socket.off('error', failed)
|
|
signal?.removeEventListener('abort', cancelled)
|
|
}
|
|
const connected = () => { cleanup(); resolve() }
|
|
const failed = error => { cleanup(); reject(error) }
|
|
const cancelled = () => {
|
|
const error = new ProxyTransportError('upstream connection was cancelled', {
|
|
code: 'REQUEST_CANCELLED', statusCode: 499,
|
|
})
|
|
cleanup()
|
|
socket.destroy()
|
|
reject(error)
|
|
}
|
|
const timer = setTimeout(() => { cleanup(); reject(timeoutError('TCP connection')) }, timeoutMs)
|
|
socket.once('connect', connected)
|
|
socket.once('error', failed)
|
|
signal?.addEventListener('abort', cancelled, { once: true })
|
|
})
|
|
socket.setTimeout(0)
|
|
return socket
|
|
} catch (error) {
|
|
socket.destroy()
|
|
if (error instanceof ProxyTransportError) throw error
|
|
throw new ProxyTransportError('upstream connection failed', { cause: error })
|
|
}
|
|
}
|
|
|
|
export function basicProxyAuthorization(username, password) {
|
|
if (username === '' && password === null) return null
|
|
return `Basic ${Buffer.from(`${username}:${password ?? ''}`, 'utf8').toString('base64')}`
|
|
}
|
|
|
|
function validateHttpHeadLimits(head) {
|
|
const lines = head.toString('latin1').split('\r\n')
|
|
if (lines.length - 2 > 256 || lines.some(line => Buffer.byteLength(line, 'latin1') > 64 * 1024)) {
|
|
throw new ProxyTransportError('upstream response headers are too large', {
|
|
code: 'UPSTREAM_HEADERS_TOO_LARGE',
|
|
})
|
|
}
|
|
}
|
|
|
|
export async function readHttpHead(socket, {
|
|
maxBytes = 256 * 1024,
|
|
timeoutMs = PROXY_TIMEOUTS.handshakeMs,
|
|
signal,
|
|
} = {}) {
|
|
const reader = new SocketByteReader(socket)
|
|
const cancel = () => socket.destroy(new ProxyTransportError('proxy handshake was cancelled', {
|
|
code: 'REQUEST_CANCELLED', statusCode: 499,
|
|
}))
|
|
signal?.addEventListener('abort', cancel, { once: true })
|
|
try {
|
|
const head = await reader.readUntil(Buffer.from('\r\n\r\n'), maxBytes, Date.now() + timeoutMs)
|
|
return Object.freeze({ head, remainder: reader.release() })
|
|
} catch (error) {
|
|
if (reader.pending === null) reader.release()
|
|
if (error instanceof ProxyTransportError) throw error
|
|
throw new ProxyTransportError('upstream proxy handshake failed', { cause: error })
|
|
} finally {
|
|
signal?.removeEventListener('abort', cancel)
|
|
}
|
|
}
|
|
|
|
export async function connectThroughHttpProxy({
|
|
endpoint,
|
|
targetHost,
|
|
targetPort,
|
|
connectTimeoutMs = PROXY_TIMEOUTS.connectMs,
|
|
handshakeTimeoutMs = PROXY_TIMEOUTS.handshakeMs,
|
|
signal,
|
|
}) {
|
|
const socket = await connectTcp({ ...endpoint, timeoutMs: connectTimeoutMs, signal })
|
|
const authorization = basicProxyAuthorization(endpoint.username, endpoint.password)
|
|
const authority = targetHost.includes(':') ? `[${targetHost}]:${targetPort}` : `${targetHost}:${targetPort}`
|
|
socket.write([
|
|
`CONNECT ${authority} HTTP/1.1`,
|
|
`Host: ${authority}`,
|
|
...(authorization === null ? [] : [`Proxy-Authorization: ${authorization}`]),
|
|
'Proxy-Connection: keep-alive',
|
|
'',
|
|
'',
|
|
].join('\r\n'))
|
|
try {
|
|
const result = await readHttpHead(socket, { timeoutMs: handshakeTimeoutMs, signal })
|
|
validateHttpHeadLimits(result.head)
|
|
const first = result.head.subarray(0, result.head.indexOf('\r\n')).toString('latin1')
|
|
const match = /^HTTP\/1\.[01] ([0-9]{3})(?: |$)/.exec(first)
|
|
if (match === null) throw new ProxyTransportError('upstream proxy returned an invalid response')
|
|
const status = Number(match[1])
|
|
if (status < 200 || status >= 300) {
|
|
throw new ProxyTransportError(
|
|
status === 407 ? 'upstream proxy authentication failed' : 'upstream proxy rejected the tunnel',
|
|
{ code: status === 407 ? 'UPSTREAM_PROXY_AUTH_FAILED' : 'UPSTREAM_PROXY_REJECTED' },
|
|
)
|
|
}
|
|
return Object.freeze({ socket, remainder: result.remainder })
|
|
} catch (error) {
|
|
socket.destroy()
|
|
throw error
|
|
}
|
|
}
|
|
|
|
function ipv4Bytes(address) {
|
|
const values = address.split('.').map(Number)
|
|
if (values.length !== 4 || values.some(value => !Number.isInteger(value) || value < 0 || value > 255)) {
|
|
throw new ProxyTransportError('SOCKS5 IPv4 target is invalid', { code: 'SOCKS5_TARGET_INVALID' })
|
|
}
|
|
return Buffer.from(values)
|
|
}
|
|
|
|
function ipv6Groups(input) {
|
|
const expand = values => {
|
|
const result = []
|
|
for (const value of values) {
|
|
if (value.includes('.')) {
|
|
const bytes = ipv4Bytes(value)
|
|
result.push(bytes.readUInt16BE(0).toString(16), bytes.readUInt16BE(2).toString(16))
|
|
} else result.push(value)
|
|
}
|
|
return result
|
|
}
|
|
const halves = input.toLowerCase().split('::')
|
|
if (halves.length > 2) throw new ProxyTransportError('SOCKS5 IPv6 target is invalid', { code: 'SOCKS5_TARGET_INVALID' })
|
|
const left = expand(halves[0] === '' ? [] : halves[0].split(':'))
|
|
const right = expand(halves.length === 1 || halves[1] === '' ? [] : halves[1].split(':'))
|
|
const missing = 8 - left.length - right.length
|
|
if ((halves.length === 1 && missing !== 0) || (halves.length === 2 && missing < 1)) {
|
|
throw new ProxyTransportError('SOCKS5 IPv6 target is invalid', { code: 'SOCKS5_TARGET_INVALID' })
|
|
}
|
|
const groups = halves.length === 1 ? left : [...left, ...Array(missing).fill('0'), ...right]
|
|
if (groups.length !== 8 || groups.some(group => !/^[0-9a-f]{1,4}$/.test(group))) {
|
|
throw new ProxyTransportError('SOCKS5 IPv6 target is invalid', { code: 'SOCKS5_TARGET_INVALID' })
|
|
}
|
|
return groups.map(group => Number.parseInt(group, 16))
|
|
}
|
|
|
|
function ipv6Bytes(address) {
|
|
const output = Buffer.alloc(16)
|
|
ipv6Groups(address).forEach((value, index) => output.writeUInt16BE(value, index * 2))
|
|
return output
|
|
}
|
|
|
|
async function socksTarget(host, remoteDns, resolver) {
|
|
let value = host
|
|
let type = isIP(value)
|
|
if (type === 0 && !remoteDns) {
|
|
let record
|
|
try { record = await resolver(value, { verbatim: true }) } catch (error) {
|
|
throw new ProxyTransportError('SOCKS5 target DNS resolution failed', {
|
|
code: 'TARGET_DNS_FAILED',
|
|
cause: error,
|
|
})
|
|
}
|
|
value = record.address
|
|
type = record.family
|
|
}
|
|
if (type === 4) return Buffer.concat([Buffer.from([0x01]), ipv4Bytes(value)])
|
|
if (type === 6) return Buffer.concat([Buffer.from([0x04]), ipv6Bytes(value)])
|
|
const ascii = domainToASCII(value)
|
|
const bytes = Buffer.from(ascii, 'ascii')
|
|
if (ascii === '' || bytes.byteLength > 255) {
|
|
throw new ProxyTransportError('SOCKS5 domain target is invalid', { code: 'SOCKS5_TARGET_INVALID' })
|
|
}
|
|
return Buffer.concat([Buffer.from([0x03, bytes.byteLength]), bytes])
|
|
}
|
|
|
|
class SocketByteReader {
|
|
constructor(socket) {
|
|
this.socket = socket
|
|
this.buffer = Buffer.alloc(0)
|
|
this.pending = null
|
|
this.ended = false
|
|
this.failure = null
|
|
this.onData = chunk => {
|
|
this.buffer = this.buffer.byteLength === 0 ? chunk : Buffer.concat([this.buffer, chunk])
|
|
this.flush()
|
|
}
|
|
this.onEnd = () => {
|
|
this.ended = true
|
|
this.flush()
|
|
}
|
|
this.onError = error => {
|
|
this.failure = error
|
|
this.flush()
|
|
}
|
|
socket.on('data', this.onData)
|
|
socket.on('end', this.onEnd)
|
|
socket.on('error', this.onError)
|
|
}
|
|
|
|
flush() {
|
|
if (this.pending === null) return
|
|
let size = null
|
|
if (this.pending.kind === 'exact' && this.buffer.byteLength >= this.pending.size) size = this.pending.size
|
|
if (this.pending.kind === 'until') {
|
|
const boundary = this.buffer.indexOf(this.pending.delimiter)
|
|
if (boundary >= 0) {
|
|
size = boundary + this.pending.delimiter.byteLength
|
|
if (size > this.pending.maxBytes) {
|
|
const pending = this.pending
|
|
this.pending = null
|
|
clearTimeout(pending.timer)
|
|
pending.reject(new ProxyTransportError('upstream response headers are too large', {
|
|
code: 'UPSTREAM_HEADERS_TOO_LARGE',
|
|
}))
|
|
return
|
|
}
|
|
}
|
|
else if (this.buffer.byteLength > this.pending.maxBytes) {
|
|
const pending = this.pending
|
|
this.pending = null
|
|
clearTimeout(pending.timer)
|
|
pending.reject(new ProxyTransportError('upstream response headers are too large', {
|
|
code: 'UPSTREAM_HEADERS_TOO_LARGE',
|
|
}))
|
|
return
|
|
}
|
|
}
|
|
if (size !== null) {
|
|
const pending = this.pending
|
|
this.pending = null
|
|
clearTimeout(pending.timer)
|
|
const value = this.buffer.subarray(0, size)
|
|
this.buffer = this.buffer.subarray(size)
|
|
pending.resolve(value)
|
|
return
|
|
}
|
|
if (this.failure !== null || this.ended) {
|
|
const pending = this.pending
|
|
this.pending = null
|
|
clearTimeout(pending.timer)
|
|
pending.reject(this.failure ?? new ProxyTransportError('upstream closed during SOCKS5 handshake'))
|
|
}
|
|
}
|
|
|
|
read(size, deadline) {
|
|
if (!Number.isSafeInteger(size) || size < 0) {
|
|
return Promise.reject(new ProxyTransportError('SOCKS5 response length is invalid'))
|
|
}
|
|
if (this.pending !== null) {
|
|
return Promise.reject(new ProxyTransportError('concurrent SOCKS5 reads are not supported'))
|
|
}
|
|
if (this.buffer.byteLength >= size) {
|
|
const value = this.buffer.subarray(0, size)
|
|
this.buffer = this.buffer.subarray(size)
|
|
return Promise.resolve(value)
|
|
}
|
|
if (this.failure !== null) return Promise.reject(this.failure)
|
|
if (this.ended) return Promise.reject(new ProxyTransportError('upstream closed during SOCKS5 handshake'))
|
|
const remaining = deadline - Date.now()
|
|
if (remaining <= 0) return Promise.reject(timeoutError('SOCKS5 handshake'))
|
|
return new Promise((resolve, reject) => {
|
|
const timer = setTimeout(() => {
|
|
if (this.pending?.timer !== timer) return
|
|
this.pending = null
|
|
reject(timeoutError('SOCKS5 handshake'))
|
|
}, remaining)
|
|
this.pending = { kind: 'exact', size, resolve, reject, timer }
|
|
this.flush()
|
|
})
|
|
}
|
|
|
|
readUntil(delimiter, maxBytes, deadline) {
|
|
if (!Buffer.isBuffer(delimiter) || delimiter.byteLength === 0 || !Number.isSafeInteger(maxBytes) || maxBytes < delimiter.byteLength) {
|
|
return Promise.reject(new ProxyTransportError('proxy handshake boundary is invalid'))
|
|
}
|
|
if (this.pending !== null) return Promise.reject(new ProxyTransportError('concurrent proxy handshake reads are not supported'))
|
|
const boundary = this.buffer.indexOf(delimiter)
|
|
if (boundary >= 0) {
|
|
const size = boundary + delimiter.byteLength
|
|
if (size > maxBytes) return Promise.reject(new ProxyTransportError('upstream response headers are too large', { code: 'UPSTREAM_HEADERS_TOO_LARGE' }))
|
|
const value = this.buffer.subarray(0, size)
|
|
this.buffer = this.buffer.subarray(size)
|
|
return Promise.resolve(value)
|
|
}
|
|
if (this.buffer.byteLength > maxBytes) return Promise.reject(new ProxyTransportError('upstream response headers are too large', { code: 'UPSTREAM_HEADERS_TOO_LARGE' }))
|
|
if (this.failure !== null) return Promise.reject(this.failure)
|
|
if (this.ended) return Promise.reject(new ProxyTransportError('upstream closed during proxy handshake'))
|
|
const remaining = deadline - Date.now()
|
|
if (remaining <= 0) return Promise.reject(timeoutError('proxy handshake'))
|
|
return new Promise((resolve, reject) => {
|
|
const timer = setTimeout(() => {
|
|
if (this.pending?.timer !== timer) return
|
|
this.pending = null
|
|
reject(timeoutError('proxy handshake'))
|
|
}, remaining)
|
|
this.pending = { kind: 'until', delimiter, maxBytes, resolve, reject, timer }
|
|
this.flush()
|
|
})
|
|
}
|
|
|
|
release() {
|
|
if (this.pending !== null) throw new ProxyTransportError('proxy handshake read is still pending')
|
|
this.socket.pause()
|
|
this.socket.off('data', this.onData)
|
|
this.socket.off('end', this.onEnd)
|
|
this.socket.off('error', this.onError)
|
|
const remainder = this.buffer
|
|
this.buffer = Buffer.alloc(0)
|
|
return remainder
|
|
}
|
|
}
|
|
|
|
function socksReplyError(code) {
|
|
const names = new Map([
|
|
[0x01, 'general failure'], [0x02, 'connection not allowed'], [0x03, 'network unreachable'],
|
|
[0x04, 'host unreachable'], [0x05, 'connection refused'], [0x06, 'TTL expired'],
|
|
[0x07, 'command not supported'], [0x08, 'address type not supported'],
|
|
])
|
|
return new ProxyTransportError(`SOCKS5 proxy rejected the connection (${names.get(code) ?? 'unknown error'})`, {
|
|
code: 'UPSTREAM_PROXY_REJECTED',
|
|
})
|
|
}
|
|
|
|
export async function connectThroughSocks5({
|
|
endpoint,
|
|
targetHost,
|
|
targetPort,
|
|
resolver = lookup,
|
|
connectTimeoutMs = PROXY_TIMEOUTS.connectMs,
|
|
handshakeTimeoutMs = PROXY_TIMEOUTS.handshakeMs,
|
|
timeoutMs,
|
|
signal,
|
|
}) {
|
|
const socket = await connectTcp({ ...endpoint, timeoutMs: connectTimeoutMs, signal })
|
|
const cancel = () => socket.destroy(new ProxyTransportError('SOCKS5 handshake was cancelled', {
|
|
code: 'REQUEST_CANCELLED', statusCode: 499,
|
|
}))
|
|
signal?.addEventListener('abort', cancel, { once: true })
|
|
const reader = new SocketByteReader(socket)
|
|
const deadline = Date.now() + (timeoutMs ?? handshakeTimeoutMs)
|
|
try {
|
|
const username = Buffer.from(endpoint.username ?? '', 'utf8')
|
|
const password = Buffer.from(endpoint.password ?? '', 'utf8')
|
|
const authenticated = username.byteLength > 0 || endpoint.password !== null
|
|
if (username.byteLength > 255 || password.byteLength > 255) {
|
|
throw new ProxyTransportError('SOCKS5 credentials are too long', { code: 'SOCKS5_CREDENTIALS_INVALID' })
|
|
}
|
|
socket.write(authenticated ? Buffer.from([0x05, 0x02, 0x00, 0x02]) : Buffer.from([0x05, 0x01, 0x00]))
|
|
const greeting = await reader.read(2, deadline)
|
|
if (greeting[0] !== 0x05) throw new ProxyTransportError('SOCKS5 proxy returned an invalid greeting')
|
|
if (greeting[1] === 0xff) throw new ProxyTransportError('SOCKS5 proxy has no acceptable authentication method', { code: 'UPSTREAM_PROXY_AUTH_FAILED' })
|
|
if (greeting[1] === 0x02) {
|
|
if (!authenticated) throw new ProxyTransportError('SOCKS5 proxy requires authentication', { code: 'UPSTREAM_PROXY_AUTH_FAILED' })
|
|
socket.write(Buffer.concat([
|
|
Buffer.from([0x01, username.byteLength]), username,
|
|
Buffer.from([password.byteLength]), password,
|
|
]))
|
|
const authentication = await reader.read(2, deadline)
|
|
if (authentication[0] !== 0x01 || authentication[1] !== 0x00) {
|
|
throw new ProxyTransportError('SOCKS5 proxy authentication failed', { code: 'UPSTREAM_PROXY_AUTH_FAILED' })
|
|
}
|
|
} else if (greeting[1] !== 0x00) {
|
|
throw new ProxyTransportError('SOCKS5 proxy selected an unsupported authentication method', { code: 'UPSTREAM_PROXY_AUTH_FAILED' })
|
|
}
|
|
const address = await socksTarget(targetHost, endpoint.remoteDns, resolver)
|
|
const port = Buffer.alloc(2)
|
|
port.writeUInt16BE(targetPort)
|
|
socket.write(Buffer.concat([Buffer.from([0x05, 0x01, 0x00]), address, port]))
|
|
const response = await reader.read(4, deadline)
|
|
if (response[0] !== 0x05 || response[2] !== 0x00) throw new ProxyTransportError('SOCKS5 proxy returned an invalid CONNECT response')
|
|
if (response[1] !== 0x00) throw socksReplyError(response[1])
|
|
if (response[3] === 0x01) await reader.read(6, deadline)
|
|
else if (response[3] === 0x04) await reader.read(18, deadline)
|
|
else if (response[3] === 0x03) {
|
|
const length = (await reader.read(1, deadline))[0]
|
|
await reader.read(length + 2, deadline)
|
|
} else throw new ProxyTransportError('SOCKS5 proxy returned an invalid address type')
|
|
const remainder = reader.release()
|
|
return Object.freeze({ socket, remainder })
|
|
} catch (error) {
|
|
if (reader.pending === null) reader.release()
|
|
socket.destroy()
|
|
if (error instanceof ProxyTransportError) throw error
|
|
throw new ProxyTransportError('SOCKS5 proxy handshake failed', { cause: error })
|
|
} finally {
|
|
signal?.removeEventListener('abort', cancel)
|
|
}
|
|
}
|