## 真机发现(Parallels Win11 ARM,B2 验证过程中撞到)
心跳只发 `ping()` 而**不检查 pong**,且 ws 库默认不会因「ping 无 pong」断开 ——
客户端进程已死(TCP 未发 FIN)时,连接会一直挂在 `states` 里占 `max_connections` 名额。
叠加「有任务/订阅时清扫保守跳过」的既有策略 → `states.size` 虚高 → **把正常控制端挡在外面**
(本次实测就把自己锁在外面了)。
## 改法
- `attachConnection` 里加 `ws.on('pong')`:**客户端回应也算活跃**(更新 lastActiveAt)
- 心跳里:若某连接静默超过 `idle_timeout_ms * 2` → `ws.terminate()`(标准 ws 保活)
→ 僵尸连接不会再永久占位
## B2 三场景真机验证(全部通过)
- ① **握手封禁**:配 `max_attempts=3` → 3 次错误密钥被拒后,**正确密钥也被拒** ✓
- ② **连接上限**:配 `max_connections=6` → 第 1~6 个成功、**第 7 个被拒** ✓
- ③ **空闲断开**:配 `idle_timeout_ms=20000` → 静默 **40 秒**被断开 ✓
(符合 20s 阈值 + 30s 清扫周期)
- 新增 `scripts/verify-b2.mjs`(三模式:idle / maxconn / ban),全部走**真实连接**
## 验证过程中另修的两个自测问题(都记进文档)
1. 脚本默认 URL 写成了 `ws://` —— 被控端 `tls:true`,**必须 wss**(否则 socket hang up)
2. 测 `max_connections` 时值太小会**把自己锁在外面**;且被拒连接会被计入握手失败形成恶性循环
→ 结论:测试要留余量,并给自己配 `auth_ban.whitelist`
## 收尾
测试用的 `security` 段已用**备份恢复**(`agent.json.before-b2`);pong 修复已 `deploy` 到 VM
(指纹 abca2b8168f0)。
156 lines
5.4 KiB
JavaScript
156 lines
5.4 KiB
JavaScript
#!/usr/bin/env node
|
||
/**
|
||
* B2 验证脚本:v23 连接层三防护(真机跑,不是 mock)。
|
||
*
|
||
* 为什么单独写:CLI 的命令都是**短连接**(连完即断),测不出「空闲断开」;
|
||
* 而 `events` 订阅会让服务端认为"该连接有订阅、不能踢"(这是 v23 的保守设计)。
|
||
* 所以需要一个**握手完成后什么都不做**的长连接。
|
||
*
|
||
* 用法:
|
||
* node /tmp/b2-idle.mjs idle # ③ 空闲断开(观察是否 ~idle_timeout 被断)
|
||
* node /tmp/b2-idle.mjs maxconn # ② 连接上限(并发 N+1 个,第 N+1 应被拒)
|
||
*/
|
||
|
||
// 用相对路径:从仓库根跑时解析不到 workspace 包(根 package.json 未声明它)
|
||
import { NodeAgentClient } from '../packages/client/dist/index.js';
|
||
|
||
const URL = process.env.NA_URL ?? 'wss://10.211.55.9:8765' // ⚠️ 必须 wss:被控端 tls=true(写成 ws 会 socket hang up);
|
||
const KEY = process.env.NA_KEY;
|
||
if (!KEY) {
|
||
console.error('需要 NA_KEY 环境变量');
|
||
process.exit(1);
|
||
}
|
||
|
||
const mode = process.argv[2] ?? 'idle';
|
||
const ts = () => new Date().toISOString().slice(11, 19);
|
||
|
||
function mkClient(id) {
|
||
return new NodeAgentClient({
|
||
url: URL,
|
||
key: KEY,
|
||
clientId: id,
|
||
insecure: true,
|
||
handshakeTimeoutMs: 10_000,
|
||
onStateChange: (s) => console.log(`[${ts()}] ${id} state=${s}`),
|
||
onLog: (m) => console.log(`[${ts()}] ${id} log: ${m}`),
|
||
});
|
||
}
|
||
|
||
// ---------------- ③ 空闲断开 ----------------
|
||
async function idleTest() {
|
||
const started = Date.now();
|
||
const c = mkClient('idle-probe');
|
||
try {
|
||
await c.connect();
|
||
} catch (err) {
|
||
console.log(`[${ts()}] ⚠️ 握手失败:${err?.message ?? err}`);
|
||
console.log(`[${ts()}] 提示:若报 socket hang up,通常说明已达 max_connections(先等空闲超时清掉残留)`);
|
||
process.exit(3);
|
||
}
|
||
console.log(`[${ts()}] ${'idle-probe'} 已握手完成(t=0s),之后**不做任何请求、不订阅**`);
|
||
console.log(`[${ts()}] 期待:约 idle_timeout_ms 后被服务端断开(state=closed)`);
|
||
|
||
await new Promise((resolve) => {
|
||
const timer = setInterval(() => {
|
||
const sec = ((Date.now() - started) / 1000).toFixed(0);
|
||
if (sec % 10 === '0') console.log(`[${ts()}] t=${sec}s 仍存活`);
|
||
if (Number(sec) >= 75) {
|
||
clearInterval(timer);
|
||
console.log(`[${ts()}] t=${sec}s —— 75 秒到,**未被断开**(空闲断开未生效或阈值更长)`);
|
||
resolve();
|
||
}
|
||
}, 1000);
|
||
// 订阅状态变化:closed 即被服务端断开
|
||
const orig = c.opts?.onStateChange;
|
||
void orig;
|
||
});
|
||
|
||
try {
|
||
c.close();
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
console.log(`[${ts()}] 结束`);
|
||
process.exit(0);
|
||
}
|
||
|
||
// ---------------- ② 连接上限 ----------------
|
||
async function maxConnTest() {
|
||
const n = Number(process.env.NA_MAX ?? 2);
|
||
const clients = [];
|
||
console.log(`[${ts()}] 尝试建立 ${n + 1} 个并发连接(配置 max_connections=${n})`);
|
||
|
||
for (let i = 1; i <= n + 1; i += 1) {
|
||
const id = `conn-${i}`;
|
||
const c = mkClient(id);
|
||
try {
|
||
await c.connect();
|
||
clients.push(c);
|
||
console.log(`[${ts()}] ✓ 第 ${i} 个连接成功`);
|
||
} catch (err) {
|
||
console.log(`[${ts()}] ✗ 第 ${i} 个连接被拒:${err?.message ?? err}`);
|
||
// 第 n+1 个被拒即为通过
|
||
for (const x of clients) {
|
||
try {
|
||
x.close();
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
}
|
||
const pass = i === n + 1;
|
||
console.log(`[${ts()}] 结论:${pass ? '✓ 连接上限生效' : '⚠️ 被拒的不是第 N+1 个'}`);
|
||
process.exit(pass ? 0 : 2);
|
||
}
|
||
// 稍等,避免瞬时并发被当成同一个
|
||
await new Promise((r) => setTimeout(r, 400));
|
||
}
|
||
|
||
console.log(`[${ts()}] ⚠️ ${n + 1} 个连接全部成功 —— 连接上限未生效`);
|
||
for (const x of clients) {
|
||
try {
|
||
x.close();
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
}
|
||
process.exit(2);
|
||
}
|
||
|
||
|
||
// ---------------- ① 握手封禁 ----------------
|
||
async function banTest() {
|
||
const n = Number(process.env.NA_ATTEMPTS ?? 3);
|
||
console.log(`[${ts()}] 用**错误密钥**连 ${n} 次(配置 max_attempts=${n})`);
|
||
for (let i = 1; i <= n; i += 1) {
|
||
const c = new NodeAgentClient({ url: URL, key: 'WRONG-KEY-' + i, clientId: 'ban-' + i, insecure: true, handshakeTimeoutMs: 8000 });
|
||
try {
|
||
await c.connect();
|
||
console.log(`[${ts()}] ⚠️ 第 ${i} 次错误密钥竟然成功(不应发生)`);
|
||
c.close();
|
||
} catch (err) {
|
||
console.log(`[${ts()}] ✓ 第 ${i} 次被拒:${String(err?.message ?? err).slice(0, 60)}`);
|
||
}
|
||
await new Promise((r) => setTimeout(r, 500));
|
||
}
|
||
console.log(`[${ts()}] 现在用**正确密钥**连(若封禁生效,应当也被拒)`);
|
||
const good = new NodeAgentClient({ url: URL, key: KEY, clientId: 'ban-good', insecure: true, handshakeTimeoutMs: 8000 });
|
||
try {
|
||
await good.connect();
|
||
console.log(`[${ts()}] ✗ 正确密钥连接成功 —— **封禁未生效**`);
|
||
good.close();
|
||
process.exit(2);
|
||
} catch (err) {
|
||
console.log(`[${ts()}] ✓ 正确密钥也被拒:${String(err?.message ?? err).slice(0, 70)}`);
|
||
console.log(`[${ts()}] 结论:✓ 握手失败封禁生效(连正确密钥都被挡)`);
|
||
process.exit(0);
|
||
}
|
||
}
|
||
|
||
if (mode === 'idle') await idleTest();
|
||
else if (mode === 'maxconn') await maxConnTest();
|
||
else if (mode === 'ban') await banTest();
|
||
else {
|
||
console.error('用法: node b2-idle.mjs <idle|maxconn>');
|
||
process.exit(1);
|
||
}
|