425 lines
20 KiB
JavaScript
425 lines
20 KiB
JavaScript
import assert from 'node:assert/strict'
|
|
import { chmod, mkdir, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises'
|
|
import { spawn } from 'node:child_process'
|
|
import { once } from 'node:events'
|
|
import { createServer, request } from 'node:http'
|
|
import { tmpdir } from 'node:os'
|
|
import { join } from 'node:path'
|
|
import test from 'node:test'
|
|
import {
|
|
defaultProxyConfiguration,
|
|
PROXY_PORTS,
|
|
sanitizedProxyConfiguration,
|
|
validateProxyConfiguration,
|
|
} from '../../control-plane/services/outbound-proxy/lib/contracts.mjs'
|
|
import { ProxyConfigurationError, proxyErrorBody } from '../../control-plane/services/outbound-proxy/lib/errors.mjs'
|
|
import {
|
|
matchesProxyRules,
|
|
noProxyEnvironment,
|
|
normalizeProxyRules,
|
|
} from '../../control-plane/services/outbound-proxy/lib/rules.mjs'
|
|
import { ProxyConfigurationStore } from '../../control-plane/services/outbound-proxy/lib/store.mjs'
|
|
import { PROXY_SCOPE_CATALOG } from '../../control-plane/services/outbound-proxy/lib/scope-catalog.mjs'
|
|
import { createOutboundProxyControl } from '../../control-plane/services/outbound-proxy/lib/control.mjs'
|
|
import { OutboundProxyControlClient } from '../../control-plane/services/management/outbound-proxy-client.mjs'
|
|
|
|
function configured(overrides = {}) {
|
|
const defaults = defaultProxyConfiguration()
|
|
return {
|
|
...defaults,
|
|
enabled: true,
|
|
proxy: {
|
|
...defaults.proxy,
|
|
host: 'Proxy.Example.COM.',
|
|
port: 1080,
|
|
username: 'user:@/',
|
|
password: 'secret:@/',
|
|
...overrides.proxy,
|
|
},
|
|
scopes: { ...defaults.scopes, updates: true, ...overrides.scopes },
|
|
environment: { ...defaults.environment, ...overrides.environment },
|
|
modelApi: { ...defaults.modelApi, providers: {}, ...overrides.modelApi },
|
|
noProxy: { user: ['GOOGLE.com.', '.Example.COM', 'google.com'], ...overrides.noProxy },
|
|
bypass: { additional: ['192.168.1.0/24', '[::1]:8443'], ...overrides.bypass },
|
|
}
|
|
}
|
|
|
|
async function listen(server) {
|
|
await new Promise((resolve, reject) => {
|
|
server.once('error', reject)
|
|
server.listen(0, '127.0.0.1', resolve)
|
|
})
|
|
return server.address().port
|
|
}
|
|
|
|
function exchange(port, method, path, body) {
|
|
return new Promise((resolve, reject) => {
|
|
const bytes = body === undefined ? undefined : Buffer.from(JSON.stringify(body))
|
|
const outgoing = request({
|
|
host: '127.0.0.1', port, method, path,
|
|
headers: bytes === undefined ? {} : { 'content-type': 'application/json', 'content-length': bytes.byteLength },
|
|
}, response => {
|
|
const chunks = []
|
|
response.on('data', chunk => chunks.push(chunk))
|
|
response.on('end', () => resolve({
|
|
status: response.statusCode,
|
|
body: JSON.parse(Buffer.concat(chunks).toString('utf8')),
|
|
}))
|
|
})
|
|
outgoing.once('error', reject)
|
|
outgoing.end(bytes)
|
|
})
|
|
}
|
|
|
|
test('normalizes strict proxy configuration without exposing a password', () => {
|
|
const { configuration, credentials } = validateProxyConfiguration(configured())
|
|
assert.equal(configuration.proxy.host, 'proxy.example.com')
|
|
assert.equal(configuration.proxy.passwordConfigured, true)
|
|
assert.equal('password' in configuration.proxy, false)
|
|
assert.deepEqual(configuration.noProxy.user, ['.example.com', 'google.com'])
|
|
assert.deepEqual(configuration.bypass.additional, ['[::1]:8443', '192.168.1.0/24'])
|
|
assert.equal(credentials.password, 'secret:@/')
|
|
assert.equal(Object.isFrozen(configuration.proxy), true)
|
|
assert.equal(Object.isFrozen(configuration.scopes), true)
|
|
assert.deepEqual(PROXY_PORTS, {
|
|
updates: 17891, platform: 17892, dshCore: 17893, dshPlugins: 17894,
|
|
agentNetwork: 17895, managementTerminal: 17896, modelApi: 17897, sharedDsh: 17898,
|
|
})
|
|
const view = sanitizedProxyConfiguration(configuration, 'revision')
|
|
assert.equal(JSON.stringify(view).includes('secret'), false)
|
|
})
|
|
|
|
test('rejects unknown schemas, fields, wildcards, URLs, incomplete endpoints, and CIDR in NO_PROXY', () => {
|
|
for (const value of [
|
|
{ ...configured(), schema: 2 },
|
|
{ ...configured(), unexpected: true },
|
|
configured({ proxy: { host: '', port: 1080 } }),
|
|
configured({ noProxy: { user: ['*.example.com'] } }),
|
|
configured({ noProxy: { user: ['https://example.com'] } }),
|
|
configured({ noProxy: { user: ['192.168.0.0/16'] } }),
|
|
]) assert.throws(() => validateProxyConfiguration(value), ProxyConfigurationError)
|
|
})
|
|
|
|
test('enforces SOCKS5 credential byte limits without rejecting valid UTF-8 credentials', () => {
|
|
const boundary = validateProxyConfiguration(configured({ proxy: {
|
|
username: 'u'.repeat(255), password: 'p'.repeat(255), protocol: 'socks5',
|
|
} }))
|
|
assert.equal(Buffer.byteLength(boundary.credentials.username), 255)
|
|
assert.equal(Buffer.byteLength(boundary.credentials.password), 255)
|
|
assert.equal(validateProxyConfiguration(configured({ proxy: {
|
|
username: '代理', password: '密钥', protocol: 'socks5',
|
|
} })).credentials.username, '代理')
|
|
assert.throws(() => validateProxyConfiguration(configured({ proxy: { username: 'u'.repeat(256) } })), ProxyConfigurationError)
|
|
assert.throws(() => validateProxyConfiguration(configured({ proxy: { password: 'p'.repeat(256) } })), ProxyConfigurationError)
|
|
})
|
|
|
|
test('matches exact, suffix, port, IPv6, CIDR, wildcard, and domain boundaries', () => {
|
|
const noProxy = normalizeProxyRules(['example.com', '.example.net', 'service.local:8443', '*'], { allowWildcard: true })
|
|
assert.equal(matchesProxyRules(noProxy, 'anything.invalid', 443), true)
|
|
const precise = normalizeProxyRules(['example.com', '.example.net', 'service.local:8443'])
|
|
assert.equal(matchesProxyRules(precise, 'example.com', 443), true)
|
|
assert.equal(matchesProxyRules(precise, 'sub.example.com', 443), false)
|
|
assert.equal(matchesProxyRules(precise, 'example.net', 443), true)
|
|
assert.equal(matchesProxyRules(precise, 'sub.example.net.', 443), true)
|
|
assert.equal(matchesProxyRules(precise, 'badexample.net', 443), false)
|
|
assert.equal(matchesProxyRules(precise, 'service.local', 8443), true)
|
|
assert.equal(matchesProxyRules(precise, 'service.local', 443), false)
|
|
const bypass = normalizeProxyRules(['192.168.0.0/16', 'fd00::/8', '[::1]:8443'], { allowCidr: true })
|
|
assert.equal(matchesProxyRules(bypass, '192.168.1.4', 80), true)
|
|
assert.equal(matchesProxyRules(bypass, '192.169.1.4', 80), false)
|
|
assert.equal(matchesProxyRules(bypass, 'fd00::1', 80), true)
|
|
assert.equal(matchesProxyRules(bypass, '::1', 8443), true)
|
|
assert.equal(noProxyEnvironment(['.example.com']), '::1,127.0.0.1,localhost,.example.com')
|
|
})
|
|
|
|
test('atomically stores revisions, preserves and clears write-only credentials, and rejects stale writes', async () => {
|
|
const root = await mkdtemp(join(tmpdir(), 'dsh-proxy-state-'))
|
|
const store = new ProxyConfigurationStore(root, { now: () => new Date('2026-08-25T00:00:00.000Z') })
|
|
const initial = await store.load()
|
|
assert.match(initial.revision, /^[A-Za-z0-9_-]{43}$/)
|
|
const saved = await store.commit({ baseRevision: initial.revision, value: configured() })
|
|
assert.equal(saved.credentials.password, 'secret:@/')
|
|
assert.equal((await stat(root)).mode & 0o777, 0o700)
|
|
assert.equal((await stat(join(root, 'revisions', saved.revision, 'credentials.json'))).mode & 0o777, 0o600)
|
|
assert.equal((await readFile(join(root, 'revisions', saved.revision, 'config.json'), 'utf8')).includes('secret'), false)
|
|
|
|
const preserving = configured({ proxy: { password: null } })
|
|
const preserved = await store.commit({ baseRevision: saved.revision, value: preserving })
|
|
assert.equal(preserved.credentials.password, 'secret:@/')
|
|
const clearing = configured({ proxy: { password: null, clearPassword: true } })
|
|
const cleared = await store.commit({ baseRevision: preserved.revision, value: clearing })
|
|
assert.equal(cleared.credentials.password, null)
|
|
assert.equal(cleared.configuration.proxy.passwordConfigured, false)
|
|
await assert.rejects(store.commit({ baseRevision: saved.revision, value: configured() }), error => (
|
|
error.code === 'REVISION_CONFLICT' && error.statusCode === 409
|
|
))
|
|
})
|
|
|
|
test('control API returns only sanitized configuration and hot-activates one current revision', async t => {
|
|
const root = await mkdtemp(join(tmpdir(), 'dsh-proxy-control-'))
|
|
const store = new ProxyConfigurationStore(root)
|
|
let snapshot = Object.freeze({ ...await store.load(), recovery: 'none' })
|
|
let handlesCleared = 0
|
|
const server = createOutboundProxyControl({
|
|
getSnapshot: () => snapshot,
|
|
routeHealth: { status: current => ({ updates: current.configuration.scopes.updates ? 'unknown' : 'direct' }) },
|
|
providerHandles: { clear: () => { handlesCleared += 1 } },
|
|
commitConfiguration: async update => {
|
|
snapshot = Object.freeze({ ...await store.commit(update), recovery: 'none' })
|
|
return snapshot
|
|
},
|
|
})
|
|
const port = await listen(server)
|
|
t.after(() => new Promise(resolve => server.close(resolve)))
|
|
|
|
const initial = await exchange(port, 'GET', '/v1/configuration')
|
|
assert.equal(initial.status, 200)
|
|
assert.equal(initial.body.componentReady, true)
|
|
assert.equal(initial.body.enabled, false)
|
|
assert.equal('credentials' in initial.body, false)
|
|
|
|
const activated = await exchange(port, 'PUT', '/v1/configuration', {
|
|
baseRevision: initial.body.revision,
|
|
value: configured(),
|
|
})
|
|
assert.equal(activated.status, 200)
|
|
assert.equal(activated.body.enabled, true)
|
|
assert.equal(activated.body.proxy.passwordConfigured, true)
|
|
assert.equal(JSON.stringify(activated.body).includes('secret:@/'), false)
|
|
assert.equal(snapshot.credentials.password, 'secret:@/')
|
|
assert.equal(handlesCleared, 1)
|
|
|
|
const stale = await exchange(port, 'PUT', '/v1/configuration', {
|
|
baseRevision: initial.body.revision,
|
|
value: configured(),
|
|
})
|
|
assert.equal(stale.status, 409)
|
|
assert.equal(stale.body.error.code, 'REVISION_CONFLICT')
|
|
assert.equal(snapshot.revision, activated.body.revision)
|
|
})
|
|
|
|
test('control API accepts dynamic model Provider routing before persistence', async t => {
|
|
const current = Object.freeze({
|
|
revision: 'revision-one', recovery: 'none',
|
|
...validateProxyConfiguration(defaultProxyConfiguration()),
|
|
})
|
|
let committed = false
|
|
const server = createOutboundProxyControl({
|
|
getSnapshot: () => current,
|
|
routeHealth: { status: () => ({}) },
|
|
commitConfiguration: async request => {
|
|
committed = true
|
|
return Object.freeze({ revision: 'revision-two', recovery: 'none', ...validateProxyConfiguration(request.value) })
|
|
},
|
|
})
|
|
const port = await listen(server)
|
|
t.after(() => new Promise(resolve => server.close(resolve)))
|
|
const value = configured({ modelApi: { providers: { unsupported: { proxyEnabled: true } } } })
|
|
const result = await exchange(port, 'PUT', '/v1/configuration', { baseRevision: current.revision, value })
|
|
assert.equal(result.status, 200)
|
|
assert.deepEqual(result.body.modelApi.providers.unsupported, { proxyEnabled: true })
|
|
assert.equal(committed, true)
|
|
})
|
|
|
|
test('rejects unreleased legacy Provider policy shapes', () => {
|
|
assert.throws(() => validateProxyConfiguration(configured({
|
|
modelApi: { default: 'direct', providers: {} },
|
|
})), /must be an object/)
|
|
assert.throws(() => validateProxyConfiguration(configured({
|
|
modelApi: { default: { proxyEnabled: false }, providers: { legacy: { followDsh: true, proxyEnabled: true } } },
|
|
})), /unsupported fields: followDsh/)
|
|
})
|
|
|
|
test('Management outbound proxy client preserves structured control errors', async t => {
|
|
const root = await mkdtemp(join(tmpdir(), 'dsh-proxy-client-'))
|
|
const socket = join(root, 'control.sock')
|
|
const server = createServer((_request, response) => {
|
|
response.writeHead(409, { 'content-type': 'application/json' })
|
|
response.end(JSON.stringify({ error: {
|
|
code: 'REVISION_CONFLICT', message: 'proxy configuration changed', stage: 'activate', retryable: true,
|
|
} }))
|
|
})
|
|
await new Promise((resolve, reject) => {
|
|
server.once('error', reject)
|
|
server.listen(socket, resolve)
|
|
})
|
|
t.after(() => new Promise(resolve => server.close(resolve)))
|
|
const client = new OutboundProxyControlClient(socket)
|
|
await assert.rejects(client.updateConfiguration({}), error => (
|
|
error.statusCode === 409 && error.code === 'REVISION_CONFLICT'
|
|
&& error.stage === 'activate' && error.retryable === true
|
|
))
|
|
})
|
|
|
|
test('Management outbound proxy client redacts unavailable control sockets', async () => {
|
|
const root = await mkdtemp(join(tmpdir(), 'dsh-proxy-client-unavailable-'))
|
|
const client = new OutboundProxyControlClient(join(root, 'missing.sock'), { timeoutMs: 100 })
|
|
await assert.rejects(client.configuration(), error => (
|
|
error.statusCode === 503
|
|
&& error.code === 'PROXY_MANAGER_UNAVAILABLE'
|
|
&& error.stage === 'control'
|
|
&& error.retryable === true
|
|
&& !error.message.includes(root)
|
|
&& !error.message.includes('missing.sock')
|
|
))
|
|
})
|
|
|
|
test('recovers the newest intact immutable revision when current state is corrupt', async () => {
|
|
const root = await mkdtemp(join(tmpdir(), 'dsh-proxy-recovery-'))
|
|
let tick = 0
|
|
const store = new ProxyConfigurationStore(root, { now: () => new Date(Date.UTC(2026, 7, 25, 0, 0, tick++)) })
|
|
const initial = await store.load()
|
|
const first = await store.commit({ baseRevision: initial.revision, value: configured() })
|
|
const second = await store.commit({ baseRevision: first.revision, value: configured({ proxy: { port: 1081 } }) })
|
|
await writeFile(join(root, 'revisions', second.revision, 'config.json'), '{}\n')
|
|
const recovered = await store.load()
|
|
assert.equal(recovered.revision, first.revision)
|
|
assert.equal(JSON.parse(await readFile(join(root, 'current.json'), 'utf8')).revision, first.revision)
|
|
})
|
|
|
|
test('returns stable redacted error structures', () => {
|
|
const body = proxyErrorBody(new ProxyConfigurationError('proxy configuration changed', {
|
|
code: 'REVISION_CONFLICT', statusCode: 409, stage: 'activate',
|
|
}))
|
|
assert.deepEqual(body, {
|
|
error: { code: 'REVISION_CONFLICT', message: 'proxy configuration changed', stage: 'activate', retryable: false },
|
|
})
|
|
})
|
|
|
|
test('recovers a corrupt pointer and safely disables proxy when every revision is corrupt', async () => {
|
|
const root = await mkdtemp(join(tmpdir(), 'dsh-proxy-reset-'))
|
|
const store = new ProxyConfigurationStore(root)
|
|
const initial = await store.load()
|
|
await writeFile(join(root, 'revisions', initial.revision, 'credentials.json'), '{broken')
|
|
await writeFile(join(root, 'current.json'), '{broken')
|
|
const recovered = await store.load()
|
|
assert.equal(recovered.recovery, 'reset-disabled')
|
|
assert.equal(recovered.configuration.enabled, false)
|
|
assert.equal(recovered.configuration.proxy.passwordConfigured, false)
|
|
assert.equal((await readFile(join(root, 'revisions', initial.revision, 'credentials.json'), 'utf8')), '{broken')
|
|
const names = await import('node:fs/promises').then(({ readdir }) => readdir(root))
|
|
assert.equal(names.some(name => name.startsWith('current.corrupt.')), true)
|
|
})
|
|
|
|
test('publishes one complete bilingual proxy scope catalog', () => {
|
|
assert.equal(PROXY_SCOPE_CATALOG.schema, 1)
|
|
assert.equal(PROXY_SCOPE_CATALOG.entries.length, 24)
|
|
assert.equal(new Set(PROXY_SCOPE_CATALOG.entries.map(entry => entry.id)).size, 24)
|
|
assert.deepEqual(new Set(PROXY_SCOPE_CATALOG.entries.map(entry => entry.group)), new Set(Object.keys(PROXY_SCOPE_CATALOG.groups)))
|
|
for (const entry of PROXY_SCOPE_CATALOG.entries) {
|
|
assert.equal(typeof entry.source.zh, 'string')
|
|
assert.equal(typeof entry.source.en, 'string')
|
|
assert.equal(typeof entry.detail.zh, 'string')
|
|
assert.equal(typeof entry.detail.en, 'string')
|
|
}
|
|
const pluginTerminal = PROXY_SCOPE_CATALOG.entries.find(entry => entry.id === 'plugin-terminal')
|
|
assert.match(pluginTerminal.detail.zh, /可识别子进程接口/)
|
|
assert.match(pluginTerminal.detail.zh, /共享 DSH/)
|
|
assert.match(pluginTerminal.detail.en, /identifiable subprocess interface/)
|
|
assert.match(pluginTerminal.detail.en, /shared DSH policy/)
|
|
})
|
|
|
|
test('keeps the Bootstrap proxy supervisor alive until an explicit stop', async () => {
|
|
const root = await mkdtemp(join(tmpdir(), 'dsh-proxy-supervisor-'))
|
|
const runRoot = join(root, 'run')
|
|
const socket = join(runRoot, 'proxy-launch.sock')
|
|
await mkdir(runRoot)
|
|
let running = false
|
|
const calls = []
|
|
const server = createServer((request, response) => {
|
|
const chunks = []
|
|
request.on('data', chunk => chunks.push(chunk))
|
|
request.on('end', () => {
|
|
calls.push(`${request.method} ${request.url}`)
|
|
if (request.method === 'POST' && request.url === '/v1/start') running = true
|
|
if (request.method === 'POST' && request.url === '/v1/stop') running = false
|
|
response.writeHead(200, { 'content-type': 'application/json' })
|
|
response.end(JSON.stringify({ running, componentReady: running }))
|
|
})
|
|
})
|
|
await new Promise((resolve, reject) => {
|
|
server.once('error', reject)
|
|
server.listen(socket, resolve)
|
|
})
|
|
const script = new URL('../../control-plane/services/outbound-proxy/supervisor.mjs', import.meta.url).pathname
|
|
const child = spawn(process.execPath, [script], {
|
|
env: {
|
|
...process.env,
|
|
DSH_PLATFORM_DATA: join(root, 'data'),
|
|
DSH_PLATFORM_RUN: runRoot,
|
|
DSH_PROXY_LAUNCH_TOKEN: 'x'.repeat(43),
|
|
},
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
})
|
|
try {
|
|
await new Promise((resolve, reject) => {
|
|
const deadline = setTimeout(() => reject(new Error('proxy supervisor did not start')), 3_000)
|
|
const check = setInterval(() => {
|
|
if (calls.includes('POST /v1/start')) {
|
|
clearInterval(check)
|
|
clearTimeout(deadline)
|
|
resolve()
|
|
}
|
|
}, 20)
|
|
})
|
|
await new Promise(resolve => setTimeout(resolve, 750))
|
|
assert.equal(child.exitCode, null)
|
|
child.kill('SIGTERM')
|
|
const [code] = await once(child, 'exit')
|
|
assert.equal(code, 0)
|
|
assert.equal(calls.includes('POST /v1/stop'), true)
|
|
} finally {
|
|
if (child.exitCode === null) child.kill('SIGKILL')
|
|
await new Promise(resolve => server.close(resolve))
|
|
await rm(root, { recursive: true, force: true })
|
|
}
|
|
})
|
|
|
|
test('stops the Bootstrap proxy supervisor cleanly after its launch socket disappears', async () => {
|
|
const root = await mkdtemp(join(tmpdir(), 'dsh-proxy-supervisor-missing-socket-'))
|
|
const runRoot = join(root, 'run')
|
|
const socket = join(runRoot, 'proxy-launch.sock')
|
|
await mkdir(runRoot)
|
|
let started = false
|
|
const server = createServer((request, response) => {
|
|
request.resume()
|
|
request.on('end', () => {
|
|
if (request.method === 'POST' && request.url === '/v1/start') started = true
|
|
response.writeHead(200, { 'content-type': 'application/json' })
|
|
response.end(JSON.stringify({ running: true, componentReady: true }))
|
|
})
|
|
})
|
|
await new Promise((resolve, reject) => {
|
|
server.once('error', reject)
|
|
server.listen(socket, resolve)
|
|
})
|
|
const script = new URL('../../control-plane/services/outbound-proxy/supervisor.mjs', import.meta.url).pathname
|
|
const child = spawn(process.execPath, [script], {
|
|
env: {
|
|
...process.env,
|
|
DSH_PLATFORM_DATA: join(root, 'data'),
|
|
DSH_PLATFORM_RUN: runRoot,
|
|
DSH_PROXY_LAUNCH_TOKEN: 'x'.repeat(43),
|
|
},
|
|
stdio: ['ignore', 'ignore', 'pipe'],
|
|
})
|
|
const stderr = []
|
|
child.stderr.on('data', chunk => stderr.push(chunk))
|
|
try {
|
|
const deadline = Date.now() + 3_000
|
|
while (!started && Date.now() < deadline) await new Promise(resolve => setTimeout(resolve, 20))
|
|
assert.equal(started, true)
|
|
await new Promise(resolve => setTimeout(resolve, 50))
|
|
await new Promise(resolve => server.close(resolve))
|
|
await rm(socket, { force: true })
|
|
child.kill('SIGTERM')
|
|
const [code] = await once(child, 'exit')
|
|
assert.equal(code, 0)
|
|
assert.equal(Buffer.concat(stderr).toString('utf8'), '')
|
|
} finally {
|
|
if (child.exitCode === null) child.kill('SIGKILL')
|
|
if (server.listening) await new Promise(resolve => server.close(resolve))
|
|
await rm(root, { recursive: true, force: true })
|
|
}
|
|
})
|