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,185 @@
|
||||
import { isIP } from 'node:net'
|
||||
import { domainToASCII } from 'node:url'
|
||||
import { ProxyConfigurationError } from './errors.mjs'
|
||||
import { normalizeProxyRules, PLATFORM_NO_PROXY } from './rules.mjs'
|
||||
import { OUTBOUND_PROXY_PORTS } from '../../../../platform/lib/outbound-proxy.mjs'
|
||||
|
||||
export const PROXY_SCHEMA = 1
|
||||
export const PROXY_SCOPES = Object.freeze([
|
||||
'updates', 'platform', 'dshCore', 'dshPlugins', 'agentNetwork', 'managementTerminal',
|
||||
])
|
||||
export const PROXY_PORTS = OUTBOUND_PROXY_PORTS
|
||||
|
||||
function deepFreeze(value) {
|
||||
if (value !== null && typeof value === 'object' && !Object.isFrozen(value)) {
|
||||
for (const child of Object.values(value)) deepFreeze(child)
|
||||
Object.freeze(value)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
function object(value, label) {
|
||||
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw new ProxyConfigurationError(`${label} must be an object`)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
function allowedKeys(value, allowed, label) {
|
||||
const extras = Object.keys(value).filter(key => !allowed.includes(key))
|
||||
if (extras.length > 0) throw new ProxyConfigurationError(`${label} contains unsupported fields: ${extras.sort().join(', ')}`)
|
||||
}
|
||||
|
||||
function requiredKeys(value, required, label) {
|
||||
const missing = required.filter(key => !(key in value))
|
||||
if (missing.length > 0) throw new ProxyConfigurationError(`${label} is missing fields: ${missing.join(', ')}`)
|
||||
}
|
||||
|
||||
function normalizeProxyHost(value) {
|
||||
if (typeof value !== 'string' || value !== value.trim() || /[\u0000-\u0020\u007f\/@?#]/.test(value)) {
|
||||
throw new ProxyConfigurationError('proxy host is invalid')
|
||||
}
|
||||
if (value === '') return ''
|
||||
if (isIP(value) !== 0) return value.toLowerCase()
|
||||
const ascii = domainToASCII(value.toLowerCase().replace(/\.$/, ''))
|
||||
if (ascii === '' || ascii.length > 253 || ascii.split('.').some(part => part === '' || part.length > 63)) {
|
||||
throw new ProxyConfigurationError('proxy host is invalid')
|
||||
}
|
||||
return ascii
|
||||
}
|
||||
|
||||
function normalizeScopes(value) {
|
||||
object(value, 'proxy scopes')
|
||||
requiredKeys(value, PROXY_SCOPES, 'proxy scopes')
|
||||
allowedKeys(value, PROXY_SCOPES, 'proxy scopes')
|
||||
return Object.fromEntries(PROXY_SCOPES.map(scope => {
|
||||
if (typeof value[scope] !== 'boolean') throw new ProxyConfigurationError(`proxy scope ${scope} must be boolean`)
|
||||
return [scope, value[scope]]
|
||||
}))
|
||||
}
|
||||
|
||||
function normalizeProviderPolicy(value, label) {
|
||||
object(value, label)
|
||||
requiredKeys(value, ['proxyEnabled'], label)
|
||||
allowedKeys(value, ['proxyEnabled'], label)
|
||||
if (typeof value.proxyEnabled !== 'boolean') throw new ProxyConfigurationError(`${label} proxyEnabled must be boolean`)
|
||||
return { proxyEnabled: value.proxyEnabled }
|
||||
}
|
||||
|
||||
export function validProviderId(value) {
|
||||
return typeof value === 'string' && /^[a-z0-9][a-z0-9._-]{0,127}$/.test(value)
|
||||
}
|
||||
|
||||
function normalizeModelApi(value) {
|
||||
object(value, 'model API proxy policy')
|
||||
requiredKeys(value, ['default', 'providers'], 'model API proxy policy')
|
||||
allowedKeys(value, ['default', 'providers'], 'model API proxy policy')
|
||||
object(value.providers, 'model API provider policies')
|
||||
const providers = {}
|
||||
for (const [id, policy] of Object.entries(value.providers)) {
|
||||
if (!validProviderId(id)) throw new ProxyConfigurationError('model API provider ID is invalid')
|
||||
providers[id] = normalizeProviderPolicy(policy, `model API provider ${id}`)
|
||||
}
|
||||
return { default: normalizeProviderPolicy(value.default, 'model API default'), providers }
|
||||
}
|
||||
|
||||
export function defaultProxyConfiguration() {
|
||||
return Object.freeze({
|
||||
schema: PROXY_SCHEMA,
|
||||
enabled: false,
|
||||
proxy: Object.freeze({
|
||||
protocol: 'http', host: '', port: null, username: '', passwordConfigured: false, remoteDns: true,
|
||||
}),
|
||||
scopes: Object.freeze(Object.fromEntries(PROXY_SCOPES.map(scope => [scope, false]))),
|
||||
environment: Object.freeze({ allProxy: null }),
|
||||
modelApi: Object.freeze({
|
||||
default: Object.freeze({ proxyEnabled: false }),
|
||||
providers: Object.freeze({}),
|
||||
}),
|
||||
noProxy: Object.freeze({ system: PLATFORM_NO_PROXY, user: Object.freeze([]) }),
|
||||
bypass: Object.freeze({ additional: Object.freeze([]) }),
|
||||
})
|
||||
}
|
||||
|
||||
export function validateProxyConfiguration(value, { existingPassword = null } = {}) {
|
||||
object(value, 'proxy configuration')
|
||||
requiredKeys(value, ['schema', 'enabled', 'proxy', 'scopes', 'environment', 'modelApi', 'noProxy', 'bypass'], 'proxy configuration')
|
||||
allowedKeys(value, ['schema', 'enabled', 'proxy', 'scopes', 'environment', 'modelApi', 'noProxy', 'bypass'], 'proxy configuration')
|
||||
if (value.schema !== PROXY_SCHEMA) throw new ProxyConfigurationError('proxy configuration schema must be 1')
|
||||
if (typeof value.enabled !== 'boolean') throw new ProxyConfigurationError('proxy enabled must be boolean')
|
||||
|
||||
const proxy = object(value.proxy, 'proxy endpoint')
|
||||
requiredKeys(proxy, ['protocol', 'host', 'port', 'username', 'remoteDns'], 'proxy endpoint')
|
||||
allowedKeys(proxy, ['protocol', 'host', 'port', 'username', 'password', 'clearPassword', 'passwordConfigured', 'remoteDns'], 'proxy endpoint')
|
||||
if (!['http', 'socks5'].includes(proxy.protocol)) throw new ProxyConfigurationError('proxy protocol must be http or socks5')
|
||||
const host = normalizeProxyHost(proxy.host)
|
||||
const port = proxy.port === null ? null : Number(proxy.port)
|
||||
if (port !== null && (!Number.isSafeInteger(port) || port < 1 || port > 65535)) throw new ProxyConfigurationError('proxy port is invalid')
|
||||
if (value.enabled && (host === '' || port === null)) throw new ProxyConfigurationError('enabled proxy requires a host and port')
|
||||
if ((host === '') !== (port === null)) throw new ProxyConfigurationError('proxy host and port must be configured together')
|
||||
if (typeof proxy.username !== 'string' || Buffer.byteLength(proxy.username, 'utf8') > 255 || /[\u0000-\u001f\u007f]/.test(proxy.username)) {
|
||||
throw new ProxyConfigurationError('proxy username is invalid')
|
||||
}
|
||||
if (typeof proxy.remoteDns !== 'boolean') throw new ProxyConfigurationError('proxy remoteDns must be boolean')
|
||||
if (proxy.clearPassword === true && proxy.password !== undefined && proxy.password !== null) {
|
||||
throw new ProxyConfigurationError('proxy password cannot be set and cleared together')
|
||||
}
|
||||
let password = existingPassword
|
||||
if (proxy.clearPassword === true) password = null
|
||||
else if (proxy.password !== undefined && proxy.password !== null) {
|
||||
if (typeof proxy.password !== 'string' || proxy.password === '' || Buffer.byteLength(proxy.password, 'utf8') > 255 || /[\u0000-\u001f\u007f]/.test(proxy.password)) {
|
||||
throw new ProxyConfigurationError('proxy password is invalid')
|
||||
}
|
||||
password = proxy.password
|
||||
}
|
||||
|
||||
const environment = object(value.environment, 'proxy environment')
|
||||
requiredKeys(environment, ['allProxy'], 'proxy environment')
|
||||
allowedKeys(environment, ['allProxy'], 'proxy environment')
|
||||
if (environment.allProxy !== null && environment.allProxy !== 'scope-proxy') {
|
||||
throw new ProxyConfigurationError('proxy environment allProxy must be null or scope-proxy')
|
||||
}
|
||||
const noProxy = object(value.noProxy, 'NO_PROXY')
|
||||
requiredKeys(noProxy, ['user'], 'NO_PROXY')
|
||||
allowedKeys(noProxy, ['user', 'system'], 'NO_PROXY')
|
||||
const bypass = object(value.bypass, 'proxy bypass')
|
||||
requiredKeys(bypass, ['additional'], 'proxy bypass')
|
||||
allowedKeys(bypass, ['additional'], 'proxy bypass')
|
||||
|
||||
const normalized = {
|
||||
schema: PROXY_SCHEMA,
|
||||
enabled: value.enabled,
|
||||
proxy: {
|
||||
protocol: proxy.protocol,
|
||||
host,
|
||||
port,
|
||||
username: proxy.username,
|
||||
passwordConfigured: password !== null,
|
||||
remoteDns: proxy.remoteDns,
|
||||
},
|
||||
scopes: normalizeScopes(value.scopes),
|
||||
environment: { allProxy: environment.allProxy },
|
||||
modelApi: normalizeModelApi(value.modelApi),
|
||||
noProxy: {
|
||||
system: PLATFORM_NO_PROXY,
|
||||
user: normalizeProxyRules(noProxy.user, { allowWildcard: true, label: 'NO_PROXY' }).map(rule => rule.value),
|
||||
},
|
||||
bypass: {
|
||||
additional: normalizeProxyRules(bypass.additional, { allowCidr: true, label: 'proxy bypass' }).map(rule => rule.value),
|
||||
},
|
||||
}
|
||||
return Object.freeze({
|
||||
configuration: deepFreeze(normalized),
|
||||
credentials: deepFreeze({ username: proxy.username, password }),
|
||||
})
|
||||
}
|
||||
|
||||
export function sanitizedProxyConfiguration(configuration, revision, state = {}) {
|
||||
return Object.freeze({
|
||||
...configuration,
|
||||
revision,
|
||||
componentReady: state.componentReady === true,
|
||||
routeHealth: state.routeHealth ?? Object.fromEntries([...PROXY_SCOPES, 'modelApi', 'sharedDsh'].map(scope => [scope, 'unknown'])),
|
||||
lastTest: state.lastTest ?? null,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import { createServer } from 'node:http'
|
||||
import { outboundProxyEnvironment, outboundProxyScopeEnabled } from '../../../../platform/lib/outbound-proxy.mjs'
|
||||
import { sanitizedProxyConfiguration } from './contracts.mjs'
|
||||
import { ProxyConfigurationError, proxyErrorBody } from './errors.mjs'
|
||||
|
||||
const MAX_CONTROL_BODY_BYTES = 64 * 1024
|
||||
const MAX_PROVIDER_HANDLE_BODY_BYTES = 4096
|
||||
const TEST_TASK_ROUTE = /^\/v1\/test\/tasks\/([0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})$/
|
||||
|
||||
async function jsonBody(request, maximum = MAX_CONTROL_BODY_BYTES) {
|
||||
const chunks = []
|
||||
let total = 0
|
||||
for await (const chunk of request) {
|
||||
total += chunk.byteLength
|
||||
if (total > maximum) throw new ProxyConfigurationError('control request body is too large')
|
||||
chunks.push(chunk)
|
||||
}
|
||||
try { return JSON.parse(Buffer.concat(chunks).toString('utf8')) } catch {
|
||||
throw new ProxyConfigurationError('control request body is invalid')
|
||||
}
|
||||
}
|
||||
|
||||
function send(response, status, value) {
|
||||
response.writeHead(status, { 'content-type': 'application/json; charset=utf-8' })
|
||||
response.end(`${JSON.stringify(value)}\n`)
|
||||
}
|
||||
|
||||
function configurationView(snapshot, routeHealth, state = {}) {
|
||||
return sanitizedProxyConfiguration(snapshot.configuration, snapshot.revision, {
|
||||
componentReady: true,
|
||||
routeHealth: routeHealth.status(snapshot),
|
||||
lastTest: state.lastTest ?? null,
|
||||
})
|
||||
}
|
||||
|
||||
export function createOutboundProxyControl({
|
||||
getSnapshot,
|
||||
routeHealth,
|
||||
providerHandles,
|
||||
proxyTests,
|
||||
commitConfiguration,
|
||||
getTestState = () => ({ lastTest: null }),
|
||||
}) {
|
||||
return createServer(async (request, response) => {
|
||||
const url = new URL(request.url ?? '/', 'http://outbound-proxy.internal')
|
||||
const snapshot = getSnapshot()
|
||||
if (request.method === 'GET' && url.pathname === '/v1/status') {
|
||||
send(response, 200, {
|
||||
componentReady: true,
|
||||
revision: snapshot.revision,
|
||||
recovery: snapshot.recovery,
|
||||
routeHealth: routeHealth.status(snapshot),
|
||||
})
|
||||
return
|
||||
}
|
||||
if (request.method === 'GET' && url.pathname === '/v1/configuration') {
|
||||
send(response, 200, configurationView(snapshot, routeHealth, getTestState()))
|
||||
return
|
||||
}
|
||||
if (request.method === 'PUT' && url.pathname === '/v1/configuration') {
|
||||
try {
|
||||
if (commitConfiguration === undefined) throw new Error('proxy configuration updates are unavailable')
|
||||
const body = await jsonBody(request)
|
||||
if (body === null || typeof body !== 'object' || Array.isArray(body)
|
||||
|| Object.keys(body).sort().join(',') !== 'baseRevision,value') {
|
||||
throw new ProxyConfigurationError('proxy configuration update is invalid')
|
||||
}
|
||||
const activated = await commitConfiguration(body)
|
||||
providerHandles?.clear()
|
||||
send(response, 200, configurationView(activated, routeHealth, getTestState()))
|
||||
} catch (error) {
|
||||
send(response, error?.statusCode ?? 400, proxyErrorBody(error))
|
||||
}
|
||||
return
|
||||
}
|
||||
if (request.method === 'POST' && url.pathname === '/v1/test') {
|
||||
try {
|
||||
if (proxyTests === undefined) throw new Error('proxy connection tests are unavailable')
|
||||
send(response, 202, await proxyTests.start(await jsonBody(request), snapshot))
|
||||
} catch (error) {
|
||||
send(response, error?.statusCode ?? 400, proxyErrorBody(error))
|
||||
}
|
||||
return
|
||||
}
|
||||
if (request.method === 'GET' && TEST_TASK_ROUTE.test(url.pathname)) {
|
||||
try {
|
||||
if (proxyTests === undefined) throw new Error('proxy connection tests are unavailable')
|
||||
send(response, 200, proxyTests.get(TEST_TASK_ROUTE.exec(url.pathname)[1]))
|
||||
} catch (error) {
|
||||
send(response, error?.statusCode ?? 400, proxyErrorBody(error))
|
||||
}
|
||||
return
|
||||
}
|
||||
if (request.method === 'DELETE' && TEST_TASK_ROUTE.test(url.pathname)) {
|
||||
try {
|
||||
if (proxyTests === undefined) throw new Error('proxy connection tests are unavailable')
|
||||
send(response, 202, await proxyTests.cancel(TEST_TASK_ROUTE.exec(url.pathname)[1]))
|
||||
} catch (error) {
|
||||
send(response, error?.statusCode ?? 400, proxyErrorBody(error))
|
||||
}
|
||||
return
|
||||
}
|
||||
if (request.method === 'GET' && url.pathname === '/v1/environment') {
|
||||
try {
|
||||
const scope = url.searchParams.get('scope')
|
||||
const environment = outboundProxyEnvironment(scope, {
|
||||
noProxy: [...snapshot.configuration.noProxy.system, ...snapshot.configuration.noProxy.user],
|
||||
allProxy: snapshot.configuration.environment.allProxy === 'scope-proxy',
|
||||
enabled: outboundProxyScopeEnabled(snapshot.configuration, scope),
|
||||
})
|
||||
send(response, 200, { scope, revision: snapshot.revision, environment })
|
||||
} catch (error) {
|
||||
send(response, 400, { error: error instanceof Error ? error.message : 'invalid scope' })
|
||||
}
|
||||
return
|
||||
}
|
||||
if (request.method === 'POST' && url.pathname === '/v1/provider-handles') {
|
||||
try {
|
||||
if (providerHandles === undefined) throw new Error('provider handles are unavailable')
|
||||
send(response, 201, providerHandles.issue(await jsonBody(request, MAX_PROVIDER_HANDLE_BODY_BYTES), snapshot))
|
||||
} catch (error) {
|
||||
send(response, error?.statusCode ?? 400, proxyErrorBody(error))
|
||||
}
|
||||
return
|
||||
}
|
||||
send(response, 404, { error: 'not found' })
|
||||
})
|
||||
}
|
||||
|
||||
export const outboundProxyControlInternals = Object.freeze({ configurationView })
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { lookup } from 'node:dns/promises'
|
||||
import { isIP } from 'node:net'
|
||||
import { domainToASCII } from 'node:url'
|
||||
import { ProxyTransportError } from './transport.mjs'
|
||||
|
||||
const POSITIVE_TTL_MS = 60_000
|
||||
const NEGATIVE_TTL_MS = 10_000
|
||||
|
||||
function normalizeHost(value) {
|
||||
if (isIP(value) !== 0) return value.toLowerCase()
|
||||
const ascii = domainToASCII(String(value).toLowerCase().replace(/\.+$/, ''))
|
||||
if (ascii === '') throw new ProxyTransportError('target DNS name is invalid', { code: 'TARGET_DNS_FAILED' })
|
||||
return ascii
|
||||
}
|
||||
|
||||
function normalizeRecords(value) {
|
||||
if (!Array.isArray(value)) value = [value]
|
||||
const records = new Map()
|
||||
for (const record of value) {
|
||||
const address = typeof record === 'string' ? record : record?.address
|
||||
const family = typeof record === 'object' ? Number(record?.family) : isIP(address)
|
||||
if (typeof address !== 'string' || ![4, 6].includes(family) || isIP(address) !== family) continue
|
||||
const ttl = typeof record?.ttl === 'number' && Number.isFinite(record.ttl)
|
||||
? Math.max(0, Math.min(60, record.ttl))
|
||||
: 60
|
||||
records.set(`${family}:${address}`, Object.freeze({ address, family, ttl }))
|
||||
}
|
||||
return Object.freeze([...records.values()])
|
||||
}
|
||||
|
||||
export class ProxyDnsCache {
|
||||
constructor({ resolver = lookup, now = Date.now, positiveTtlMs = POSITIVE_TTL_MS, negativeTtlMs = NEGATIVE_TTL_MS } = {}) {
|
||||
this.resolver = resolver
|
||||
this.now = now
|
||||
this.positiveTtlMs = positiveTtlMs
|
||||
this.negativeTtlMs = negativeTtlMs
|
||||
this.entries = new Map()
|
||||
}
|
||||
|
||||
async resolve(host, revision, { signal } = {}) {
|
||||
const normalized = normalizeHost(host)
|
||||
const type = isIP(normalized)
|
||||
if (type !== 0) return Object.freeze([{ address: normalized, family: type, ttl: 60 }])
|
||||
const key = `${revision}\u0000${normalized}`
|
||||
const cached = this.entries.get(key)
|
||||
if (cached !== undefined && cached.expiresAt > this.now()) {
|
||||
if (cached.error !== null) throw cached.error
|
||||
return cached.records
|
||||
}
|
||||
if (signal?.aborted) throw new ProxyTransportError('target DNS resolution was cancelled', { code: 'REQUEST_CANCELLED', statusCode: 499 })
|
||||
let records
|
||||
try {
|
||||
if (signal === undefined) records = await this.resolver(normalized, { all: true, verbatim: true })
|
||||
else records = await new Promise((resolve, reject) => {
|
||||
const cancelled = () => reject(new ProxyTransportError('target DNS resolution was cancelled', {
|
||||
code: 'REQUEST_CANCELLED', statusCode: 499,
|
||||
}))
|
||||
signal.addEventListener('abort', cancelled, { once: true })
|
||||
this.resolver(normalized, { all: true, verbatim: true }).then(resolve, reject).finally(() => {
|
||||
signal.removeEventListener('abort', cancelled)
|
||||
})
|
||||
})
|
||||
records = normalizeRecords(records)
|
||||
if (records.length === 0) throw new Error('resolver returned no usable addresses')
|
||||
} catch (cause) {
|
||||
if (cause instanceof ProxyTransportError) throw cause
|
||||
const error = new ProxyTransportError('target DNS resolution failed', { code: 'TARGET_DNS_FAILED', cause })
|
||||
this.entries.set(key, Object.freeze({ records: null, error, expiresAt: this.now() + this.negativeTtlMs }))
|
||||
throw error
|
||||
}
|
||||
const recordTtlMs = Math.min(...records.map(record => record.ttl * 1000), this.positiveTtlMs)
|
||||
this.entries.set(key, Object.freeze({ records, error: null, expiresAt: this.now() + recordTtlMs }))
|
||||
if (this.entries.size > 512) {
|
||||
const now = this.now()
|
||||
for (const [entryKey, entry] of this.entries) if (entry.expiresAt <= now) this.entries.delete(entryKey)
|
||||
while (this.entries.size > 512) this.entries.delete(this.entries.keys().next().value)
|
||||
}
|
||||
return records
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
export class ProxyConfigurationError extends Error {
|
||||
constructor(message, {
|
||||
code = 'INVALID_CONFIG',
|
||||
statusCode = 400,
|
||||
stage = 'validate',
|
||||
retryable = false,
|
||||
} = {}) {
|
||||
super(message)
|
||||
this.name = 'ProxyConfigurationError'
|
||||
this.code = code
|
||||
this.statusCode = statusCode
|
||||
this.stage = stage
|
||||
this.retryable = retryable
|
||||
}
|
||||
}
|
||||
|
||||
export function proxyErrorBody(error) {
|
||||
return Object.freeze({
|
||||
error: Object.freeze({
|
||||
code: error?.code ?? 'PROXY_MANAGER_UNAVAILABLE',
|
||||
message: error?.message ?? 'proxy manager is unavailable',
|
||||
stage: error?.stage ?? 'unknown',
|
||||
retryable: error?.retryable === true,
|
||||
}),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { isIP } from 'node:net'
|
||||
import { matchesProxyRules, normalizeProxyRules } from './rules.mjs'
|
||||
import { outboundProxyScopeEnabled } from '../../../../platform/lib/outbound-proxy.mjs'
|
||||
|
||||
const PLATFORM_RULES = normalizeProxyRules(['localhost', '127.0.0.1', '::1'], { label: 'platform NO_PROXY' })
|
||||
|
||||
function route(mode, reason, snapshot, fields = {}) {
|
||||
return Object.freeze({ mode, reason, revision: snapshot.revision, ...fields })
|
||||
}
|
||||
|
||||
export async function selectProxyRoute({ snapshot, scope, providerId, host, port, dnsCache, signal }) {
|
||||
const configuration = snapshot.configuration
|
||||
const userNoProxy = normalizeProxyRules(configuration.noProxy.user, {
|
||||
allowWildcard: true,
|
||||
label: 'NO_PROXY',
|
||||
})
|
||||
const bypass = normalizeProxyRules(configuration.bypass.additional, {
|
||||
allowCidr: true,
|
||||
label: 'proxy bypass',
|
||||
})
|
||||
const bypassHosts = bypass.filter(rule => rule.type !== 'cidr')
|
||||
const bypassCidrs = bypass.filter(rule => rule.type === 'cidr')
|
||||
if (matchesProxyRules(PLATFORM_RULES, host, port)) return route('direct', 'platform-forced-direct', snapshot)
|
||||
if (matchesProxyRules(userNoProxy, host, port)) return route('direct', 'no-proxy', snapshot)
|
||||
if (matchesProxyRules(bypassHosts, host, port)) return route('direct', 'bypass', snapshot)
|
||||
if (isIP(host) !== 0 && matchesProxyRules(bypassCidrs, host, port)) return route('direct', 'bypass', snapshot)
|
||||
if (!outboundProxyScopeEnabled(configuration, scope, providerId)) {
|
||||
return route('direct', scope === 'modelApi' ? 'provider-direct' : 'scope-direct', snapshot)
|
||||
}
|
||||
const endpoint = Object.freeze({
|
||||
host: configuration.proxy.host,
|
||||
port: configuration.proxy.port,
|
||||
remoteDns: configuration.proxy.remoteDns,
|
||||
username: snapshot.credentials.username,
|
||||
password: snapshot.credentials.password,
|
||||
})
|
||||
if (configuration.proxy.protocol !== 'socks5' || configuration.proxy.remoteDns || isIP(host) !== 0) {
|
||||
const reason = scope === 'modelApi' ? 'provider-proxy' : 'scope-proxy'
|
||||
return route(configuration.proxy.protocol, reason, snapshot, { endpoint })
|
||||
}
|
||||
if (dnsCache === undefined) throw new TypeError('SOCKS5 local DNS requires a ProxyDnsCache')
|
||||
const targets = await dnsCache.resolve(host, snapshot.revision, { signal })
|
||||
const directTargets = targets.filter(target => matchesProxyRules(bypassCidrs, target.address, port))
|
||||
if (directTargets.length > 0) return route('direct', 'bypass-cidr', snapshot, { targets: Object.freeze(directTargets) })
|
||||
const reason = scope === 'modelApi' ? 'provider-proxy' : 'scope-proxy'
|
||||
return route('socks5', reason, snapshot, { endpoint, targets })
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { randomBytes } from 'node:crypto'
|
||||
import { ProxyConfigurationError } from './errors.mjs'
|
||||
import { validProviderId } from './contracts.mjs'
|
||||
|
||||
const HANDLE_BYTES = 32
|
||||
const HANDLE_PATTERN = /^[A-Za-z0-9_-]{43}$/
|
||||
export const PROVIDER_HANDLE_TTL_MS = 15 * 60 * 1000
|
||||
|
||||
function invalidHandle() {
|
||||
return new ProxyConfigurationError('provider policy handle is invalid', {
|
||||
code: 'PROVIDER_HANDLE_INVALID', statusCode: 407, stage: 'authorize', retryable: true,
|
||||
})
|
||||
}
|
||||
|
||||
export class ProviderHandleStore {
|
||||
constructor({ ttlMs = PROVIDER_HANDLE_TTL_MS, now = () => Date.now(), random = randomBytes } = {}) {
|
||||
if (!Number.isSafeInteger(ttlMs) || ttlMs < 1) throw new TypeError('provider handle TTL is invalid')
|
||||
this.ttlMs = ttlMs
|
||||
this.now = now
|
||||
this.random = random
|
||||
this.handles = new Map()
|
||||
}
|
||||
|
||||
issue({ providerId, policyRevision }, snapshot) {
|
||||
if (!validProviderId(providerId)) throw new ProxyConfigurationError('model API provider ID is invalid')
|
||||
if (typeof policyRevision !== 'string' || policyRevision !== snapshot.revision) {
|
||||
throw new ProxyConfigurationError('provider policy revision changed', {
|
||||
code: 'REVISION_CONFLICT', statusCode: 409, stage: 'authorize', retryable: true,
|
||||
})
|
||||
}
|
||||
this.prune()
|
||||
const handle = this.random(HANDLE_BYTES).toString('base64url')
|
||||
if (!HANDLE_PATTERN.test(handle) || this.handles.has(handle)) throw new Error('provider handle generation failed')
|
||||
const expiresAtMs = this.now() + this.ttlMs
|
||||
this.handles.set(handle, Object.freeze({ providerId, policyRevision, expiresAtMs }))
|
||||
return Object.freeze({ handle, policyRevision, expiresAt: new Date(expiresAtMs).toISOString() })
|
||||
}
|
||||
|
||||
resolve(authorization, snapshot) {
|
||||
const match = typeof authorization === 'string' ? /^DSH-Provider ([A-Za-z0-9_-]{43})$/.exec(authorization) : null
|
||||
if (match === null) throw invalidHandle()
|
||||
const entry = this.handles.get(match[1])
|
||||
if (entry === undefined || entry.expiresAtMs <= this.now() || entry.policyRevision !== snapshot.revision) {
|
||||
if (entry !== undefined) this.handles.delete(match[1])
|
||||
throw invalidHandle()
|
||||
}
|
||||
return entry.providerId
|
||||
}
|
||||
|
||||
prune() {
|
||||
const now = this.now()
|
||||
for (const [handle, entry] of this.handles) {
|
||||
if (entry.expiresAtMs <= now) this.handles.delete(handle)
|
||||
}
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.handles.clear()
|
||||
}
|
||||
}
|
||||
|
||||
export const providerHandleInternals = Object.freeze({ HANDLE_BYTES, HANDLE_PATTERN })
|
||||
@@ -0,0 +1,29 @@
|
||||
import { request } from 'node:http'
|
||||
import { ProxyTransportError } from './transport.mjs'
|
||||
|
||||
export async function probeProxyEntry(port, { timeoutMs = 2_000 } = {}) {
|
||||
await new Promise((resolve, reject) => {
|
||||
const probe = request({
|
||||
host: '127.0.0.1',
|
||||
port,
|
||||
method: 'GET',
|
||||
path: '/__dsh_outbound_proxy_readiness__',
|
||||
headers: { connection: 'close' },
|
||||
agent: false,
|
||||
}, response => {
|
||||
response.resume()
|
||||
response.once('end', () => {
|
||||
if (response.statusCode === 400 && response.headers['x-dsh-proxy-error'] === 'INVALID_PROXY_REQUEST') resolve()
|
||||
else reject(new ProxyTransportError('outbound proxy readiness response is invalid', {
|
||||
code: 'PROXY_READINESS_FAILED',
|
||||
}))
|
||||
})
|
||||
})
|
||||
const timer = setTimeout(() => probe.destroy(new ProxyTransportError('outbound proxy readiness timed out', {
|
||||
code: 'PROXY_READINESS_FAILED',
|
||||
})), timeoutMs)
|
||||
probe.once('close', () => clearTimeout(timer))
|
||||
probe.once('error', reject)
|
||||
probe.end()
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { PROXY_PORTS } from './contracts.mjs'
|
||||
|
||||
function enabled(configuration, scope) {
|
||||
if (!configuration.enabled) return false
|
||||
if (scope === 'sharedDsh') return configuration.scopes.dshCore || configuration.scopes.dshPlugins
|
||||
if (scope === 'modelApi') {
|
||||
return configuration.modelApi.default.proxyEnabled
|
||||
|| Object.values(configuration.modelApi.providers).some(policy => policy.proxyEnabled)
|
||||
}
|
||||
return configuration.scopes[scope] === true
|
||||
}
|
||||
|
||||
export class ProxyRouteHealth {
|
||||
constructor() {
|
||||
this.revision = null
|
||||
this.routes = new Map()
|
||||
}
|
||||
|
||||
observe(snapshot, scope, outcome) {
|
||||
if (snapshot.revision !== this.revision) {
|
||||
this.revision = snapshot.revision
|
||||
this.routes.clear()
|
||||
}
|
||||
if (!['ready', 'degraded'].includes(outcome)) throw new TypeError('proxy route health outcome is invalid')
|
||||
this.routes.set(scope, outcome)
|
||||
}
|
||||
|
||||
status(snapshot) {
|
||||
if (snapshot.revision !== this.revision) {
|
||||
this.revision = snapshot.revision
|
||||
this.routes.clear()
|
||||
}
|
||||
return Object.freeze(Object.fromEntries(Object.keys(PROXY_PORTS).map(scope => [
|
||||
scope,
|
||||
enabled(snapshot.configuration, scope) ? (this.routes.get(scope) ?? 'unknown') : 'direct',
|
||||
])))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import { BlockList, isIP } from 'node:net'
|
||||
import { domainToASCII } from 'node:url'
|
||||
import { ProxyConfigurationError } from './errors.mjs'
|
||||
import { PLATFORM_NO_PROXY } from '../../../../platform/lib/outbound-proxy.mjs'
|
||||
|
||||
export { PLATFORM_NO_PROXY }
|
||||
|
||||
function invalid(label) {
|
||||
throw new ProxyConfigurationError(`${label} rule is invalid`)
|
||||
}
|
||||
|
||||
function normalizePort(value, label) {
|
||||
if (!/^[0-9]+$/.test(value)) invalid(label)
|
||||
const port = Number(value)
|
||||
if (!Number.isSafeInteger(port) || port < 1 || port > 65535) invalid(label)
|
||||
return port
|
||||
}
|
||||
|
||||
function splitHostPort(input, label) {
|
||||
if (input.startsWith('[')) {
|
||||
const close = input.indexOf(']')
|
||||
if (close < 0) invalid(label)
|
||||
const host = input.slice(1, close)
|
||||
const suffix = input.slice(close + 1)
|
||||
if (suffix === '') return { host, port: null }
|
||||
if (!suffix.startsWith(':')) invalid(label)
|
||||
return { host, port: normalizePort(suffix.slice(1), label) }
|
||||
}
|
||||
if (isIP(input) === 6) return { host: input, port: null }
|
||||
const colon = input.lastIndexOf(':')
|
||||
if (colon >= 0) {
|
||||
if (input.indexOf(':') !== colon) invalid(label)
|
||||
return { host: input.slice(0, colon), port: normalizePort(input.slice(colon + 1), label) }
|
||||
}
|
||||
return { host: input, port: null }
|
||||
}
|
||||
|
||||
function normalizeHost(input, label) {
|
||||
let value = input.toLowerCase()
|
||||
while (value.endsWith('.')) value = value.slice(0, -1)
|
||||
if (value === '') invalid(label)
|
||||
const ipType = isIP(value)
|
||||
if (ipType !== 0) return { host: value, ipType }
|
||||
const suffix = value.startsWith('.')
|
||||
const name = suffix ? value.slice(1) : value
|
||||
if (name === '' || name.includes('*')) invalid(label)
|
||||
const ascii = domainToASCII(name)
|
||||
if (ascii === '' || ascii.length > 253 || ascii.split('.').some(part => part === '' || part.length > 63)) invalid(label)
|
||||
return { host: suffix ? `.${ascii}` : ascii, ipType: 0 }
|
||||
}
|
||||
|
||||
export function normalizeProxyRule(input, { allowWildcard = false, allowCidr = false, label = 'proxy' } = {}) {
|
||||
if (
|
||||
typeof input !== 'string'
|
||||
|| input === ''
|
||||
|| input !== input.trim()
|
||||
|| /[\u0000-\u0020\u007f]/.test(input)
|
||||
|| /[@?#]/.test(input)
|
||||
) invalid(label)
|
||||
if (input === '*') {
|
||||
if (!allowWildcard) invalid(label)
|
||||
return Object.freeze({ type: 'wildcard', value: '*' })
|
||||
}
|
||||
if (input.includes('/')) {
|
||||
if (!allowCidr) invalid(label)
|
||||
if (input.indexOf('/') !== input.lastIndexOf('/')) invalid(label)
|
||||
const slash = input.lastIndexOf('/')
|
||||
const address = input.slice(0, slash)
|
||||
const type = isIP(address)
|
||||
const prefix = Number(input.slice(slash + 1))
|
||||
if (type === 0 || !Number.isInteger(prefix) || prefix < 0 || prefix > (type === 4 ? 32 : 128)) invalid(label)
|
||||
return Object.freeze({ type: 'cidr', value: `${address}/${prefix}`, address, prefix, ipType: type })
|
||||
}
|
||||
const { host: rawHost, port } = splitHostPort(input, label)
|
||||
const { host, ipType } = normalizeHost(rawHost, label)
|
||||
const bracketed = ipType === 6 && port !== null
|
||||
const value = port === null ? host : `${bracketed ? `[${host}]` : host}:${String(port)}`
|
||||
return Object.freeze({ type: host.startsWith('.') ? 'suffix' : 'host', value, host, port, ipType })
|
||||
}
|
||||
|
||||
export function normalizeProxyRules(values, options = {}) {
|
||||
if (!Array.isArray(values)) throw new ProxyConfigurationError(`${options.label ?? 'proxy'} rules must be an array`)
|
||||
const result = new Map()
|
||||
for (const value of values) {
|
||||
const rule = normalizeProxyRule(value, options)
|
||||
result.set(rule.value, rule)
|
||||
}
|
||||
return Object.freeze([...result.values()].sort((left, right) => left.value.localeCompare(right.value)))
|
||||
}
|
||||
|
||||
function hostMatches(rule, host, port) {
|
||||
if (rule.port !== null && rule.port !== port) return false
|
||||
if (rule.type === 'suffix') {
|
||||
const root = rule.host.slice(1)
|
||||
return host === root || host.endsWith(`.${root}`)
|
||||
}
|
||||
return host === rule.host
|
||||
}
|
||||
|
||||
export function matchesProxyRules(rules, targetHost, targetPort = null) {
|
||||
const normalized = normalizeHost(String(targetHost), 'target').host
|
||||
const ipType = isIP(normalized)
|
||||
for (const rule of rules) {
|
||||
if (rule.type === 'wildcard') return true
|
||||
if (rule.type === 'cidr') {
|
||||
if (ipType === 0) continue
|
||||
const block = new BlockList()
|
||||
block.addSubnet(rule.address, rule.prefix, rule.ipType === 4 ? 'ipv4' : 'ipv6')
|
||||
if (block.check(normalized, ipType === 4 ? 'ipv4' : 'ipv6')) return true
|
||||
continue
|
||||
}
|
||||
if (hostMatches(rule, normalized, targetPort)) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
export function noProxyEnvironment(userRules) {
|
||||
const system = normalizeProxyRules(PLATFORM_NO_PROXY, { label: 'platform NO_PROXY' })
|
||||
const user = normalizeProxyRules(userRules, { allowWildcard: true, label: 'NO_PROXY' })
|
||||
return [...new Set([...system, ...user].map(rule => rule.value))].join(',')
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
const groups = Object.freeze({
|
||||
updates: { zh: '更新管理', en: 'Updates' },
|
||||
platform: { zh: '平台组件', en: 'Platform components' },
|
||||
dsh: { zh: 'DSH 核心与插件', en: 'DSH core and plugins' },
|
||||
agent: { zh: 'Agent 与终端', en: 'Agent and terminals' },
|
||||
models: { zh: '模型 API', en: 'Model APIs' },
|
||||
direct: { zh: '始终直连或不受平台管理', en: 'Always direct or unmanaged' },
|
||||
})
|
||||
|
||||
function entry(id, group, category, sourceZh, sourceEn, detailZh, detailEn) {
|
||||
return Object.freeze({
|
||||
id, group, category,
|
||||
source: Object.freeze({ zh: sourceZh, en: sourceEn }),
|
||||
detail: Object.freeze({ zh: detailZh, en: detailEn }),
|
||||
})
|
||||
}
|
||||
|
||||
export const PROXY_SCOPE_CATALOG = Object.freeze({
|
||||
schema: 1,
|
||||
groups,
|
||||
entries: Object.freeze([
|
||||
entry('stable-metadata', 'updates', 'updates', 'Stable、keyring、Release metadata 检查', 'Stable, keyring, and Release metadata checks', '仅远程获取走代理。', 'Only remote retrieval uses the proxy.'),
|
||||
entry('update-artifacts', 'updates', 'updates', '更新 Artifact 下载', 'Update Artifact downloads', '包括 DSH、Environment 和 Bootstrap 等下载。', 'Includes DSH, Environment, Bootstrap, and related downloads.'),
|
||||
entry('experimental-metadata', 'updates', 'updates', 'Experimental 上游版本检查', 'Experimental upstream checks', '包括 npm 和相关 metadata 请求。', 'Includes npm and related metadata requests.'),
|
||||
entry('update-remote', 'updates', 'updates', '更新、回滚中的远程请求', 'Remote requests during update and rollback', '本地切换、恢复和校验保持直连。', 'Local switching, recovery, and verification remain direct.'),
|
||||
entry('stage0-verification', 'direct', 'direct', 'Stage-0 签名与 Hash 校验', 'Stage-0 signature and Hash verification', '完全本地执行,不接收代理凭据。', 'Runs locally and never receives proxy credentials.'),
|
||||
entry('management-external', 'platform', 'platform', 'Management 对外请求', 'Management external requests', '不包括本地 Unix socket 通信。', 'Excludes local Unix socket communication.'),
|
||||
entry('gateway-external', 'platform', 'platform', 'Gateway 自身对外请求', 'Gateway external requests', '浏览器到 DSH 的本地反向代理不使用外部代理。', 'The local browser-to-DSH reverse proxy does not use the external proxy.'),
|
||||
entry('updater-platform', 'platform', 'platform', 'Updater 的非更新类外部请求', 'Updater non-update external requests', '更新请求优先归入更新管理。', 'Update requests are classified as Updates first.'),
|
||||
entry('system-plugins', 'platform', 'platform', 'DSH Docker 系统插件', 'DSH Docker System Plugins', '身份来自签名 Environment Manifest。', 'Identity comes from the signed Environment Manifest.'),
|
||||
entry('dsh-core', 'dsh', 'dshCore', 'DSH 核心对外请求', 'DSH core external requests', '不包括模型 Provider API。', 'Excludes model Provider APIs.'),
|
||||
entry('official-plugins', 'dsh', 'dshPlugins', 'DSH 官方插件', 'Official DSH plugins', '当前与第三方插件共用一个总开关。', 'Currently shares one switch with third-party plugins.'),
|
||||
entry('user-plugins', 'dsh', 'dshPlugins', '用户安装的第三方插件', 'User-installed third-party plugins', '包括插件产生的可识别子进程。', 'Includes identifiable child processes created by plugins.'),
|
||||
entry('plugin-terminal', 'dsh', 'dshPlugins', '第三方插件提供的终端', 'Third-party plugin terminals', '通过可识别子进程接口创建时归入插件范围;直接创建的 PTY 跟随共享 DSH 策略。', 'Uses the plugin scope when created through an identifiable subprocess interface; directly created PTYs follow the shared DSH policy.'),
|
||||
entry('agent-tools', 'agent', 'agentNetwork', 'Agent 的 web_search、web_fetch 等联网工具', 'Agent network tools such as web_search and web_fetch', '按 Agent 工具执行上下文注入代理。', 'Proxy settings are injected using the Agent execution context.'),
|
||||
entry('agent-commands', 'agent', 'agentNetwork', 'Agent 执行的 curl、git、npm 等命令', 'Commands such as curl, git, and npm run by an Agent', '包括 Agent 创建的子进程。', 'Includes child processes created by the Agent.'),
|
||||
entry('agent-session', 'agent', 'agentNetwork', 'Agent 持久终端会话', 'Persistent Agent terminal sessions', '会话创建时确定代理范围。', 'The proxy scope is selected when the session is created.'),
|
||||
entry('management-terminal', 'agent', 'managementTerminal', '管理中心容器终端', 'Management container terminal', '独立于 Agent 和插件终端。', 'Independent from Agent and plugin terminals.'),
|
||||
entry('docker-exec', 'direct', 'unmanaged', '宿主机执行的 docker exec Shell', 'Host-created docker exec Shell', '不受平台管理,用户自行设置代理环境。', 'Not managed by the platform; users configure its environment.'),
|
||||
entry('provider-routed', 'models', 'modelApi', '可识别的模型 Provider', 'Identifiable model Providers', '可分别选择直连或独立代理。', 'Can independently select direct access or a dedicated proxy route.'),
|
||||
entry('provider-shared', 'models', 'sharedDsh', '无法独立路由的模型请求', 'Model requests without independent routing', '只能选择直连或跟随 DSH 共享流量策略;DSH 核心或 DSH 插件任一范围启用时共享路由使用代理。', 'Can only connect directly or follow the shared DSH policy; the shared route uses the proxy when either DSH Core or DSH Plugins is enabled.'),
|
||||
entry('provider-local', 'direct', 'forcedDirect', '本地模型 Provider', 'Local model Providers', '本地地址强制直连。', 'Local addresses are always direct.'),
|
||||
entry('gateway-dsh', 'direct', 'direct', 'Gateway 到 DSH 的本地转发', 'Local Gateway-to-DSH forwarding', '容器内部 loopback 通信。', 'Container-internal loopback communication.'),
|
||||
entry('platform-sockets', 'direct', 'direct', 'Management、Bootstrap、Stage-0 的 Unix socket', 'Management, Bootstrap, and Stage-0 Unix sockets', '本地控制协议不使用代理。', 'Local control protocols do not use the proxy.'),
|
||||
entry('local-files', 'direct', 'direct', '日志、文件、快照、Runtime 构建', 'Logs, files, snapshots, and Runtime builds', '本地文件和进程操作不使用代理。', 'Local file and process operations do not use the proxy.'),
|
||||
]),
|
||||
summaries: Object.freeze([
|
||||
Object.freeze({ zh: '本地平台通信始终直连。', en: 'Local platform communication is always direct.' }),
|
||||
Object.freeze({ zh: '可识别的模型 Provider 可分别选择直连或独立代理;无法独立路由的请求只能选择直连或跟随 DSH。', en: 'Identifiable model Providers independently select direct access or a dedicated proxy; requests without independent routing can only connect directly or follow DSH.' }),
|
||||
Object.freeze({ zh: 'docker exec 创建的 Shell 不受平台代理设置管理。', en: 'Shells created with docker exec are not managed by platform proxy settings.' }),
|
||||
]),
|
||||
})
|
||||
@@ -0,0 +1,195 @@
|
||||
import { createHash, randomBytes, randomUUID } from 'node:crypto'
|
||||
import { chmod, mkdir, open, readFile, readdir, rename, rm, stat } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { canonicalJson } from '../../../../platform/lib/canonical-json.mjs'
|
||||
import { durableReplace } from '../../../../platform/lib/atomic.mjs'
|
||||
import { defaultProxyConfiguration, validateProxyConfiguration } from './contracts.mjs'
|
||||
import { ProxyConfigurationError } from './errors.mjs'
|
||||
|
||||
function hash(bytes) {
|
||||
return createHash('sha256').update(bytes).digest('hex')
|
||||
}
|
||||
|
||||
async function syncDirectory(path) {
|
||||
const handle = await open(path, 'r')
|
||||
try { await handle.sync() } finally { await handle.close() }
|
||||
}
|
||||
|
||||
async function writeSynced(path, bytes, mode) {
|
||||
const handle = await open(path, 'wx', mode)
|
||||
try {
|
||||
await handle.writeFile(bytes)
|
||||
await handle.sync()
|
||||
} finally {
|
||||
await handle.close()
|
||||
}
|
||||
}
|
||||
|
||||
function revision() {
|
||||
return randomBytes(32).toString('base64url')
|
||||
}
|
||||
|
||||
function parse(bytes, label) {
|
||||
try { return JSON.parse(bytes.toString('utf8')) } catch { throw new ProxyConfigurationError(`${label} is corrupt`, { code: 'PROXY_STATE_CORRUPT', stage: 'load' }) }
|
||||
}
|
||||
|
||||
export class ProxyConfigurationStore {
|
||||
constructor(root, { now = () => new Date() } = {}) {
|
||||
this.root = root
|
||||
this.revisionsRoot = join(root, 'revisions')
|
||||
this.currentPath = join(root, 'current.json')
|
||||
this.now = now
|
||||
this.queue = Promise.resolve()
|
||||
}
|
||||
|
||||
exclusive(operation) {
|
||||
const result = this.queue.then(operation, operation)
|
||||
this.queue = result.then(() => undefined, () => undefined)
|
||||
return result
|
||||
}
|
||||
|
||||
async prepare() {
|
||||
await mkdir(this.revisionsRoot, { recursive: true, mode: 0o700 })
|
||||
await chmod(this.root, 0o700)
|
||||
await chmod(this.revisionsRoot, 0o700)
|
||||
}
|
||||
|
||||
async readPointer() {
|
||||
try {
|
||||
const pointer = parse(await readFile(this.currentPath), 'proxy current pointer')
|
||||
if (pointer?.schema !== 1 || typeof pointer.revision !== 'string' || !/^[A-Za-z0-9_-]{43}$/.test(pointer.revision)) {
|
||||
throw new ProxyConfigurationError('proxy current pointer is corrupt', { code: 'PROXY_STATE_CORRUPT', stage: 'load' })
|
||||
}
|
||||
return pointer.revision
|
||||
} catch (error) {
|
||||
if (error?.code === 'ENOENT') return null
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async readRevision(id) {
|
||||
const root = join(this.revisionsRoot, id)
|
||||
const [configBytes, credentialsBytes, manifestBytes] = await Promise.all([
|
||||
readFile(join(root, 'config.json')),
|
||||
readFile(join(root, 'credentials.json')),
|
||||
readFile(join(root, 'manifest.json')),
|
||||
])
|
||||
const manifest = parse(manifestBytes, 'proxy revision manifest')
|
||||
if (
|
||||
manifest?.schema !== 1 || manifest.revision !== id
|
||||
|| manifest.files?.config !== hash(configBytes)
|
||||
|| manifest.files?.credentials !== hash(credentialsBytes)
|
||||
) throw new ProxyConfigurationError('proxy revision manifest does not match its files', { code: 'PROXY_STATE_CORRUPT', stage: 'load' })
|
||||
const configuration = parse(configBytes, 'proxy configuration')
|
||||
const credentials = parse(credentialsBytes, 'proxy credentials')
|
||||
if (credentials?.schema !== 1 || credentials.username !== configuration?.proxy?.username || !('password' in credentials)) {
|
||||
throw new ProxyConfigurationError('proxy credentials do not match configuration', { code: 'PROXY_STATE_CORRUPT', stage: 'load' })
|
||||
}
|
||||
const validated = validateProxyConfiguration({
|
||||
...configuration,
|
||||
proxy: { ...configuration.proxy, password: credentials.password },
|
||||
noProxy: { user: configuration.noProxy.user },
|
||||
})
|
||||
if (validated.configuration.proxy.passwordConfigured !== configuration.proxy.passwordConfigured) {
|
||||
throw new ProxyConfigurationError('proxy password state is inconsistent', { code: 'PROXY_STATE_CORRUPT', stage: 'load' })
|
||||
}
|
||||
return Object.freeze({ revision: id, configuration: validated.configuration, credentials: validated.credentials, createdAt: manifest.createdAt })
|
||||
}
|
||||
|
||||
async recoveryCandidates() {
|
||||
const entries = await readdir(this.revisionsRoot, { withFileTypes: true })
|
||||
const candidates = []
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory() || entry.name.startsWith('.')) continue
|
||||
try {
|
||||
const loaded = await this.readRevision(entry.name)
|
||||
candidates.push(loaded)
|
||||
} catch {}
|
||||
}
|
||||
return candidates.sort((left, right) => right.createdAt.localeCompare(left.createdAt))
|
||||
}
|
||||
|
||||
async load() {
|
||||
await this.prepare()
|
||||
let current = null
|
||||
let corruptPointer = false
|
||||
try {
|
||||
current = await this.readPointer()
|
||||
} catch (error) {
|
||||
if (error?.code !== 'PROXY_STATE_CORRUPT') throw error
|
||||
corruptPointer = true
|
||||
}
|
||||
if (current !== null) {
|
||||
try { return Object.freeze({ ...(await this.readRevision(current)), recovery: 'none' }) } catch (error) {
|
||||
if (error?.code !== 'PROXY_STATE_CORRUPT' && error?.code !== 'ENOENT') throw error
|
||||
}
|
||||
}
|
||||
const recovered = (await this.recoveryCandidates())[0]
|
||||
if (recovered !== undefined) {
|
||||
await durableReplace(this.currentPath, canonicalJson({ schema: 1, revision: recovered.revision }), 0o640)
|
||||
return Object.freeze({ ...recovered, recovery: 'previous' })
|
||||
}
|
||||
const entries = await readdir(this.revisionsRoot, { withFileTypes: true })
|
||||
const hadPublishedState = corruptPointer || entries.some(entry => entry.isDirectory() && !entry.name.startsWith('.'))
|
||||
if (corruptPointer) {
|
||||
await rename(this.currentPath, join(this.root, `current.corrupt.${randomUUID()}.json`))
|
||||
await syncDirectory(this.root)
|
||||
}
|
||||
const initialized = await this.commit({ baseRevision: null, value: defaultProxyConfiguration() })
|
||||
return Object.freeze({ ...initialized, recovery: hadPublishedState ? 'reset-disabled' : 'none' })
|
||||
}
|
||||
|
||||
commit(request) {
|
||||
return this.exclusive(() => this.commitUnlocked(request))
|
||||
}
|
||||
|
||||
async commitUnlocked({ baseRevision, value }) {
|
||||
await this.prepare()
|
||||
let current = null
|
||||
try { current = await this.loadCurrentWithoutRecovery() } catch (error) {
|
||||
if (error?.code !== 'ENOENT') throw error
|
||||
}
|
||||
if ((current?.revision ?? null) !== baseRevision) {
|
||||
throw new ProxyConfigurationError('proxy configuration changed', {
|
||||
code: 'REVISION_CONFLICT', statusCode: 409, stage: 'activate',
|
||||
})
|
||||
}
|
||||
const validated = validateProxyConfiguration(value, { existingPassword: current?.credentials.password ?? null })
|
||||
const id = revision()
|
||||
const staging = join(this.revisionsRoot, `.${id}.${randomUUID()}.tmp`)
|
||||
const published = join(this.revisionsRoot, id)
|
||||
await mkdir(staging, { mode: 0o700 })
|
||||
try {
|
||||
const configBytes = canonicalJson(validated.configuration)
|
||||
const credentialsBytes = canonicalJson({ schema: 1, ...validated.credentials })
|
||||
const manifestBytes = canonicalJson({
|
||||
schema: 1,
|
||||
revision: id,
|
||||
createdAt: this.now().toISOString(),
|
||||
files: { config: hash(configBytes), credentials: hash(credentialsBytes) },
|
||||
})
|
||||
await writeSynced(join(staging, 'config.json'), configBytes, 0o640)
|
||||
await writeSynced(join(staging, 'credentials.json'), credentialsBytes, 0o600)
|
||||
await writeSynced(join(staging, 'manifest.json'), manifestBytes, 0o640)
|
||||
await syncDirectory(staging)
|
||||
await rename(staging, published)
|
||||
await syncDirectory(this.revisionsRoot)
|
||||
const candidate = await this.readRevision(id)
|
||||
await durableReplace(this.currentPath, canonicalJson({ schema: 1, revision: id }), 0o640)
|
||||
return candidate
|
||||
} catch (error) {
|
||||
await rm(staging, { recursive: true, force: true })
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async loadCurrentWithoutRecovery() {
|
||||
const current = await this.readPointer()
|
||||
if (current === null) {
|
||||
const error = new Error('proxy configuration is not initialized')
|
||||
error.code = 'ENOENT'
|
||||
throw error
|
||||
}
|
||||
return this.readRevision(current)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,455 @@
|
||||
import { createHash, randomUUID } from 'node:crypto'
|
||||
import { lookup } from 'node:dns/promises'
|
||||
import { connect as tlsConnect } from 'node:tls'
|
||||
import { canonicalJson } from '../../../../platform/lib/canonical-json.mjs'
|
||||
import { durableReplace } from '../../../../platform/lib/atomic.mjs'
|
||||
import { validateProxyConfiguration } from './contracts.mjs'
|
||||
import { ProxyDnsCache } from './dns-cache.mjs'
|
||||
import { ProxyConfigurationError } from './errors.mjs'
|
||||
import { selectProxyRoute } from './policy.mjs'
|
||||
import {
|
||||
connectTcp,
|
||||
connectThroughHttpProxy,
|
||||
connectThroughSocks5,
|
||||
readHttpHead,
|
||||
ProxyTransportError,
|
||||
} from './transport.mjs'
|
||||
|
||||
const TASK_SCHEMA = 1
|
||||
const PHASE_TIMEOUT_MS = 15_000
|
||||
const TOTAL_TIMEOUT_MS = 60_000
|
||||
const RETENTION_MS = 10 * 60_000
|
||||
const STAGES = Object.freeze([
|
||||
'proxy-address', 'proxy-connect', 'proxy-handshake', 'target-dns', 'target-tls', 'target-http',
|
||||
])
|
||||
const TARGETS = Object.freeze([
|
||||
Object.freeze({ id: 'github-metadata', host: 'api.github.com', port: 443, path: '/repos/yjrszcq/dsh-docker/releases/latest' }),
|
||||
Object.freeze({ id: 'npm-registry', host: 'registry.npmjs.org', port: 443, path: '/@deepseek-ai%2fdsh/latest' }),
|
||||
])
|
||||
|
||||
function safeError(error) {
|
||||
return Object.freeze({
|
||||
errorCode: typeof error?.code === 'string' ? error.code : 'PROXY_TEST_FAILED',
|
||||
detail: error instanceof Error ? error.message : 'proxy test failed',
|
||||
})
|
||||
}
|
||||
|
||||
function pendingStage(stage) {
|
||||
return {
|
||||
stage,
|
||||
status: 'pending',
|
||||
durationMs: null,
|
||||
errorCode: null,
|
||||
detail: null,
|
||||
detailCode: null,
|
||||
detailData: null,
|
||||
}
|
||||
}
|
||||
|
||||
function stageDetail(detail, detailCode, detailData = {}) {
|
||||
return Object.freeze({ detail, detailCode, detailData: Object.freeze(detailData) })
|
||||
}
|
||||
|
||||
function stagesFor(configuration) {
|
||||
const localTargetDns = configuration.proxy.protocol === 'socks5'
|
||||
&& configuration.proxy.remoteDns === false
|
||||
return localTargetDns ? STAGES : STAGES.filter(stage => stage !== 'target-dns')
|
||||
}
|
||||
|
||||
function taskHash(configuration, baseRevision) {
|
||||
return `sha256:${createHash('sha256').update(canonicalJson({ baseRevision, configuration })).digest('hex')}`
|
||||
}
|
||||
|
||||
function publicTask(task) {
|
||||
return Object.freeze({
|
||||
schema: TASK_SCHEMA,
|
||||
taskId: task.taskId,
|
||||
status: task.status,
|
||||
baseRevision: task.baseRevision,
|
||||
candidateHash: task.candidateHash,
|
||||
mode: task.mode,
|
||||
currentStage: task.currentStage,
|
||||
stages: Object.freeze(task.stages.map(stage => Object.freeze({ ...stage }))),
|
||||
error: task.error === null ? null : Object.freeze({ ...task.error }),
|
||||
createdAt: task.createdAt,
|
||||
updatedAt: task.updatedAt,
|
||||
})
|
||||
}
|
||||
|
||||
function modeFor(routes) {
|
||||
const modes = new Set(routes.map(route => route.mode === 'direct' ? 'direct' : 'proxy'))
|
||||
return modes.size === 1 ? modes.values().next().value : 'mixed'
|
||||
}
|
||||
|
||||
async function firstConnection(route, target, signal) {
|
||||
if (route.mode === 'direct') {
|
||||
const candidates = route.targets ?? [{ address: target.host }]
|
||||
let failure
|
||||
for (const candidate of candidates) {
|
||||
try {
|
||||
return Object.freeze({
|
||||
socket: await connectTcp({ host: candidate.address, port: target.port, signal }),
|
||||
remainder: Buffer.alloc(0),
|
||||
})
|
||||
} catch (error) { failure = error }
|
||||
}
|
||||
throw failure ?? new ProxyTransportError('target connection failed')
|
||||
}
|
||||
if (route.mode === 'http') return connectThroughHttpProxy({
|
||||
endpoint: route.endpoint, targetHost: target.host, targetPort: target.port, signal,
|
||||
})
|
||||
if (route.mode === 'socks5') {
|
||||
const candidates = route.targets ?? [{ address: target.host }]
|
||||
let failure
|
||||
for (const candidate of candidates) {
|
||||
try {
|
||||
return await connectThroughSocks5({
|
||||
endpoint: route.endpoint, targetHost: candidate.address, targetPort: target.port, signal,
|
||||
})
|
||||
} catch (error) { failure = error }
|
||||
}
|
||||
throw failure ?? new ProxyTransportError('SOCKS5 target connection failed')
|
||||
}
|
||||
throw new ProxyTransportError('proxy route is invalid', { code: 'PROXY_PROTOCOL_UNAVAILABLE' })
|
||||
}
|
||||
|
||||
async function secureConnection(route, target, signal) {
|
||||
const connection = await firstConnection(route, target, signal)
|
||||
if (connection.remainder.byteLength > 0) connection.socket.unshift(connection.remainder)
|
||||
const socket = tlsConnect({ socket: connection.socket, servername: target.host, rejectUnauthorized: true })
|
||||
await new Promise((resolve, reject) => {
|
||||
const cleanup = () => {
|
||||
socket.off('secureConnect', connected)
|
||||
socket.off('error', failed)
|
||||
signal.removeEventListener('abort', cancelled)
|
||||
}
|
||||
const connected = () => { cleanup(); resolve() }
|
||||
const failed = error => { cleanup(); reject(error) }
|
||||
const cancelled = () => {
|
||||
cleanup()
|
||||
socket.destroy()
|
||||
reject(new ProxyTransportError('TLS connection was cancelled', { code: 'REQUEST_CANCELLED', statusCode: 499 }))
|
||||
}
|
||||
socket.once('secureConnect', connected)
|
||||
socket.once('error', failed)
|
||||
signal.addEventListener('abort', cancelled, { once: true })
|
||||
})
|
||||
return socket
|
||||
}
|
||||
|
||||
async function targetHttp(route, target, signal) {
|
||||
const socket = await secureConnection(route, target, signal)
|
||||
try {
|
||||
socket.write([
|
||||
`GET ${target.path} HTTP/1.1`,
|
||||
`Host: ${target.host}`,
|
||||
'User-Agent: DSH-Docker-Proxy-Test/1',
|
||||
'Accept: application/json',
|
||||
'Connection: close',
|
||||
'',
|
||||
'',
|
||||
].join('\r\n'))
|
||||
const { head } = await readHttpHead(socket, { signal })
|
||||
const status = Number(/^HTTP\/1\.[01] ([0-9]{3})/.exec(head.toString('latin1'))?.[1])
|
||||
if (!Number.isInteger(status) || status >= 500) {
|
||||
throw new ProxyTransportError(`target returned HTTP ${Number.isInteger(status) ? String(status) : 'invalid'}`, {
|
||||
code: 'TARGET_HTTP_FAILED',
|
||||
})
|
||||
}
|
||||
return status
|
||||
} finally {
|
||||
socket.destroy()
|
||||
}
|
||||
}
|
||||
|
||||
export class ProxyTestManager {
|
||||
constructor({
|
||||
statePath,
|
||||
now = () => new Date(),
|
||||
resolve = lookup,
|
||||
phaseTimeoutMs = PHASE_TIMEOUT_MS,
|
||||
totalTimeoutMs = TOTAL_TIMEOUT_MS,
|
||||
retentionMs = RETENTION_MS,
|
||||
targets = TARGETS,
|
||||
}) {
|
||||
this.statePath = statePath
|
||||
this.now = now
|
||||
this.resolve = resolve
|
||||
this.phaseTimeoutMs = phaseTimeoutMs
|
||||
this.totalTimeoutMs = totalTimeoutMs
|
||||
this.retentionMs = retentionMs
|
||||
this.targets = targets
|
||||
this.tasks = new Map()
|
||||
this.active = null
|
||||
this.lastTest = null
|
||||
this.writeQueue = Promise.resolve()
|
||||
}
|
||||
|
||||
async initialize(readState) {
|
||||
let value
|
||||
try { value = await readState(this.statePath) } catch (error) {
|
||||
if (error?.code === 'ENOENT') return
|
||||
throw error
|
||||
}
|
||||
if (value?.schema !== TASK_SCHEMA || !Array.isArray(value.tasks)) return
|
||||
for (const stored of value.tasks) {
|
||||
if (stored?.schema !== TASK_SCHEMA || typeof stored.taskId !== 'string') continue
|
||||
const task = { ...stored, stages: stored.stages.map(stage => ({ ...stage })), controller: undefined }
|
||||
if (task.status === 'running') {
|
||||
task.status = 'failed'
|
||||
task.error = { errorCode: 'PROXY_TEST_INTERRUPTED', detail: 'Proxy Manager restarted during the test' }
|
||||
task.updatedAt = this.now().toISOString()
|
||||
for (const stage of task.stages) if (['pending', 'running'].includes(stage.status)) stage.status = 'skipped'
|
||||
}
|
||||
this.tasks.set(task.taskId, task)
|
||||
}
|
||||
this.prune()
|
||||
this.lastTest = [...this.tasks.values()].sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))[0] ?? null
|
||||
await this.persist()
|
||||
}
|
||||
|
||||
getState() {
|
||||
return { lastTest: this.lastTest === null ? null : publicTask(this.lastTest) }
|
||||
}
|
||||
|
||||
prune() {
|
||||
const cutoff = this.now().getTime() - this.retentionMs
|
||||
for (const [id, task] of this.tasks) {
|
||||
if (task.status !== 'running' && Date.parse(task.updatedAt) < cutoff) this.tasks.delete(id)
|
||||
}
|
||||
}
|
||||
|
||||
async persist() {
|
||||
this.prune()
|
||||
const tasks = [...this.tasks.values()].map(task => publicTask(task))
|
||||
const bytes = canonicalJson({ schema: TASK_SCHEMA, tasks })
|
||||
const write = this.writeQueue.then(() => durableReplace(this.statePath, bytes, 0o640))
|
||||
this.writeQueue = write.catch(() => {})
|
||||
await write
|
||||
}
|
||||
|
||||
async lookup(host, signal) {
|
||||
if (signal?.aborted) throw signal.reason
|
||||
return new Promise((resolve, reject) => {
|
||||
const cancelled = () => reject(signal.reason ?? new ProxyTransportError('DNS resolution was cancelled', {
|
||||
code: 'REQUEST_CANCELLED', statusCode: 499,
|
||||
}))
|
||||
signal?.addEventListener('abort', cancelled, { once: true })
|
||||
Promise.resolve(this.resolve(host, { all: true, verbatim: true })).then(resolve, reject).finally(() => {
|
||||
signal?.removeEventListener('abort', cancelled)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async start(request, current) {
|
||||
if (request === null || typeof request !== 'object' || Array.isArray(request)
|
||||
|| Object.keys(request).sort().join(',') !== 'baseRevision,value') {
|
||||
throw new ProxyConfigurationError('proxy test request is invalid', { stage: 'test' })
|
||||
}
|
||||
const { baseRevision, value } = request
|
||||
if (this.active !== null) {
|
||||
throw new ProxyConfigurationError('another proxy test is already running', {
|
||||
code: 'PROXY_TEST_BUSY', statusCode: 409, stage: 'test', retryable: true,
|
||||
})
|
||||
}
|
||||
if (baseRevision !== current.revision) {
|
||||
throw new ProxyConfigurationError('proxy configuration changed', {
|
||||
code: 'REVISION_CONFLICT', statusCode: 409, stage: 'test', retryable: true,
|
||||
})
|
||||
}
|
||||
const testedValue = {
|
||||
...value,
|
||||
enabled: true,
|
||||
scopes: { ...value.scopes, updates: true },
|
||||
noProxy: { ...value.noProxy, user: [] },
|
||||
bypass: { ...value.bypass, additional: [] },
|
||||
}
|
||||
const validated = validateProxyConfiguration(testedValue, { existingPassword: current.credentials.password })
|
||||
const snapshot = Object.freeze({
|
||||
revision: `test-${randomUUID()}`,
|
||||
configuration: validated.configuration,
|
||||
credentials: validated.credentials,
|
||||
})
|
||||
const createdAt = this.now().toISOString()
|
||||
const task = {
|
||||
schema: TASK_SCHEMA,
|
||||
taskId: randomUUID(),
|
||||
status: 'running',
|
||||
baseRevision,
|
||||
candidateHash: taskHash(validated.configuration, baseRevision),
|
||||
mode: null,
|
||||
currentStage: stagesFor(validated.configuration)[0],
|
||||
stages: stagesFor(validated.configuration).map(pendingStage),
|
||||
error: null,
|
||||
createdAt,
|
||||
updatedAt: createdAt,
|
||||
controller: new AbortController(),
|
||||
}
|
||||
this.tasks.set(task.taskId, task)
|
||||
this.active = task
|
||||
await this.persist()
|
||||
void this.run(task, snapshot).catch(() => {})
|
||||
return publicTask(task)
|
||||
}
|
||||
|
||||
async update(task, value) {
|
||||
Object.assign(task, value, { updatedAt: this.now().toISOString() })
|
||||
await this.persist()
|
||||
}
|
||||
|
||||
async stage(task, name, operation) {
|
||||
const entry = task.stages.find(value => value.stage === name)
|
||||
entry.status = 'running'
|
||||
task.currentStage = name
|
||||
await this.update(task, {})
|
||||
const started = Date.now()
|
||||
const signal = AbortSignal.any([task.controller.signal, AbortSignal.timeout(this.phaseTimeoutMs)])
|
||||
try {
|
||||
const result = await operation(signal)
|
||||
Object.assign(entry, {
|
||||
status: 'success', durationMs: Date.now() - started, errorCode: null,
|
||||
detail: typeof result === 'string' ? result : result?.detail ?? null,
|
||||
detailCode: typeof result === 'object' ? result?.detailCode ?? null : null,
|
||||
detailData: typeof result === 'object' ? result?.detailData ?? null : null,
|
||||
})
|
||||
await this.update(task, {})
|
||||
} catch (error) {
|
||||
const cancelled = task.controller.signal.aborted
|
||||
&& task.controller.signal.reason?.code !== 'PROXY_TEST_TIMEOUT'
|
||||
const failure = cancelled
|
||||
? { errorCode: 'REQUEST_CANCELLED', detail: 'proxy test was cancelled' }
|
||||
: signal.reason?.name === 'TimeoutError'
|
||||
? { errorCode: 'PROXY_TEST_STAGE_TIMEOUT', detail: `${name} timed out` }
|
||||
: safeError(error)
|
||||
Object.assign(entry, {
|
||||
status: cancelled ? 'cancelled' : 'failed',
|
||||
durationMs: Date.now() - started,
|
||||
...failure,
|
||||
})
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async run(task, snapshot) {
|
||||
const totalTimer = setTimeout(() => task.controller.abort(new ProxyTransportError('proxy test timed out', {
|
||||
code: 'PROXY_TEST_TIMEOUT', statusCode: 504,
|
||||
})), this.totalTimeoutMs)
|
||||
totalTimer.unref?.()
|
||||
const dnsCache = new ProxyDnsCache({ resolver: this.resolve })
|
||||
let routes
|
||||
try {
|
||||
routes = await Promise.all(this.targets.map(target => selectProxyRoute({
|
||||
snapshot, scope: 'updates', host: target.host, port: target.port, dnsCache,
|
||||
signal: task.controller.signal,
|
||||
})))
|
||||
task.mode = modeFor(routes)
|
||||
const proxied = routes.find(route => route.mode !== 'direct')
|
||||
if (proxied === undefined) {
|
||||
for (const name of ['proxy-address', 'proxy-connect', 'proxy-handshake']) {
|
||||
const stage = task.stages.find(value => value.stage === name)
|
||||
Object.assign(stage, {
|
||||
status: 'skipped', durationMs: 0, detail: 'candidate route is direct',
|
||||
detailCode: 'route-direct', detailData: {},
|
||||
})
|
||||
}
|
||||
await this.update(task, {})
|
||||
} else {
|
||||
await this.stage(task, 'proxy-address', async () => {
|
||||
const records = await this.lookup(proxied.endpoint.host, task.controller.signal)
|
||||
if (!Array.isArray(records) || records.length === 0) throw new ProxyTransportError('proxy address resolution returned no records', { code: 'PROXY_DNS_FAILED' })
|
||||
return stageDetail(
|
||||
`resolved ${String(records.length)} proxy address record(s)`,
|
||||
'proxy-addresses-resolved',
|
||||
{ count: records.length },
|
||||
)
|
||||
})
|
||||
await this.stage(task, 'proxy-connect', async signal => {
|
||||
const socket = await connectTcp({ host: proxied.endpoint.host, port: proxied.endpoint.port, signal })
|
||||
socket.destroy()
|
||||
return stageDetail('proxy TCP connection succeeded', 'proxy-connected')
|
||||
})
|
||||
await this.stage(task, 'proxy-handshake', async signal => {
|
||||
const target = this.targets[routes.indexOf(proxied)]
|
||||
const connection = await firstConnection(proxied, target, signal)
|
||||
connection.socket.destroy()
|
||||
const protocol = snapshot.configuration.proxy.protocol.toUpperCase()
|
||||
return stageDetail(`${protocol} proxy authentication succeeded`, 'proxy-handshake-completed', { protocol })
|
||||
})
|
||||
}
|
||||
if (task.stages.some(stage => stage.stage === 'target-dns')) {
|
||||
await this.stage(task, 'target-dns', async () => {
|
||||
for (const target of this.targets) {
|
||||
const records = await this.lookup(target.host, task.controller.signal)
|
||||
if (!Array.isArray(records) || records.length === 0) throw new ProxyTransportError('target DNS returned no records', { code: 'TARGET_DNS_FAILED' })
|
||||
}
|
||||
return stageDetail(
|
||||
`resolved ${String(this.targets.length)} target(s)`,
|
||||
'target-addresses-resolved',
|
||||
{ count: this.targets.length },
|
||||
)
|
||||
})
|
||||
}
|
||||
await this.stage(task, 'target-tls', async signal => {
|
||||
for (let index = 0; index < this.targets.length; index += 1) {
|
||||
const socket = await secureConnection(routes[index], this.targets[index], signal)
|
||||
socket.destroy()
|
||||
}
|
||||
return stageDetail(
|
||||
`TLS verified for ${String(this.targets.length)} target(s)`,
|
||||
'target-tls-verified',
|
||||
{ count: this.targets.length },
|
||||
)
|
||||
})
|
||||
await this.stage(task, 'target-http', async signal => {
|
||||
const results = []
|
||||
for (let index = 0; index < this.targets.length; index += 1) {
|
||||
const status = await targetHttp(routes[index], this.targets[index], signal)
|
||||
results.push({ id: this.targets[index].id, status })
|
||||
}
|
||||
return stageDetail(
|
||||
results.map(result => `${result.id}=HTTP ${String(result.status)}`).join(', '),
|
||||
'target-http-completed',
|
||||
{ results },
|
||||
)
|
||||
})
|
||||
await this.update(task, { status: 'success', currentStage: null, error: null })
|
||||
} catch (error) {
|
||||
const timeout = task.controller.signal.reason?.code === 'PROXY_TEST_TIMEOUT'
|
||||
const cancelled = task.controller.signal.aborted && !timeout
|
||||
for (const stage of task.stages) {
|
||||
if (stage.status === 'pending') Object.assign(stage, { status: 'skipped', durationMs: 0 })
|
||||
}
|
||||
await this.update(task, {
|
||||
status: cancelled ? 'cancelled' : 'failed',
|
||||
currentStage: null,
|
||||
error: cancelled
|
||||
? { errorCode: 'REQUEST_CANCELLED', detail: 'proxy test was cancelled' }
|
||||
: timeout ? safeError(task.controller.signal.reason) : safeError(error),
|
||||
})
|
||||
} finally {
|
||||
clearTimeout(totalTimer)
|
||||
if (this.active === task) this.active = null
|
||||
task.controller = undefined
|
||||
this.lastTest = task
|
||||
await this.persist()
|
||||
}
|
||||
}
|
||||
|
||||
get(taskId) {
|
||||
this.prune()
|
||||
const task = this.tasks.get(taskId)
|
||||
if (task === undefined) throw new ProxyConfigurationError('proxy test task was not found', {
|
||||
code: 'PROXY_TEST_NOT_FOUND', statusCode: 404, stage: 'test',
|
||||
})
|
||||
return publicTask(task)
|
||||
}
|
||||
|
||||
async cancel(taskId) {
|
||||
const task = this.tasks.get(taskId)
|
||||
if (task === undefined) return this.get(taskId)
|
||||
if (task.status === 'running') task.controller.abort(new Error('proxy test cancelled'))
|
||||
return publicTask(task)
|
||||
}
|
||||
}
|
||||
|
||||
export const proxyTestInternals = Object.freeze({ STAGES, TARGETS, modeFor, publicTask, stageDetail, stagesFor })
|
||||
@@ -0,0 +1,423 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user