Files
dsh/upstream/container/platform/test/proxy-configuration.test.mjs
T

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