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:
hermes committed 2026-10-01 19:21:19 +00:00
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)
}
}