dsh 0.2.0-rc.2: in-container update (experimental channel), path-agnostic compose, upstream patches adapted to 0.1.x/0.2.x
This commit is contained in:
commit
e6dcef9bf5
302 files changed
+75178
No files matched your search
@@ -0,0 +1,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 })
|
||||
Reference in new issue
Block a user