Files
dsh/upstream/container/control-plane/services/outbound-proxy/lib/test-manager.mjs
T

456 lines
17 KiB
JavaScript

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 })