覆盖网络线累积产物(此前只在工作区、未入版本库):
- 新增 relay 子系统 src/net/relay/**(wire/duplex/server/client/dialer/switcher/directory/identity/keys/placement/network/addr-override/main/index)
- 新增 src/worker/relay-tunnel.ts、src/web/routes/overlay.ts
- 新增观测/演练脚本 overlay-probe、overlay-failover-drill、overlay-keyring、overlay-holepunch、overlay-jitter、overlay-wan、overlay-relaykey-add、relay-mem-calibrate
- 新增测试 12 个(relay / relay-failover / remote-spawner / instance-port / overlay-{network,auth,bootstrap,identity} / remote-user-fs 等)
验收基线:npm test = 162 pass / 0 fail / 1 skip;--scene all = 12 PASS / 0 SKIP / 0 FAIL;overlay-probe = 12/12
407 lines
18 KiB
JavaScript
407 lines
18 KiB
JavaScript
/**
|
||
* 覆盖网络 · **② P0-3 名字解析与授权** 单测 —— 逻辑名是不是"唯一入口"、跨网是不是**真拒绝**。
|
||
*
|
||
* ## 这个文件要回答的两个问题(= 交接单 §4 Step 3 的两条 + §5 判据 3)
|
||
* 1. **`place` / `resolve` 的唯一入口收不收逻辑名** `<network_id>/<hostId>`?
|
||
* —— 只收裸 `hostId` 时,网络维度得由**每个调用方**自己补;两张网各有一台 `w-1` 就会**串网**。
|
||
* ⛔ 判据不是"代码里写了逻辑名",而是**拿两张同名 host 实测:各解析各的,谁也不覆盖谁**。
|
||
* 2. **跨网访问在 relay / 控制面被拒**,且**不泄露**目标在哪张网?
|
||
* —— 判据:`u:A` 的节点**看不到、也到不了** `u:B` 的节点;`ops` 对用户网**默认不可见**。
|
||
* "看不到" = 对"在别张网"与"根本不存在"的响应**逐字相同**(拿一串猜测的 hostId 打过来
|
||
* 也枚举不出别张网的成员)。
|
||
*
|
||
* 运行:`node --test test/overlay-auth.test.mjs`(Node ≥ 22;测的是 `lib/` 产物,先 `npm run build`)。
|
||
*
|
||
* @module test/overlay-auth
|
||
*/
|
||
|
||
import assert from 'node:assert/strict'
|
||
import { randomBytes } from 'node:crypto'
|
||
import { createServer as createTcpServer, connect } from 'node:net'
|
||
import { test } from 'node:test'
|
||
|
||
import { RelayClient } from '../lib/net/relay/client.js'
|
||
import { RelayDialer } from '../lib/net/relay/dialer.js'
|
||
import { RelayServer } from '../lib/net/relay/server.js'
|
||
import { OPS_NETWORK, assertSameNetwork, logicalName, parseLogicalName } from '../lib/net/relay/network.js'
|
||
import { agentBaseUrl, agentBaseUrlOf, parseReachability, VIA_LOCAL, VIA_RELAY } from '../lib/net/reachability.js'
|
||
import { LocalRendezvous, ManagerSshRendezvous, RendezvousRegistry } from '../lib/net/rendezvous.js'
|
||
|
||
const BASE = 47000
|
||
const SPAN = 200
|
||
const PATH = '/dshs-relay'
|
||
const U_A = 'u:alpha'
|
||
const U_B = 'u:beta'
|
||
|
||
/* ─────────────────────────── 工具 ─────────────────────────── */
|
||
|
||
/**
|
||
* 在 `[lo, hi)` 里挑一个空闲口,**返回端口号**(不是 server 对象 —— 传错了会变成
|
||
* `ports: [object Object]`,服务端只回一个 `bad-port`,看着像"网络不通")。
|
||
*/
|
||
async function listenInRange(server, lo, hi) {
|
||
for (let p = lo; p < hi; p++) {
|
||
const ok = await new Promise((resolve) => {
|
||
const onErr = () => {
|
||
server.off('error', onErr)
|
||
resolve(false)
|
||
}
|
||
server.once('error', onErr)
|
||
server.listen(p, '127.0.0.1', () => {
|
||
server.off('error', onErr)
|
||
resolve(true)
|
||
})
|
||
})
|
||
if (ok) return server.address().port
|
||
}
|
||
throw new Error(`no free port in ${lo}..${hi}`)
|
||
}
|
||
|
||
async function waitFor(fn, ms = 3000) {
|
||
const until = Date.now() + ms
|
||
while (Date.now() < until) {
|
||
if (fn()) return true
|
||
await new Promise((r) => setTimeout(r, 20))
|
||
}
|
||
return false
|
||
}
|
||
|
||
/**
|
||
* 回显服务:收到的每个 chunk 都回 `<tag>:<原字节>`。
|
||
* ⚠️ 与 `overlay-network.test.mjs` 同款(两个文件口径一致,免得"同一条判据两份行为")。
|
||
*/
|
||
function taggedEcho(tag) {
|
||
return createTcpServer((s) => s.on('data', (c) => s.write(`${tag}:${c.toString('utf8')}`)))
|
||
}
|
||
|
||
/** 拨号流往返:写进去、读回(前缀长度显式传入,避免"读到一半就认为对了")。 */
|
||
function dialRoundTrip(duplex, text, prefixLen = 0, ms = 5000) {
|
||
return new Promise((resolve, reject) => {
|
||
let got = ''
|
||
const timer = setTimeout(() => {
|
||
duplex.destroy()
|
||
reject(new Error(`dial roundTrip timeout (got ${got.length}/${text.length + prefixLen})`))
|
||
}, ms)
|
||
duplex.on('data', (chunk) => {
|
||
got += chunk.toString('utf8')
|
||
if (got.length >= text.length + prefixLen) {
|
||
clearTimeout(timer)
|
||
resolve(got)
|
||
}
|
||
})
|
||
duplex.on('error', (err) => {
|
||
clearTimeout(timer)
|
||
reject(err)
|
||
})
|
||
duplex.write(text)
|
||
})
|
||
}
|
||
|
||
/* ─────────── A1:同网断言(控制面侧那道门的公共件) ─────────── */
|
||
|
||
test('A1 assertSameNetwork:同网静默通过,跨网**抛**且点名两边是哪张网', () => {
|
||
assert.doesNotThrow(() => assertSameNetwork(OPS_NETWORK, OPS_NETWORK))
|
||
assert.doesNotThrow(() => assertSameNetwork(U_A, U_A))
|
||
// 抛而不是回 false:回 false 的写法会让"跨网拒绝"在日志里什么都不留(= 静默失效)
|
||
assert.throws(() => assertSameNetwork(U_A, U_B), /跨网/)
|
||
assert.throws(() => assertSameNetwork(U_A, U_B), new RegExp(U_A))
|
||
assert.throws(() => assertSameNetwork(U_A, U_B), new RegExp(U_B))
|
||
})
|
||
|
||
/* ─────────── A2:place 的唯一入口收逻辑名 ─────────── */
|
||
|
||
test('A2 parseReachability 的第一个参数是**逻辑名**:网络段由它切,调用方不许拼', () => {
|
||
const r = parseReachability(logicalName(U_A, 'w-1'), 'http://127.0.0.1:19000', VIA_RELAY)
|
||
assert.equal(r.hostId, 'w-1', 'hostId 必须是**裸**的(网络另存一栏,两者分开表达)')
|
||
assert.equal(r.networkId, U_A)
|
||
assert.equal(agentBaseUrl(r), 'http://127.0.0.1:19000', '取址与 S0/S2 逐字一致(网络维度不进地址)')
|
||
// 裸 hostId 仍兼容 ⇒ 落 ops(过渡期:现网所有调用方一个字都不用改)
|
||
assert.equal(parseReachability('w-47', 'http://127.0.0.1:19100', VIA_LOCAL).networkId, OPS_NETWORK)
|
||
// 非法网络段 ⇒ **抛**(配错别伪装成"这张网里没有它")
|
||
assert.throws(() => parseReachability('UPPER/w-1', 'http://x:1', VIA_LOCAL), /网络段/)
|
||
})
|
||
|
||
/* ─────────── A3:resolve 的唯一入口收逻辑名,两张同名 host 不互相覆盖 ─────────── */
|
||
|
||
test('A3 两张网各有一台同名 w-1 ⇒ 解析各查各的(键是逻辑名,不是裸 hostId)', async () => {
|
||
const table = new Map([
|
||
[logicalName(OPS_NETWORK, 'w-1'), '127.0.0.1:19001'],
|
||
[logicalName(U_A, 'w-1'), '127.0.0.1:19002'],
|
||
])
|
||
const reg = new RendezvousRegistry([
|
||
new LocalRendezvous((name) => table.get(name)),
|
||
new ManagerSshRendezvous({ target: 't', addressOf: (name) => table.get(name) }),
|
||
])
|
||
for (const impl of [reg.get(VIA_LOCAL), reg.get('manager-ssh')]) {
|
||
const ops = await impl.resolve(logicalName(OPS_NETWORK, 'w-1'))
|
||
const alpha = await impl.resolve(logicalName(U_A, 'w-1'))
|
||
assert.equal(ops.networkId, OPS_NETWORK)
|
||
assert.equal(ops.address, '127.0.0.1:19001', 'ops 的 w-1 拿到的是 ops 的落点')
|
||
assert.equal(alpha.networkId, U_A)
|
||
assert.equal(alpha.address, '127.0.0.1:19002', `${impl.id}:u:alpha 的 w-1 拿到的是 u:alpha 的落点(没被 ops 覆盖)`)
|
||
assert.notEqual(ops.address, alpha.address, '同名 host 分属两张网 ⇒ 落点必须不同')
|
||
}
|
||
// 查表键是逻辑名 —— 这条断言看着废话,但正是"只按裸 hostId 建键"会炸的地方
|
||
assert.equal(await reg.get(VIA_LOCAL).resolve('w-1'), undefined, '裸 hostId 不该命中任何逻辑名条目')
|
||
})
|
||
|
||
/* ─────────── A4:via=relay 时禁止回落到 endpoint ─────────── */
|
||
|
||
test('A4 via=relay 且解析不出落点 ⇒ **抛**(不许回落到 endpoint = relay 落点)', () => {
|
||
const relayHost = { hostId: 'w-106', agentUrl: 'http://127.0.0.1:19000', via: VIA_RELAY }
|
||
assert.throws(() => agentBaseUrlOf(relayHost), /拒绝回落到 endpoint/)
|
||
// 一旦解析成功 ⇒ 照常取址,且**优先**于 endpoint
|
||
const ok = {
|
||
...relayHost,
|
||
reachability: { hostId: 'w-106', networkId: OPS_NETWORK, via: VIA_RELAY, address: '127.0.0.1:42067', scheme: 'http' },
|
||
}
|
||
assert.equal(agentBaseUrlOf(ok), 'http://127.0.0.1:42067')
|
||
// 非 relay 语义(同机直连 / ssh 隧道)照旧回落 endpoint —— 这条不能被误伤
|
||
assert.equal(agentBaseUrlOf({ hostId: 'w-47', agentUrl: 'http://127.0.0.1:19100', via: VIA_LOCAL }), 'http://127.0.0.1:19100')
|
||
assert.equal(agentBaseUrlOf({ hostId: 'w-106', agentUrl: 'http://127.0.0.1:19000' }), 'http://127.0.0.1:19000')
|
||
})
|
||
|
||
/* ─────────── A5:控制面侧的跨网门(RelayDialer 口池) ─────────── */
|
||
|
||
test('A5 RelayDialer:落点口**真的能拨通**(DIAL target 必须是裸 hostId)+ 跨网拒 + 不占槽位', async (t) => {
|
||
const wSecret = randomBytes(32).toString('hex')
|
||
const mSecret = randomBytes(32).toString('hex')
|
||
const logs = []
|
||
const echo = taggedEcho('ops')
|
||
const echoPort = await listenInRange(echo, BASE + 40, BASE + SPAN - 1)
|
||
const srv = new RelayServer({
|
||
port: 0,
|
||
keys: new Map([
|
||
['wx', wSecret],
|
||
['manager', mSecret],
|
||
]),
|
||
instancePortBase: BASE,
|
||
instancePortSpan: SPAN,
|
||
dialers: new Map([[OPS_NETWORK, new Set(['manager'])]]),
|
||
log: (line) => logs.push(line),
|
||
})
|
||
await srv.start()
|
||
const url = `ws://127.0.0.1:${srv.boundPort}${PATH}`
|
||
const worker = new RelayClient({
|
||
url,
|
||
hostId: 'wx',
|
||
secret: wSecret,
|
||
ports: [echoPort],
|
||
networkId: OPS_NETWORK,
|
||
log: () => {},
|
||
})
|
||
const mClient = new RelayClient({
|
||
url,
|
||
hostId: 'manager',
|
||
secret: mSecret,
|
||
ports: [],
|
||
dialer: true,
|
||
networkId: OPS_NETWORK,
|
||
log: (line) => logs.push(`[m] ${line}`),
|
||
})
|
||
assert.equal(mClient.networkId, OPS_NETWORK, '拨号通道声明的网要从客户端读(不另配一份)')
|
||
const dialer = new RelayDialer({ client: mClient, portBase: BASE + 60, portSpan: 8, poolSize: 4, log: (l) => logs.push(l) })
|
||
worker.start()
|
||
mClient.start()
|
||
// ⚠️ **必须 await**:口池绑完之前 `localPortFor()` 恒返回 undefined(失败关闭)——
|
||
// 少了这一句,本条会以"同网拿不到落点"的样子失败,看着像跨网判据炸了,其实是没绑池。
|
||
await dialer.start()
|
||
t.after(async () => {
|
||
worker.stop()
|
||
mClient.stop()
|
||
dialer.close()
|
||
await echo.close()
|
||
await srv.stop()
|
||
})
|
||
assert.ok(await waitFor(() => srv.isOnline('wx', OPS_NETWORK)), `worker 未注册:${logs.slice(-6).join(' | ')}`)
|
||
assert.ok(await waitFor(() => srv.isOnline('manager', OPS_NETWORK)), 'manager 未注册')
|
||
|
||
const name = logicalName(OPS_NETWORK, 'wx')
|
||
const local = dialer.localPortFor(name, echoPort)
|
||
assert.equal(typeof local, 'number', '同网必须给落点')
|
||
|
||
/**
|
||
* 🔴 **本条的承重判据**:真连一次落点口 ⇒ 触发 `onConn` ⇒ 走一次**真 DIAL**。
|
||
*
|
||
* 为什么不能只测 `localPortFor` 的返回值:键从 P0-3 起是**逻辑名**,而 `DIAL.target` 要的是
|
||
* **裸 hostId** —— 漏剥网络段时服务端按 `ops/ops/wx` 找会话 ⇒ 回 `target-offline`
|
||
* (看着像"节点离线",节点其实好好在册)。这个 bug **只测返回值是抓不到的**
|
||
* (2026-09-17 线上实测踩过一次:`refused: 8 / streamsOpened: 0`)。
|
||
*/
|
||
const sock = connect(local, '127.0.0.1')
|
||
const got = await new Promise((resolve, reject) => {
|
||
let acc = ''
|
||
const timer = setTimeout(() => {
|
||
sock.destroy()
|
||
reject(new Error(`落点口未拨通,最近日志:${logs.slice(-6).join(' | ')}`))
|
||
}, 3000)
|
||
sock.on('data', (b) => {
|
||
acc += b.toString('utf8')
|
||
if (acc.length >= 'ops:ping'.length) {
|
||
clearTimeout(timer)
|
||
resolve(acc)
|
||
}
|
||
})
|
||
sock.on('error', (e) => {
|
||
clearTimeout(timer)
|
||
reject(e)
|
||
})
|
||
sock.on('connect', () => sock.write('ping'))
|
||
})
|
||
assert.equal(got, 'ops:ping', '落点口必须真的把字节送到 worker 的本地端口(⇒ DIAL target 是裸 hostId)')
|
||
sock.destroy()
|
||
|
||
// 跨网:**回 undefined**(失败关闭),不是"给个口让它去撞"
|
||
assert.equal(dialer.localPortFor(logicalName(U_A, 'wx'), echoPort), undefined)
|
||
assert.ok(
|
||
logs.some((l) => l.includes('跨网拒绝') && l.includes(U_A)),
|
||
`跨网拒绝必须留日志(点名两边是哪张网),实测:${logs.join(' | ')}`,
|
||
)
|
||
// 不占槽位:同网再取还是**同一个**口(池大小为 4,若被跨网请求污染,这里会变成新口)
|
||
assert.equal(dialer.localPortFor(name, echoPort), local)
|
||
})
|
||
|
||
/* ─────────── A6:合体判据 —— 看不到 + 到不了(真 server / 真拨号) ─────────── */
|
||
|
||
test('A6 跨网:到不了,且**分辨不出**"在别张网"与"根本不存在"(逐字相同的响应)', async (t) => {
|
||
const secrets = {
|
||
alpha: randomBytes(32).toString('hex'),
|
||
beta: randomBytes(32).toString('hex'),
|
||
alphaDialer: randomBytes(32).toString('hex'),
|
||
betaDialer: randomBytes(32).toString('hex'),
|
||
opsManager: randomBytes(32).toString('hex'),
|
||
}
|
||
const logs = []
|
||
const echoA = taggedEcho('alpha')
|
||
const echoB = taggedEcho('beta')
|
||
const portA = await listenInRange(echoA, BASE + 20, BASE + SPAN - 1)
|
||
const portB = await listenInRange(echoB, BASE + 21, BASE + SPAN - 1)
|
||
const srv = new RelayServer({
|
||
port: 0,
|
||
/**
|
||
* ⚠️ **序③ 起:密钥表是成员资格的唯一判据** —— 键用**逻辑名** `<网>/<hostId>`(`keys.ts`),
|
||
* 所以"这台 host 属于哪张网"由**配置**决定,而不是由 HELLO 的声明决定。
|
||
* ⇒ 本测试里每台 host 都按**它真正所在的网**登记;声明别的网会被 `network-mismatch`
|
||
* 挡在 HELLO 那一步(比 DIAL 那道门更早,也更省一次会话)。
|
||
* 「两张网各有一台同名 `w-1` 且互不覆盖」由 **A3** 在解析入口上判(唯一入口),
|
||
* 由 **overlay-network.U5** 在真连接上判(那里两张网各有自己的 `w-1` 登记项)。
|
||
*/
|
||
keys: new Map([
|
||
[logicalName(U_A, 'aw1'), secrets.alpha],
|
||
[logicalName(U_B, 'bw1'), secrets.beta],
|
||
[logicalName(U_A, 'ad'), secrets.alphaDialer],
|
||
[logicalName(U_B, 'bd'), secrets.betaDialer],
|
||
[logicalName(OPS_NETWORK, 'manager'), secrets.opsManager],
|
||
]),
|
||
instancePortBase: BASE,
|
||
instancePortSpan: SPAN,
|
||
/** 三张网**各自都有合法拨号方** ⇒ 拒绝只能来自网络维度本身,不是"忘了配白名单"。 */
|
||
dialers: new Map([
|
||
[OPS_NETWORK, new Set(['manager'])],
|
||
[U_A, new Set(['ad'])],
|
||
[U_B, new Set(['bd'])],
|
||
]),
|
||
log: (line) => logs.push(line),
|
||
})
|
||
await srv.start()
|
||
const url = `ws://127.0.0.1:${srv.boundPort}${PATH}`
|
||
|
||
const clients = []
|
||
const mkNode = (hostId, secret, network, ports) => {
|
||
const c = new RelayClient({ url, hostId, secret, ports, networkId: network, log: () => {} })
|
||
clients.push(c)
|
||
c.start()
|
||
return c
|
||
}
|
||
const mkDialer = (hostId, secret, network) => {
|
||
const c = new RelayClient({
|
||
url,
|
||
hostId,
|
||
secret,
|
||
ports: [],
|
||
dialer: true,
|
||
networkId: network,
|
||
log: (line) => logs.push(`[${hostId}] ${line}`),
|
||
})
|
||
clients.push(c)
|
||
c.start()
|
||
return c
|
||
}
|
||
mkNode('aw1', secrets.alpha, U_A, [portA])
|
||
mkNode('bw1', secrets.beta, U_B, [portB])
|
||
const ad = mkDialer('ad', secrets.alphaDialer, U_A)
|
||
mkDialer('bd', secrets.betaDialer, U_B)
|
||
mkDialer('manager', secrets.opsManager, OPS_NETWORK)
|
||
|
||
t.after(async () => {
|
||
for (const c of clients) c.stop()
|
||
await echoA.close()
|
||
await echoB.close()
|
||
await srv.stop()
|
||
})
|
||
|
||
assert.ok(await waitFor(() => srv.isOnline('aw1', U_A)), `u:alpha 的节点未注册:${logs.slice(-8).join(' | ')}`)
|
||
assert.ok(await waitFor(() => srv.isOnline('bw1', U_B)), `u:beta 的节点未注册:${logs.slice(-8).join(' | ')}`)
|
||
assert.ok(await waitFor(() => srv.isOnline('ad', U_A)), 'u:alpha 拨号方未注册')
|
||
assert.ok(await waitFor(() => srv.isOnline('bd', U_B)), 'u:beta 拨号方未注册')
|
||
|
||
// ── 正证:同网全通(隔离挡的是"跨网",不是"拨号"本身) ──
|
||
const stream = await ad.openStream('aw1', portA)
|
||
assert.equal(
|
||
await dialRoundTrip(stream, 'ping-alpha', 'alpha:'.length),
|
||
'alpha:ping-alpha',
|
||
'同网必须真的通(且字节走的是 u:alpha 自己那台,不是别网的同名机)',
|
||
)
|
||
|
||
// ── 反证 ①:到不了 —— `u:alpha` 的拨号方够不到只在 `u:beta` 里的节点 ──
|
||
let errForeign = ''
|
||
await assert.rejects(
|
||
() => ad.openStream('bw1', portB),
|
||
(e) => {
|
||
errForeign = e instanceof Error ? e.message : String(e)
|
||
return true
|
||
},
|
||
'u:alpha 拨号方不得够到 u:beta 的节点',
|
||
)
|
||
|
||
// ── 反证 ②:**看不到** —— "在别张网"与"根本不存在"的响应**逐字相同** ──
|
||
// 拿一个哪儿都没有的 hostId 打一次:两者若不同形,就能拿一串猜测的 hostId 把别张网**枚举**出来。
|
||
let errGhost = ''
|
||
await assert.rejects(
|
||
() => ad.openStream('definitely-not-a-host', portB),
|
||
(e) => {
|
||
errGhost = e instanceof Error ? e.message : String(e)
|
||
return true
|
||
},
|
||
'不存在的 hostId 必须被拒',
|
||
)
|
||
const norm = (m) => m.replaceAll('bw1', 'X').replaceAll('definitely-not-a-host', 'X')
|
||
assert.equal(
|
||
norm(errForeign),
|
||
norm(errGhost),
|
||
`"在别张网"与"不存在"必须逐字同形(否则可枚举对端清单):\n 在别网=${errForeign}\n 不存在=${errGhost}`,
|
||
)
|
||
// 响应里不得出现任何网络名(`u:beta` 一旦出现 ⇒ 对端清单泄露)
|
||
assert.equal(errForeign.includes(U_B), false, `响应不得含网络名,实测:${errForeign}`)
|
||
assert.equal(errForeign.includes('dialer-not-in-network'), false, `对外不得有跨网专属拒码,实测:${errForeign}`)
|
||
|
||
// ── 反证 ③:`ops` 对用户网**默认不可见**(反向也一样) ──
|
||
// 日志里必须留得下区分(服务端可取证)—— 这是"看不到"与"查得到"的平衡点
|
||
assert.ok(
|
||
logs.some((l) => l.includes('dialer-not-in-network') && l.includes(U_B)),
|
||
`relay 日志必须能区分"在别张网",实测:${logs.filter((l) => l.includes('DIAL')).slice(-6).join(' | ')}`,
|
||
)
|
||
})
|
||
|
||
/* ─────────── A7:范围声明(Step 3 只做 P0-3) ─────────── */
|
||
|
||
test('A7 范围:本步只做"逻辑名 + 授权",不碰 L3 / DNS / 对端清单(D1 / D4)', async () => {
|
||
const net = await import('../lib/net/relay/network.js')
|
||
assert.equal(typeof net.logicalName, 'function')
|
||
assert.deepEqual(parseLogicalName(`${U_A}/w-1`), { network: U_A, hostId: 'w-1' })
|
||
// ⛔ 本阶段刻意**没有**的东西:地址段分配 / DNS 名 / 对端清单查询
|
||
assert.equal(net.assignAddress, undefined, 'P0-3 不做 L3 地址分配(D1:推迟到 L3 专项)')
|
||
assert.equal(net.resolveDns, undefined, 'P0-3 不自建 DNS(D1)')
|
||
assert.equal(net.listPeers, undefined, 'P0-3 不下发对端清单(D4)')
|
||
})
|