fix(v23): 连接保活补 pong 检测(治僵尸连接占名额)+ B2 三防护真机验证通过

## 真机发现(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)。
This commit is contained in:
jkooe committed 2026-10-11 01:13:12 +08:00
1 parent ab7a2b147b
commit fbcb8f8408
3 files changed
+179 -3

No files matched your search

+20 -1
View File
@@ -470,6 +470,13 @@ export function createAgentCore(cfg: AgentConfig): AgentCore {
connId,
lastActiveAt: Date.now(),
});
// v2.0.0 修正:**客户端对 ping 的 pong 回应也算活跃**。
// 之前只更新 message 的活跃时间 → 僵尸连接(TCP 未断但客户端已死)不会被判空闲,
// 会被永久算进 states.size → 并发上限虚高 → **可能把正常控制端挡在外面**。
ws.on('pong', () => {
const st = states.get(ws);
if (st) st.lastActiveAt = Date.now();
});
ws.on('message', (raw: RawData) => {
// v2.0.0:任何入站消息都算活跃(空闲断开以"完全静默"为准,不是"没有调用能力")
const st = states.get(ws);
@@ -548,7 +555,19 @@ export function createAgentServer(cfg: AgentConfig, tls: TlsMaterial | null): Pr
// WebSocket 层保活:30s ping,探测死连接;v23③ 同时做空闲连接清扫
const heartbeat = setInterval(() => {
for (const ws of wss.clients) {
if (ws.readyState === WebSocket.OPEN) ws.ping();
if (ws.readyState !== WebSocket.OPEN) continue;
// v2.0.0 修正(真机踩到):**pong 缺失必须主动 terminate**。
// ws 库默认不会因「ping 无 pong」而断开 —— 客户端进程已死(TCP 未发 FIN)时,
// 连接会一直挂在 states 里,占用 max_connections 名额。
// 判据用与空闲清扫同一个 lastActiveAt(pong 已在上方更新它)。
const st = core.states.get(ws);
if (st && idleTimeoutMs > 0 && Date.now() - st.lastActiveAt > idleTimeoutMs * 2) {
log('warn', `连接无响应(ping 未回 pong 且静默超 ${Math.round((Date.now() - st.lastActiveAt) / 1000)}s)→ 强制断开 ${st.connId}`);
audit({ type: 'session.idle_close', client_id: st.clientId ?? undefined, remote: st.remote, reason: 'unresponsive (no pong)' });
ws.terminate();
continue;
}
ws.ping();
}
if (idleTimeoutMs <= 0) return;
// 保守策略:只要还有任务在跑 / 还有事件订阅,就不断任何连接 ——
+4 -2
View File
@@ -214,7 +214,7 @@ ARM Windows(含 Parallels Win11 ARM)请用 **arm64 包**;用 x64 包会卡
| A 组(`verify.mjs` 17 项) | ✅ 15 通过 / 0 失败 / 1 跳过 |
| B1 v1.5 四项 | ✅ 全过(含 `route=post` 实测命中 138 窗口) |
| B4 锚点端到端 | ✅ 全过(含上传云盘) |
| B2 v23 三防护 | ⏸ 阻塞:需改配置 + restart,而计划任务当前启动失败 |
| B2 v23 三防护 | ✅ **三场景全通过**:① 握手封禁(3 次错误密钥后**连正确密钥也被拒**)② 连接上限(配 6 → 第 7 个被拒)③ 空闲断开(静默 40s 被断,符合 idle 20s + 清扫周期 30s) |
| B3 v26 清理 | ✅ **验证并改进了策略**:实测发现「1 小时」粗阈值下频繁 restart 仍累积 → 改为「**早于本 agent 启动时刻**」精确阈值(部署到 VM 实测:restart 后旧任务被自动清理、宿主未误杀) |
### 实测新增的排查项(补进 §6)
@@ -226,4 +226,6 @@ ARM Windows(含 Parallels Win11 ARM)请用 **arm64 包**;用 x64 包会卡
| 直接跑 `install.ps1` 后找不到 `PSK.txt` | **只有 `install.cmd` 才产生 PSK.txt** | 密钥在 `<数据目录>\agent.json` 的 `key` 字段 |
| 装完输入类能力不可用 | `install.cmd` 不传 `-AllowInput`(默认 false) | 用 `install.ps1 -AllowInput` |
| `nodeagent --node x cmd` 报"未知命令" | `--node` **前置需等号** | 写 `--node=x`,或放命令后 |
| 计划任务启动后端口不通 | 待查(手工跑正常) | `(Get-ScheduledTask -TaskName nodeagent).Actions` |
| 计划任务启动后端口不通 | **node.exe 被手工进程占用**(覆盖失败)+ 8765 被占(任务起来即冲突退出) | 先 `Get-Process node \| Stop-Process -Force` 再解压/启动 |
| 自测时 `socket hang up` | **URL 写成 `ws://` 而服务端是 `wss`**(`tls:true`) | 用 `wss://` |
| 测 `max_connections` 把自己锁在外面 | 测试值太小 + 被拒连接会被计入握手失败 → 恶性循环 | ① 测试时留余量 ② **给自己加 `auth_ban.whitelist`** |
+155
View File
@@ -0,0 +1,155 @@
#!/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);
}