dsh 0.2.0-rc.2: in-container update (experimental channel), path-agnostic compose, upstream patches adapted to 0.1.x/0.2.x
This commit is contained in:
commit
e6dcef9bf5
302 files changed
+75178
No files matched your search
@@ -0,0 +1,459 @@
|
||||
import { Agent, createServer, request as httpRequest } from 'node:http'
|
||||
import { ProxyDnsCache } from './dns-cache.mjs'
|
||||
import { connectTcp, basicProxyAuthorization, connectThroughHttpProxy, connectThroughSocks5, PROXY_TIMEOUTS, ProxyTransportError } from './transport.mjs'
|
||||
import { selectProxyRoute } from './policy.mjs'
|
||||
import { ProxyRouteHealth } from './route-health.mjs'
|
||||
|
||||
const MAX_HEADER_LINE_BYTES = 64 * 1024
|
||||
const MAX_CONNECT_AUTHORITY_BYTES = 1024
|
||||
const HOP_BY_HOP = new Set([
|
||||
'connection', 'keep-alive', 'proxy-authenticate', 'proxy-authorization',
|
||||
'proxy-connection', 'te', 'trailer', 'transfer-encoding', 'upgrade',
|
||||
])
|
||||
|
||||
function proxyError(response, statusCode, code, message) {
|
||||
if (response.destroyed) return
|
||||
if (response.headersSent) return response.destroy()
|
||||
const body = `${message}\n`
|
||||
response.writeHead(statusCode, {
|
||||
'content-type': 'text/plain; charset=utf-8',
|
||||
'content-length': Buffer.byteLength(body),
|
||||
'x-dsh-proxy-error': code,
|
||||
connection: 'close',
|
||||
...statusCode === 407 ? { 'proxy-authenticate': 'DSH-Provider' } : {},
|
||||
})
|
||||
response.end(body)
|
||||
}
|
||||
|
||||
function socketError(socket, statusCode, code, message) {
|
||||
if (socket.destroyed) return
|
||||
const body = `${message}\n`
|
||||
socket.end([
|
||||
`HTTP/1.1 ${statusCode} ${statusCode === 504 ? 'Gateway Timeout' : statusCode === 431 ? 'Request Header Fields Too Large' : statusCode === 407 ? 'Proxy Authentication Required' : statusCode === 400 ? 'Bad Request' : 'Bad Gateway'}`,
|
||||
'Content-Type: text/plain; charset=utf-8',
|
||||
`Content-Length: ${Buffer.byteLength(body)}`,
|
||||
`X-DSH-Proxy-Error: ${code}`,
|
||||
...statusCode === 407 ? ['Proxy-Authenticate: DSH-Provider'] : [],
|
||||
'Connection: close',
|
||||
'',
|
||||
body,
|
||||
].join('\r\n'))
|
||||
}
|
||||
|
||||
function errorFields(error) {
|
||||
if (error instanceof ProxyTransportError) {
|
||||
return { statusCode: error.statusCode, code: error.code, message: error.message }
|
||||
}
|
||||
return { statusCode: 502, code: 'UPSTREAM_CONNECT_FAILED', message: 'upstream connection failed' }
|
||||
}
|
||||
|
||||
function observeRouteHealth(context, snapshot, outcome) {
|
||||
if (context.getSnapshot().revision === snapshot.revision) {
|
||||
context.routeHealth.observe(snapshot, context.scope, outcome)
|
||||
}
|
||||
}
|
||||
|
||||
function authorizeProvider(context, headers, snapshot) {
|
||||
if (context.scope !== 'modelApi') return undefined
|
||||
try {
|
||||
if (context.providerHandles === undefined) throw new Error('provider handles are unavailable')
|
||||
return context.providerHandles.resolve(headers['proxy-authorization'], snapshot)
|
||||
} catch {
|
||||
throw new ProxyTransportError('provider policy handle is invalid', {
|
||||
code: 'PROVIDER_HANDLE_INVALID', statusCode: 407,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function checkHeaderLimits(request) {
|
||||
if (request.rawHeaders.length / 2 > 256) return false
|
||||
for (let index = 0; index < request.rawHeaders.length; index += 2) {
|
||||
if (Buffer.byteLength(`${request.rawHeaders[index]}: ${request.rawHeaders[index + 1]}`, 'latin1') > MAX_HEADER_LINE_BYTES) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function connectionTokens(headers) {
|
||||
return new Set(String(headers.connection ?? '').split(',').map(value => value.trim().toLowerCase()).filter(Boolean))
|
||||
}
|
||||
|
||||
function forwardHeaders(headers, { upgrade = false, authorization = null, host }) {
|
||||
const tokens = connectionTokens(headers)
|
||||
const result = {}
|
||||
for (const [name, value] of Object.entries(headers)) {
|
||||
const lower = name.toLowerCase()
|
||||
if (HOP_BY_HOP.has(lower) || tokens.has(lower)) continue
|
||||
if (value !== undefined) result[lower] = value
|
||||
}
|
||||
result.host = host
|
||||
if (upgrade) {
|
||||
result.connection = 'Upgrade'
|
||||
result.upgrade = headers.upgrade
|
||||
}
|
||||
if (authorization !== null) result['proxy-authorization'] = authorization
|
||||
return result
|
||||
}
|
||||
|
||||
function responseHeaders(headers) {
|
||||
const tokens = connectionTokens(headers)
|
||||
const result = {}
|
||||
for (const [name, value] of Object.entries(headers)) {
|
||||
const lower = name.toLowerCase()
|
||||
if (HOP_BY_HOP.has(lower) || tokens.has(lower)) continue
|
||||
if (value !== undefined) result[lower] = value
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
function parseAbsoluteTarget(raw) {
|
||||
let target
|
||||
try { target = new URL(raw) } catch { return null }
|
||||
if (target.username !== '' || target.password !== '' || target.hostname === '' || target.hash !== '') return null
|
||||
if (!['http:', 'ws:'].includes(target.protocol)) return Object.freeze({ unsupported: true, url: target })
|
||||
const port = target.port === '' ? 80 : Number(target.port)
|
||||
if (!Number.isInteger(port) || port < 1 || port > 65535) return null
|
||||
const host = target.hostname.startsWith('[') && target.hostname.endsWith(']')
|
||||
? target.hostname.slice(1, -1)
|
||||
: target.hostname
|
||||
return Object.freeze({ url: target, host, port, authority: target.host })
|
||||
}
|
||||
|
||||
function parseAuthority(raw) {
|
||||
if (Buffer.byteLength(raw) > MAX_CONNECT_AUTHORITY_BYTES || raw.includes('/') || raw.includes('@')) return null
|
||||
try {
|
||||
const value = new URL(`tcp://${raw}`)
|
||||
if (value.hostname === '' || value.port === '') return null
|
||||
const port = Number(value.port)
|
||||
if (!Number.isInteger(port) || port < 1 || port > 65535) return null
|
||||
const host = value.hostname.startsWith('[') && value.hostname.endsWith(']')
|
||||
? value.hostname.slice(1, -1)
|
||||
: value.hostname
|
||||
return Object.freeze({ host, port })
|
||||
} catch { return null }
|
||||
}
|
||||
|
||||
function requestPath(target) {
|
||||
return `${target.url.pathname || '/'}${target.url.search}`
|
||||
}
|
||||
|
||||
function idle(socket, timeoutMs) {
|
||||
socket.setTimeout(timeoutMs, () => socket.destroy(new ProxyTransportError('upstream stream timed out', {
|
||||
code: 'UPSTREAM_TIMEOUT', statusCode: 504,
|
||||
})))
|
||||
}
|
||||
|
||||
function bridge(left, right, { leftHead = Buffer.alloc(0), rightHead = Buffer.alloc(0), timeoutMs }) {
|
||||
const close = () => {
|
||||
if (!left.destroyed) left.destroy()
|
||||
if (!right.destroyed) right.destroy()
|
||||
}
|
||||
left.on('error', close)
|
||||
right.on('error', close)
|
||||
idle(left, timeoutMs)
|
||||
idle(right, timeoutMs)
|
||||
if (leftHead.byteLength > 0) right.write(leftHead)
|
||||
if (rightHead.byteLength > 0) left.write(rightHead)
|
||||
left.pipe(right)
|
||||
right.pipe(left)
|
||||
}
|
||||
|
||||
async function tryTargets(targets, connectTarget, { retry } = {}) {
|
||||
let failure
|
||||
for (const target of targets) {
|
||||
try { return await connectTarget(target) } catch (error) {
|
||||
failure = error
|
||||
if (error?.code === 'REQUEST_CANCELLED' || retry?.(error) === false) throw error
|
||||
}
|
||||
}
|
||||
throw failure ?? new ProxyTransportError('no usable target addresses', { code: 'TARGET_DNS_FAILED' })
|
||||
}
|
||||
|
||||
async function routeSocket(route, target, signal, timeouts) {
|
||||
if (route.mode === 'direct') return tryTargets(route.targets ?? [{ address: target.host }], async candidate => Object.freeze({
|
||||
socket: await connectTcp({ host: candidate.address, port: target.port, timeoutMs: timeouts.connectMs, signal }),
|
||||
remainder: Buffer.alloc(0),
|
||||
}))
|
||||
if (route.mode === 'http') return connectThroughHttpProxy({
|
||||
endpoint: route.endpoint,
|
||||
targetHost: target.host,
|
||||
targetPort: target.port,
|
||||
connectTimeoutMs: timeouts.connectMs,
|
||||
handshakeTimeoutMs: timeouts.handshakeMs,
|
||||
signal,
|
||||
})
|
||||
if (route.mode === 'socks5') return tryTargets(route.targets ?? [{ address: target.host }], candidate => connectThroughSocks5({
|
||||
endpoint: route.endpoint,
|
||||
targetHost: candidate.address,
|
||||
targetPort: target.port,
|
||||
connectTimeoutMs: timeouts.connectMs,
|
||||
handshakeTimeoutMs: timeouts.handshakeMs,
|
||||
signal,
|
||||
}), { retry: error => error?.code === 'UPSTREAM_PROXY_REJECTED' })
|
||||
throw new ProxyTransportError('configured proxy protocol is not available', {
|
||||
code: 'PROXY_PROTOCOL_UNAVAILABLE',
|
||||
})
|
||||
}
|
||||
|
||||
async function httpConnection(route, target, signal, timeouts) {
|
||||
if (route.mode === 'http') return Object.freeze({
|
||||
socket: await connectTcp({
|
||||
host: route.endpoint.host,
|
||||
port: route.endpoint.port,
|
||||
timeoutMs: timeouts.connectMs,
|
||||
signal,
|
||||
}),
|
||||
remainder: Buffer.alloc(0),
|
||||
})
|
||||
return routeSocket(route, target, signal, timeouts)
|
||||
}
|
||||
|
||||
function agentKey(route) {
|
||||
const endpoint = route.endpoint === undefined
|
||||
? ''
|
||||
: `${route.endpoint.host}:${route.endpoint.port}:${route.endpoint.username}`
|
||||
return `${route.revision}\u0000${route.mode}\u0000${endpoint}`
|
||||
}
|
||||
|
||||
export class ProxyAgentPool {
|
||||
constructor() {
|
||||
this.entries = new Map()
|
||||
this.currentRevision = null
|
||||
}
|
||||
|
||||
retireOtherRevisions(revision) {
|
||||
if (this.currentRevision === revision) return
|
||||
this.currentRevision = revision
|
||||
for (const [key, entry] of this.entries) {
|
||||
if (entry.revision === revision) continue
|
||||
entry.retired = true
|
||||
for (const sockets of Object.values(entry.agent.freeSockets)) {
|
||||
for (const socket of sockets) socket.destroy()
|
||||
}
|
||||
this.entries.delete(key)
|
||||
}
|
||||
}
|
||||
|
||||
agentFor(route, timeouts = PROXY_TIMEOUTS) {
|
||||
this.retireOtherRevisions(route.revision)
|
||||
const key = agentKey(route)
|
||||
const existing = this.entries.get(key)
|
||||
if (existing !== undefined) return existing.agent
|
||||
const entry = { revision: route.revision, retired: false, agent: null }
|
||||
const agent = new Agent({ keepAlive: true, maxSockets: 32, maxFreeSockets: 8, timeout: timeouts.streamIdleMs })
|
||||
entry.agent = agent
|
||||
agent.createConnection = (options, callback) => {
|
||||
const target = {
|
||||
host: options.dshProxyTargetHost,
|
||||
port: Number(options.dshProxyTargetPort),
|
||||
}
|
||||
void httpConnection(route, target, options.dshProxySignal, timeouts).then(connection => {
|
||||
if (connection.remainder.byteLength > 0) connection.socket.unshift(connection.remainder)
|
||||
connection.socket.resume()
|
||||
callback(null, connection.socket)
|
||||
}, callback)
|
||||
}
|
||||
agent.on('free', socket => {
|
||||
if (entry.retired) socket.destroy()
|
||||
})
|
||||
this.entries.set(key, entry)
|
||||
return agent
|
||||
}
|
||||
|
||||
close() {
|
||||
for (const entry of this.entries.values()) entry.agent.destroy()
|
||||
this.entries.clear()
|
||||
this.currentRevision = null
|
||||
}
|
||||
}
|
||||
|
||||
async function handleHttp(request, response, context) {
|
||||
if (!checkHeaderLimits(request)) return proxyError(response, 431, 'REQUEST_HEADERS_TOO_LARGE', 'request headers are too large')
|
||||
const target = parseAbsoluteTarget(request.url ?? '')
|
||||
if (target === null || target.unsupported === true || target.url.protocol !== 'http:') {
|
||||
const invalid = target === null
|
||||
return proxyError(response, invalid ? 400 : 501, invalid ? 'INVALID_PROXY_REQUEST' : 'UNSUPPORTED_TARGET_SCHEME', invalid ? 'absolute-form HTTP target required' : 'target scheme is not supported')
|
||||
}
|
||||
let upstream
|
||||
let route
|
||||
let snapshot
|
||||
const cancellation = new AbortController()
|
||||
request.once('aborted', () => cancellation.abort())
|
||||
response.once('close', () => {
|
||||
if (!response.writableEnded) cancellation.abort()
|
||||
})
|
||||
try {
|
||||
request.pause()
|
||||
snapshot = context.getSnapshot()
|
||||
const providerId = authorizeProvider(context, request.headers, snapshot)
|
||||
route = await selectProxyRoute({
|
||||
snapshot, scope: context.scope, providerId, host: target.host, port: target.port,
|
||||
dnsCache: context.dnsCache, signal: cancellation.signal,
|
||||
})
|
||||
const proxied = route.mode === 'http'
|
||||
const agent = context.agentPool.agentFor(route, context.timeouts)
|
||||
const headers = forwardHeaders(request.headers, {
|
||||
host: target.authority,
|
||||
authorization: proxied ? basicProxyAuthorization(route.endpoint.username, route.endpoint.password) : null,
|
||||
})
|
||||
upstream = httpRequest({
|
||||
host: proxied ? route.endpoint.host : target.host,
|
||||
port: proxied ? route.endpoint.port : target.port,
|
||||
method: request.method,
|
||||
path: proxied ? target.url.href : requestPath(target),
|
||||
headers,
|
||||
agent,
|
||||
signal: cancellation.signal,
|
||||
maxHeaderSize: 256 * 1024,
|
||||
dshProxyTargetHost: target.host,
|
||||
dshProxyTargetPort: target.port,
|
||||
dshProxySignal: cancellation.signal,
|
||||
})
|
||||
upstream.setTimeout(context.timeouts.responseHeaderMs, () => upstream.destroy(new ProxyTransportError('upstream response timed out', {
|
||||
code: 'UPSTREAM_TIMEOUT', statusCode: 504,
|
||||
})))
|
||||
request.once('aborted', () => upstream.destroy())
|
||||
upstream.once('response', incoming => {
|
||||
if (proxied && incoming.statusCode === 407) {
|
||||
observeRouteHealth(context, snapshot, 'degraded')
|
||||
incoming.socket.destroy()
|
||||
return proxyError(response, 502, 'UPSTREAM_PROXY_AUTH_FAILED', 'upstream proxy authentication failed')
|
||||
}
|
||||
if (route.mode !== 'direct') observeRouteHealth(context, snapshot, 'ready')
|
||||
incoming.socket.setTimeout(context.timeouts.streamIdleMs, () => incoming.destroy(new ProxyTransportError('upstream response timed out', {
|
||||
code: 'UPSTREAM_TIMEOUT', statusCode: 504,
|
||||
})))
|
||||
response.writeHead(incoming.statusCode ?? 502, incoming.statusMessage, responseHeaders(incoming.headers))
|
||||
incoming.pipe(response, { end: false })
|
||||
incoming.once('end', () => {
|
||||
if (Object.keys(incoming.trailers).length > 0) response.addTrailers(incoming.trailers)
|
||||
response.end()
|
||||
})
|
||||
incoming.once('error', error => response.destroy(error))
|
||||
})
|
||||
upstream.once('error', error => {
|
||||
if (route.mode !== 'direct') observeRouteHealth(context, snapshot, 'degraded')
|
||||
const fields = errorFields(error)
|
||||
proxyError(response, fields.statusCode, fields.code, fields.message)
|
||||
})
|
||||
request.pipe(upstream, { end: false })
|
||||
request.once('end', () => {
|
||||
if (Object.keys(request.trailers).length > 0) upstream.addTrailers(request.trailers)
|
||||
upstream.end()
|
||||
})
|
||||
request.resume()
|
||||
} catch (error) {
|
||||
upstream?.destroy()
|
||||
if (route?.mode !== undefined && route.mode !== 'direct') observeRouteHealth(context, snapshot, 'degraded')
|
||||
const fields = errorFields(error)
|
||||
proxyError(response, fields.statusCode, fields.code, fields.message)
|
||||
}
|
||||
}
|
||||
|
||||
function rawRequest(request, target, { absolute, authorization }) {
|
||||
const headers = forwardHeaders(request.headers, {
|
||||
upgrade: true,
|
||||
host: target.authority,
|
||||
authorization,
|
||||
})
|
||||
const start = `${request.method ?? 'GET'} ${absolute ? target.url.href : requestPath(target)} HTTP/${request.httpVersion}`
|
||||
return Buffer.from(`${[start, ...Object.entries(headers).map(([name, value]) => `${name}: ${Array.isArray(value) ? value.join(', ') : value}`), '', ''].join('\r\n')}`, 'latin1')
|
||||
}
|
||||
|
||||
async function handleUpgrade(request, socket, head, context) {
|
||||
socket.on('error', () => {})
|
||||
if (!checkHeaderLimits(request)) return socketError(socket, 431, 'REQUEST_HEADERS_TOO_LARGE', 'request headers are too large')
|
||||
const target = parseAbsoluteTarget(request.url ?? '')
|
||||
if (target === null || target.unsupported === true || target.url.protocol !== 'ws:' || String(request.headers.upgrade ?? '').toLowerCase() !== 'websocket') {
|
||||
const invalid = target === null
|
||||
return socketError(socket, invalid ? 400 : 501, invalid ? 'INVALID_PROXY_REQUEST' : 'UNSUPPORTED_TARGET_SCHEME', invalid ? 'absolute-form WS target required' : 'target scheme is not supported')
|
||||
}
|
||||
const cancellation = new AbortController()
|
||||
let route
|
||||
let snapshot
|
||||
socket.once('close', () => cancellation.abort())
|
||||
try {
|
||||
snapshot = context.getSnapshot()
|
||||
const providerId = authorizeProvider(context, request.headers, snapshot)
|
||||
route = await selectProxyRoute({
|
||||
snapshot, scope: context.scope, providerId, host: target.host, port: target.port,
|
||||
dnsCache: context.dnsCache, signal: cancellation.signal,
|
||||
})
|
||||
if (!['direct', 'http', 'socks5'].includes(route.mode)) throw new ProxyTransportError('configured proxy protocol is not available', { code: 'PROXY_PROTOCOL_UNAVAILABLE' })
|
||||
const connected = await httpConnection(route, target, cancellation.signal, context.timeouts)
|
||||
if (route.mode !== 'direct') observeRouteHealth(context, snapshot, 'ready')
|
||||
const authorization = route.mode === 'http' ? basicProxyAuthorization(route.endpoint.username, route.endpoint.password) : null
|
||||
const preface = rawRequest(request, target, { absolute: route.mode === 'http', authorization })
|
||||
bridge(socket, connected.socket, {
|
||||
leftHead: Buffer.concat([preface, head]),
|
||||
rightHead: connected.remainder,
|
||||
timeoutMs: context.timeouts.streamIdleMs,
|
||||
})
|
||||
} catch (error) {
|
||||
if (route?.mode !== undefined && route.mode !== 'direct') observeRouteHealth(context, snapshot, 'degraded')
|
||||
const fields = errorFields(error)
|
||||
socketError(socket, fields.statusCode, fields.code, fields.message)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleConnect(request, socket, head, context) {
|
||||
socket.on('error', () => {})
|
||||
if (!checkHeaderLimits(request)) return socketError(socket, 431, 'REQUEST_HEADERS_TOO_LARGE', 'request headers are too large')
|
||||
const target = parseAuthority(request.url ?? '')
|
||||
if (target === null) return socketError(socket, 400, 'INVALID_CONNECT_AUTHORITY', 'CONNECT authority is invalid')
|
||||
const cancellation = new AbortController()
|
||||
let route
|
||||
let snapshot
|
||||
socket.once('close', () => cancellation.abort())
|
||||
try {
|
||||
snapshot = context.getSnapshot()
|
||||
const providerId = authorizeProvider(context, request.headers, snapshot)
|
||||
route = await selectProxyRoute({
|
||||
snapshot, scope: context.scope, providerId, host: target.host, port: target.port,
|
||||
dnsCache: context.dnsCache, signal: cancellation.signal,
|
||||
})
|
||||
const connected = await routeSocket(route, target, cancellation.signal, context.timeouts)
|
||||
if (route.mode !== 'direct') observeRouteHealth(context, snapshot, 'ready')
|
||||
socket.write('HTTP/1.1 200 Connection Established\r\n\r\n')
|
||||
bridge(socket, connected.socket, {
|
||||
leftHead: head,
|
||||
rightHead: connected.remainder,
|
||||
timeoutMs: context.timeouts.tunnelIdleMs,
|
||||
})
|
||||
} catch (error) {
|
||||
if (route?.mode !== undefined && route.mode !== 'direct') observeRouteHealth(context, snapshot, 'degraded')
|
||||
const fields = errorFields(error)
|
||||
socketError(socket, fields.statusCode, fields.code, fields.message)
|
||||
}
|
||||
}
|
||||
|
||||
export function createScopedProxyServer({
|
||||
scope,
|
||||
getSnapshot,
|
||||
resolver,
|
||||
dnsCache = new ProxyDnsCache({ resolver }),
|
||||
agentPool = new ProxyAgentPool(),
|
||||
routeHealth = new ProxyRouteHealth(),
|
||||
providerHandles,
|
||||
timeouts = PROXY_TIMEOUTS,
|
||||
}) {
|
||||
const context = Object.freeze({
|
||||
scope,
|
||||
getSnapshot,
|
||||
dnsCache,
|
||||
agentPool,
|
||||
routeHealth,
|
||||
providerHandles,
|
||||
timeouts: Object.freeze({ ...PROXY_TIMEOUTS, ...timeouts }),
|
||||
})
|
||||
const server = createServer({ maxHeaderSize: 256 * 1024, allowHalfOpen: true }, (request, response) => {
|
||||
void handleHttp(request, response, context)
|
||||
})
|
||||
server.maxHeadersCount = 256
|
||||
server.on('connect', (request, socket, head) => { void handleConnect(request, socket, head, context) })
|
||||
server.on('upgrade', (request, socket, head) => { void handleUpgrade(request, socket, head, context) })
|
||||
server.on('clientError', (error, socket) => {
|
||||
const overflow = error?.code === 'HPE_HEADER_OVERFLOW'
|
||||
socketError(socket, overflow ? 431 : 400, overflow ? 'REQUEST_HEADERS_TOO_LARGE' : 'INVALID_PROXY_REQUEST', overflow ? 'request headers are too large' : 'proxy request is invalid')
|
||||
})
|
||||
return server
|
||||
}
|
||||
Reference in new issue
Block a user