diff --git a/apps/agent/src/server.ts b/apps/agent/src/server.ts index 7f48032..712e4b9 100644 --- a/apps/agent/src/server.ts +++ b/apps/agent/src/server.ts @@ -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; // 保守策略:只要还有任务在跑 / 还有事件订阅,就不断任何连接 —— diff --git a/docs/VM-VERIFICATION.md b/docs/VM-VERIFICATION.md index 1450e8a..2779227 100644 --- a/docs/VM-VERIFICATION.md +++ b/docs/VM-VERIFICATION.md @@ -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`** | diff --git a/scripts/verify-b2.mjs b/scripts/verify-b2.mjs new file mode 100644 index 0000000..94fff93 --- /dev/null +++ b/scripts/verify-b2.mjs @@ -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 '); + process.exit(1); +}