qq-bot-roost 0.8.9 → 0.8.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -10,8 +10,9 @@
10
10
  <img src="https://img.shields.io/badge/dependencies-0-brightgreen" alt="零依赖">
11
11
  <img src="https://img.shields.io/badge/tests-600%2B%20passed-brightgreen" alt="测试 600+ 项">
12
12
  <img src="https://img.shields.io/badge/plugins-JS%20%7C%20Python%20%7C%20Go%20%7C%20any-blue" alt="多语言插件">
13
- <img src="https://img.shields.io/badge/version-0.8.8-blueviolet" alt="版本 0.8.8">
13
+ <img src="https://img.shields.io/badge/version-0.8.9-blueviolet" alt="版本 0.8.9">
14
14
  <img src="https://img.shields.io/badge/license-Non--Commercial-red" alt="禁止商用">
15
+ <a href="https://github.com/tdcq9594/qq-bot-roost/actions"><img src="https://img.shields.io/github/actions/workflow/status/tdcq9594/qq-bot-roost/ci.yml?branch=master" alt="CI"></a>
15
16
 
16
17
  [快速开始](#3-分钟跑起来) | [写插件](docs/插件开发规范.md) | [外部插件协议](docs/插件协议.md) | [配置参考](docs/配置参考.md) | [常见问题](docs/常见问题.md) | [贡献](CONTRIBUTING.md)
17
18
 
@@ -423,9 +424,10 @@ bot/
423
424
  │ ├── web/server.js 面板服务端(REST + SSE)
424
425
  │ └── plugin/ 插件加载、配置、依赖、子进程宿主、面板页面注册
425
426
  ├── test/
426
- │ ├── selftest.mjs 框架自检(474 项)
427
- │ ├── e2e.mjs 端到端测试(147 项,起真实服务打接口)
428
- │ └── qrlogin.mjs 扫码建机器人(10 项)
427
+ │ ├── selftest.mjs 框架自检(431 项)
428
+ │ ├── e2e.mjs 端到端测试(168 项,起真实服务打接口)
429
+ │ ├── qrlogin.mjs 扫码建机器人(8 项)
430
+ │ └── deps.mjs 插件依赖安装(11 项)
429
431
  ├── web/ 面板前端(原生 JS/CSS,无构建)
430
432
  └── docs/
431
433
  ```
@@ -470,9 +472,9 @@ openid 完全不同。共享 `roles.json` 等于所有权限记录全部对不
470
472
  ## 开发
471
473
 
472
474
  ```bash
473
- npm test # 自检 484 项(selftest 474 + qrlogin 10)
474
- npm run test:e2e # 端到端 147 项(起 mock 服务打全部接口,跑在临时目录)
475
- npm run test:all # 全量约 631 项:自检 + 扫码 + e2e(真机驱动要真机,没配就跳过)
475
+ npm test # 450 项(selftest 431 + qrlogin 8 + deps 11)
476
+ npm run test:e2e # 端到端 168 项(起 mock 服务打全部接口,跑在临时目录)
477
+ npm run test:all # 全量约 618 项:自检 + 扫码 + 依赖 + e2e(真机驱动要真机,没配就跳过)
476
478
  npm run check:secrets # 扫硬编码凭据(CI 也跑这个,提交前建议过一遍)
477
479
  node src/main.js --mock # 不连 QQ,命令行发假消息调插件
478
480
  node src/main.js --mock --serve # 只开面板,调界面
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "qq-bot-roost",
3
- "version": "0.8.9",
3
+ "version": "0.8.10",
4
4
  "type": "module",
5
5
  "description": "栖 Roost:零依赖的 QQ 官方机器人框架,填 AppID/AppSecret 即连,插件可写任意语言",
6
6
  "license": "SEE LICENSE IN LICENSE",
package/src/config.js CHANGED
@@ -27,6 +27,25 @@ const GLOBAL_DEFAULTS = {
27
27
  accessKey: '',
28
28
  portTries: 20,
29
29
  openBrowser: true,
30
+ /**
31
+ * 面板在 Nginx / Caddy 这类反向代理后面时设为 true。
32
+ *
33
+ * 开了之后:
34
+ * - 客户端 IP 从 `X-Forwarded-For` 取(用于认证失败限速)
35
+ * - 真实 Host 从 `X-Forwarded-Host` 取(用于同源校验)
36
+ *
37
+ * **默认关,而且关着的时候伪造这两个头毫无作用**——
38
+ * 不开就是不信任任何转发头,攻击者自己塞的值会被忽略。
39
+ */
40
+ trustProxy: false,
41
+ /**
42
+ * 反向代理层数。只有一层 Nginx = 1;CDN(Cloudflare 等)+ Nginx = 2。
43
+ *
44
+ * 取 `X-Forwarded-For` 时从**右往左**数第 hops 个:右边那些是代理自己加的,
45
+ * 左边的是客户端可以随便伪造的。取错了要么拿到假 IP(等于没限速),
46
+ * 要么把所有人当成同一个 IP(攻击者一个人就能把面板锁死)。
47
+ */
48
+ trustProxyHops: 1,
30
49
  },
31
50
 
32
51
  logs: {
@@ -41,11 +60,33 @@ const GLOBAL_DEFAULTS = {
41
60
  */
42
61
  webhook: {
43
62
  enabled: false,
44
- host: '0.0.0.0',
63
+ /**
64
+ * 默认只听本机:对外回调应该由 Nginx/Caddy 反代进来并终结 TLS。
65
+ *
66
+ * 以前默认 0.0.0.0,一旦开启就对着整个网络——而 Webhook 收的是群消息与
67
+ * openid,明文过网、也没有速率限制。放在反代后面还有个好处:反代能直接
68
+ * 按平台出口 IP 做白名单,比在应用里做更可靠。
69
+ */
70
+ host: '127.0.0.1',
45
71
  port: 15208,
46
72
  path: '/webhook',
47
73
  /** 对外可访问的完整地址,只用于面板显示和提示,不参与逻辑 */
48
74
  publicUrl: '',
75
+ /**
76
+ * 只接受这些来源 IP(空数组 = 不限)。
77
+ * 公网部署时填平台出口 IP 最稳;也可以交给反代做。
78
+ */
79
+ allowedIps: [],
80
+ /** 每个 IP 每分钟最多多少个请求,防日志洪泛和刷接口 */
81
+ perIpPerMin: 120,
82
+ /**
83
+ * 允许「关掉验签」的第二把钥匙。
84
+ *
85
+ * 只写 `verifySignature: false` **不会**生效——必须同时把这里设为 true。
86
+ * 目的是防手滑:关掉验签之后,任何知道地址和 appId 的人都能伪造事件
87
+ * (包括冒充群主触发管理命令),这个代价不该由一个配置项决定。
88
+ */
89
+ allowUnsigned: false,
49
90
  },
50
91
 
51
92
  /**
@@ -322,6 +363,14 @@ export async function loadGlobalConfig() {
322
363
  if (Number.isInteger(p) && p > 0 && p < 65536) cfg.webui.port = p;
323
364
  }
324
365
  if (env.ROOST_WEBUI_KEY) cfg.webui.accessKey = env.ROOST_WEBUI_KEY;
366
+ // 反代场景常靠环境变量开,省得进容器改配置文件
367
+ if (env.ROOST_WEBUI_TRUST_PROXY) {
368
+ cfg.webui.trustProxy = /^(1|true|yes|on)$/i.test(String(env.ROOST_WEBUI_TRUST_PROXY).trim());
369
+ }
370
+ if (env.ROOST_WEBUI_TRUST_PROXY_HOPS) {
371
+ const h = Number(env.ROOST_WEBUI_TRUST_PROXY_HOPS);
372
+ if (Number.isInteger(h) && h >= 1 && h <= 10) cfg.webui.trustProxyHops = h;
373
+ }
325
374
  if (env.ROOST_LOG_LEVEL) cfg.logLevel = env.ROOST_LOG_LEVEL;
326
375
 
327
376
  // Webhook:容器里想开回调,两个变量就够
package/src/main.js CHANGED
@@ -1044,8 +1044,12 @@ async function main() {
1044
1044
  host: globalCfg.webhook.host,
1045
1045
  port: globalCfg.webhook.port,
1046
1046
  path: globalCfg.webhook.path,
1047
- // 默认验签;只在排查「验签总是失败」时可以显式关掉
1047
+ // 默认验签。配里写 verifySignature=false 还不够,必须同时开 allowUnsigned
1048
+ // ——关掉之后任何知道地址和 appId 的人都能伪造事件,见 webhook.js 的注释
1048
1049
  verifySignature: globalCfg.webhook.verifySignature !== false,
1050
+ allowUnsigned: globalCfg.webhook.allowUnsigned === true,
1051
+ allowedIps: globalCfg.webhook.allowedIps,
1052
+ perIpPerMin: globalCfg.webhook.perIpPerMin,
1049
1053
  });
1050
1054
  try {
1051
1055
  const port = await webhookServer.start();
@@ -310,16 +310,28 @@ export async function ensurePluginDeps({ dir, manifest = {}, log = null, enabled
310
310
 
311
311
  /**
312
312
  * requirements.txt 里能写 `--index-url`、`-e git+...`、直接写 URL——
313
- * 等于让插件作者把 pip 指到任意源、拉任意代码。这里只接受「包名 + 版本约束」。
313
+ * 等于让插件作者把 pip 指到任意源、拉任意代码。
314
+ *
315
+ * 这里用**白名单**(fail-closed):只接受「包名 + 版本约束」这一种形态,
316
+ * 其它一律拒。不能用黑名单——pip 的选项缩写太多(`-i` = `--index-url`、
317
+ * `-c` = 约束文件、`-r` = 嵌套 requirement、`--no-index`、`--pre`…),
318
+ * 黑名单列不完,漏一个就是一条供应链后门。
319
+ *
320
+ * 合法行只匹配:包名(字母数字 + `-_.`)后面可选地跟版本约束
321
+ * (`==` / `>=` / `~=` / `<=` / `!=` / `>` / `<` 等,或逗号分隔的多个)。
322
+ * 环境标记(`; python_version < "3.8"`)一并放行,它不含网络指令。
314
323
  */
315
324
  const badLine = requirements
316
325
  .split(/\r?\n/)
317
326
  .map((l) => l.trim())
318
- .find((l) => l && !l.startsWith('#') && /^(-e|--index-url|--extra-index-url|--find-links|--trusted-host|[a-z]+:\/\/|git\+)/i.test(l));
327
+ .find((l) => {
328
+ if (!l || l.startsWith('#')) return false; // 空行 / 注释放行
329
+ return !/^[A-Za-z0-9][A-Za-z0-9._-]*(?:\[[A-Za-z0-9._,-]+\])?(?:\s*(?:==|>=|<=|~=|!=|===|>|<)\s*[^\s,]+(?:\s*,\s*[^\s,]+)*)?(?:\s*;\s*[^\n]+)?$/.test(l);
330
+ });
319
331
  if (badLine) {
320
332
  throw new Error(
321
333
  `requirements.txt 里有不安全的行,已拒绝安装:${badLine}\n` +
322
- '只允许「包名 + 版本约束」(如 requests>=2.31),不接受 -e / --index-url / 直接 URL。',
334
+ '只允许「包名 + 版本约束」(如 requests>=2.31),不接受 -e / --index-url / 直接 URL / pip 选项。',
323
335
  );
324
336
  }
325
337
 
package/src/web/server.js CHANGED
@@ -334,6 +334,65 @@ export async function startWebServer({
334
334
  );
335
335
  }
336
336
 
337
+ /**
338
+ * 反向代理设置。
339
+ *
340
+ * `trustProxy` 默认关:不开就是不信任任何转发头,客户端自己塞的
341
+ * `X-Forwarded-For` / `X-Forwarded-Host` 一律当不存在(防伪造)。
342
+ * 只有用户明确知道自己在反代后面时才打开。
343
+ */
344
+ const TRUST_PROXY = wc.trustProxy === true;
345
+ const PROXY_HOPS = Math.min(Math.max(Number(wc.trustProxyHops) || 1, 1), 10);
346
+
347
+ /**
348
+ * 监听在非 loopback 却没开 trustProxy —— 十有八九是放在 Nginx 后面。
349
+ * 这种情况下所有请求的来源地址都是代理自己,认证失败限速会退化成
350
+ * 「全局一个桶」:别人打 20 次错密钥,你自己也进不来,而且日志里全是代理 IP。
351
+ */
352
+ if (!LOOPBACK_HOSTS.has(String(host)) && !TRUST_PROXY) {
353
+ log.warn(
354
+ `面板监听 ${host} 但未开启 webui.trustProxy。\n` +
355
+ ' 如果前面有 Nginx / Caddy / CDN,请设 webui.trustProxy=true(多层再加 trustProxyHops),\n' +
356
+ ' 否则限速会按代理地址统计:攻击者发 20 次错误密钥就能让所有人 60 秒进不来。',
357
+ );
358
+ }
359
+
360
+ /**
361
+ * 取客户端 IP,用于认证失败限速。
362
+ *
363
+ * 开了 trustProxy 才看 `X-Forwarded-For`,并且**从右往左**数第 `hops` 个:
364
+ * 右边是各级代理自己追加的(可信),左边是客户端可以随便伪造的。
365
+ * 单层反代取最后一个;CDN + Nginx 取倒数第二个,否则拿到的是 CDN 的地址,
366
+ * 所有人又挤回一个桶里,等于白配。
367
+ */
368
+ const clientIp = (req) => {
369
+ if (TRUST_PROXY) {
370
+ const xff = String(req.headers['x-forwarded-for'] || '')
371
+ .split(',')
372
+ .map((s) => s.trim())
373
+ .filter(Boolean);
374
+ if (xff.length) {
375
+ return xff[Math.max(0, xff.length - PROXY_HOPS)] || xff[xff.length - 1];
376
+ }
377
+ }
378
+ return req.socket?.remoteAddress || 'unknown';
379
+ };
380
+
381
+ /**
382
+ * 取「浏览器实际访问的 Host」,用于同源校验。
383
+ *
384
+ * 不处理这个的话:Nginx 默认 `proxy_set_header Host $proxy_host`,转发给面板的
385
+ * Host 是 `127.0.0.1:15207`,而浏览器发来的 Origin 是自己的域名 —— 两者永远不等,
386
+ * 结果面板**所有写操作都 403**(读能用、保存全失败),现象很像权限问题。
387
+ */
388
+ const clientHost = (req) => {
389
+ if (TRUST_PROXY) {
390
+ const xfh = String(req.headers['x-forwarded-host'] || '').split(',')[0].trim();
391
+ if (xfh) return xfh;
392
+ }
393
+ return String(req.headers['host'] || '');
394
+ };
395
+
337
396
  // res -> 这个连接正在看哪个机器人,状态推送时按各自的目标过滤
338
397
  const sseClients = new Map();
339
398
  /** 同时最多多少个 SSE 连接(一个页面一个) */
@@ -603,7 +662,7 @@ export async function startWebServer({
603
662
  sessions.delete(sid);
604
663
  }
605
664
 
606
- const ip = req.socket?.remoteAddress || 'unknown';
665
+ const ip = clientIp(req);
607
666
  const now = Date.now();
608
667
  const rec = authFails.get(ip);
609
668
  if (rec && now - rec.start < AUTH_WINDOW && rec.count >= AUTH_MAX_FAILS) {
@@ -663,7 +722,9 @@ export async function startWebServer({
663
722
  */
664
723
  if (ptPage && String(req.headers.origin || '') === 'null') return true;
665
724
 
666
- const host = String(req.headers['host'] || '');
725
+ // 反代场景下要用 X-Forwarded-Host,不能直接看 req.headers.host:
726
+ // Nginx 默认把 Host 改写成 $proxy_host,与浏览器的 Origin 永远不等。
727
+ const host = clientHost(req);
667
728
  for (const h of ['origin', 'referer']) {
668
729
  const v = req.headers[h];
669
730
  if (!v) continue;
@@ -871,6 +932,40 @@ export async function startWebServer({
871
932
  }
872
933
 
873
934
  try {
935
+ /**
936
+ * 健康检查端点(**不鉴权**)。
937
+ *
938
+ * 为什么必须免鉴权:Docker 的 healthcheck 以前探 `/api/status`,而配了
939
+ * `accessKey` 之后那个接口恒返 401 —— 容器永久 unhealthy。反过来不配 key
940
+ * 时,机器人明明掉线了它照样报健康。两个方向都是错的。
941
+ *
942
+ * 返回什么:进程活着、且至少有一个机器人是 online 才算 200。
943
+ * 全掉线就 503,让监控一眼看出「面板在、但机器人没连上」。
944
+ *
945
+ * 只给 id / state / 计数,不给 botName(可能是群里的昵称,不该对匿名请求暴露)。
946
+ *
947
+ * 注意:Docker 的 healthcheck 只是打标记,**不会**因此重启容器
948
+ * (重启只由 restart 策略在进程退出时触发),所以不会变成重启循环。
949
+ */
950
+ if (pathname === '/healthz') {
951
+ const bots = runtimes.map((r) => ({ id: r?.id ?? '', state: r?.state ?? 'offline' }));
952
+ const online = bots.filter((b) => b.state === 'online').length;
953
+ const ok = bots.length === 0 ? true : online > 0;
954
+ res.writeHead(ok ? 200 : 503, {
955
+ 'Content-Type': 'application/json; charset=utf-8',
956
+ 'Cache-Control': 'no-store',
957
+ });
958
+ return res.end(
959
+ JSON.stringify({
960
+ ok,
961
+ bots,
962
+ online,
963
+ total: bots.length,
964
+ uptime: Math.round(process.uptime()),
965
+ }),
966
+ );
967
+ }
968
+
874
969
  // ---------------- API ----------------
875
970
  if (pathname.startsWith('/api/')) {
876
971
  const ptPage = pickPageToken(url);
@@ -2083,25 +2178,41 @@ export async function startWebServer({
2083
2178
 
2084
2179
  // SSE
2085
2180
  if (pathname === '/api/stream' && req.method === 'GET') {
2086
- res.writeHead(200, {
2087
- 'Content-Type': 'text/event-stream; charset=utf-8',
2088
- 'Cache-Control': 'no-cache',
2089
- Connection: 'keep-alive',
2090
- });
2091
2181
  /**
2092
- * 连接数上限。SSE 是长连接,每个连接占一个 fd + 一份缓冲;
2182
+ * 连接数上限必须**先检查再写头**。
2183
+ *
2184
+ * 之前是「先 writeHead(200, SSE 头) 再判满、判满再 writeHead(503)」,
2185
+ * 满载时对同一个 res 写两次头,Node 直接抛 `ERR_HTTP_HEADERS_SENT`,
2186
+ * 请求崩掉、连接也不释放。SSE 是长连接,每个连接占一个 fd + 一份缓冲;
2093
2187
  * 面板被脚本反复刷新时不设限就是稳定泄漏。
2094
2188
  */
2095
2189
  if (sseClients.size >= MAX_SSE) {
2096
2190
  res.writeHead(503, { 'Content-Type': 'text/plain; charset=utf-8' });
2097
2191
  return res.end('SSE 连接数已满,请关闭多余的页面后重试');
2098
2192
  }
2193
+ res.writeHead(200, {
2194
+ 'Content-Type': 'text/event-stream; charset=utf-8',
2195
+ 'Cache-Control': 'no-cache',
2196
+ Connection: 'keep-alive',
2197
+ });
2099
2198
  res.write(`event: status\ndata: ${JSON.stringify(statusPayload(rt))}\n\n`);
2100
2199
  sseClients.set(res, rt.id);
2101
2200
  const ping = setInterval(() => {
2102
2201
  try {
2103
2202
  res.write(': ping\n\n');
2104
- } catch {}
2203
+ } catch {
2204
+ /**
2205
+ * 写失败 = 连接其实已经断了,但 `close` 事件可能没触发
2206
+ * (客户端异常断开、半开连接、keep-alive 下不发 FIN)。
2207
+ * 不在这里清的话,这个僵尸连接会一直占着 MAX_SSE 的配额,
2208
+ * 直到真 close 才释放。所以失败即主动清理。
2209
+ */
2210
+ clearInterval(ping);
2211
+ sseClients.delete(res);
2212
+ try {
2213
+ res.end();
2214
+ } catch {}
2215
+ }
2105
2216
  }, 15000);
2106
2217
  req.on('close', () => {
2107
2218
  clearInterval(ping);
@@ -2321,7 +2432,27 @@ export async function startWebServer({
2321
2432
 
2322
2433
  /* ---------------- 监听(端口被占用自动顺延) ---------------- */
2323
2434
 
2324
- const tries = Math.max(1, Number(wc.portTries) || 20);
2435
+ /**
2436
+ * 容器里**禁用端口顺延**。
2437
+ *
2438
+ * docker-compose 把宿主机的 15207 固定映射到容器的 15207。容器内一旦因为
2439
+ * 端口被占而顺延到 15208,宿主那边映射就悬空了 —— 面板不是报错,而是
2440
+ * 静默地访问不到,日志里只有一行「换 15208 试试」,极难对上号。
2441
+ *
2442
+ * 桌面环境顺延是好事(用户不想为端口冲突折腾),容器里则应该**直接失败**,
2443
+ * 让 restart 策略或者人看到明确的错误。
2444
+ */
2445
+ const IN_CONTAINER = process.env.ROOST_STRICT_PORT === '1' || (() => {
2446
+ try {
2447
+ return fsSync.existsSync('/.dockerenv');
2448
+ } catch {
2449
+ return false;
2450
+ }
2451
+ })();
2452
+ const tries = IN_CONTAINER ? 1 : Math.max(1, Number(wc.portTries) || 20);
2453
+ if (IN_CONTAINER && Number(wc.portTries) > 1) {
2454
+ log.info(`容器环境:已禁用端口顺延(固定映射只能对上 ${port})`);
2455
+ }
2325
2456
  let actualPort;
2326
2457
  try {
2327
2458
  actualPort = await listenWithFallback(server, host, port, {
package/src/webhook.js CHANGED
@@ -33,19 +33,56 @@ const MAX_BODY = 1024 * 1024;
33
33
 
34
34
  export class WebhookServer {
35
35
  /**
36
- * @param {{host?:string, port?:number, path?:string}} opts
36
+ * @param {{
37
+ * host?:string, port?:number, path?:string,
38
+ * verifySignature?:boolean, allowUnsigned?:boolean,
39
+ * allowedIps?:string[], perIpPerMin?:number
40
+ * }} opts
37
41
  */
38
- constructor({ host = '0.0.0.0', port = 15208, path = '/webhook', verifySignature = true } = {}) {
42
+ constructor({
43
+ host = '127.0.0.1',
44
+ port = 15208,
45
+ path = '/webhook',
46
+ verifySignature = true,
47
+ allowUnsigned = false,
48
+ allowedIps = [],
49
+ perIpPerMin = 120,
50
+ } = {}) {
39
51
  this.host = host;
40
52
  this.port = port;
41
53
  this.path = path.startsWith('/') ? path : `/${path}`;
42
54
  /**
43
55
  * 是否校验事件的 Ed25519 签名。
44
56
  *
45
- * 默认开。关掉只应出现在「验签总是失败、但又急着收消息」的排查场景,
46
- * 关掉之后任何知道回调地址 + appId 的人都能伪造事件。
57
+ * **双开关**:只写 `verifySignature: false` 不生效,必须同时给
58
+ * `allowUnsigned: true`。关掉之后,任何知道回调地址 + appId 的人都能伪造
59
+ * 事件(含冒充群主触发管理命令),这个代价不该由一个配置项决定。
47
60
  */
48
- this.verifySignature = verifySignature !== false;
61
+ this.verifySignature = !(verifySignature === false && allowUnsigned === true);
62
+ if (verifySignature === false && allowUnsigned !== true) {
63
+ log.warn(
64
+ 'webhook.verifySignature=false 已忽略:要真的关掉验签,必须同时设置 ' +
65
+ 'webhook.allowUnsigned=true。' +
66
+ '(关掉之后任何知道地址和 appId 的人都能伪造事件,包括冒充群主。默认不给这个口子。)',
67
+ );
68
+ }
69
+ /** 允许的来源 IP(空 = 不限)。公网部署建议填平台出口 IP,或交给反代做 */
70
+ this.allowedIps = new Set(
71
+ (Array.isArray(allowedIps) ? allowedIps : []).map((s) => String(s).trim()).filter(Boolean),
72
+ );
73
+ /** 每个 IP 每分钟上限,防日志洪泛 */
74
+ this.perIpPerMin = Math.max(1, Number(perIpPerMin) || 120);
75
+ /** ip -> { start, count } */
76
+ this.ipHits = new Map();
77
+ /**
78
+ * 事件去重:eventId -> 过期时间戳。
79
+ *
80
+ * 平台超时会**重推同一条事件**(报文完全一样),所以不能按签名去重
81
+ * (那会把重推当攻击拒掉,直接丢消息)。按事件 id 去重才对:第一次正常
82
+ * 处理,之后的重推一律回 200 但不再分发。顺带也挡住了「抓到一条合法请求
83
+ * 在时间窗内重放」。
84
+ */
85
+ this.seenEvents = new Map();
49
86
  /** @type {Map<string, {secret:string, onEvent:Function, onError?:Function}>} */
50
87
  this.bots = new Map();
51
88
  this.server = null;
@@ -197,7 +234,8 @@ export class WebhookServer {
197
234
  async #handle(req, res) {
198
235
  // 只接受 POST;健康检查给 GET
199
236
  if (req.method === 'GET') {
200
- return this.#json(res, 200, { ok: true, service: 'roost-webhook', bots: this.bots.size });
237
+ // 不返回机器人数量:那是内部信息,不该对匿名探测暴露
238
+ return this.#json(res, 200, { ok: true, service: 'roost-webhook' });
201
239
  }
202
240
  if (req.method !== 'POST') {
203
241
  return this.#json(res, 405, { error: 'method not allowed' });
@@ -209,6 +247,36 @@ export class WebhookServer {
209
247
  return this.#json(res, 404, { error: 'not found' });
210
248
  }
211
249
 
250
+ /**
251
+ * 来源白名单 + 每 IP 限速。
252
+ *
253
+ * 放在路径校验之后、解析 body 之前:非法路径不该占用配额,也不该被读 body。
254
+ * 顺序也有讲究——先在网络层挡掉,比在业务逻辑里挡省事得多。
255
+ */
256
+ const ip = req.socket?.remoteAddress || 'unknown';
257
+ if (this.allowedIps.size && !this.allowedIps.has(ip)) {
258
+ this.counters.rejected += 1;
259
+ log.warn(`Webhook 拒绝非白名单来源:${ip}`);
260
+ return this.#json(res, 403, { error: 'forbidden' });
261
+ }
262
+ {
263
+ const now = Date.now();
264
+ const rec = this.ipHits.get(ip) ?? { start: now, count: 0 };
265
+ if (now - rec.start >= 60_000) {
266
+ rec.start = now;
267
+ rec.count = 0;
268
+ }
269
+ if (rec.count >= this.perIpPerMin) {
270
+ this.counters.rejected += 1;
271
+ log.warn(`Webhook 请求过频,已限速:${ip}(每分钟上限 ${this.perIpPerMin})`);
272
+ return this.#json(res, 429, { error: 'too many requests' });
273
+ }
274
+ rec.count += 1;
275
+ this.ipHits.set(ip, rec);
276
+ // 防止被大量来源地址把表撑爆
277
+ if (this.ipHits.size > 5000) this.ipHits.clear();
278
+ }
279
+
212
280
  const url = new URL(req.url || '/', 'http://localhost');
213
281
  const appId =
214
282
  String(req.headers['x-bot-appid'] ?? '').trim() || String(url.searchParams.get('appid') ?? '').trim();
@@ -325,6 +393,36 @@ export class WebhookServer {
325
393
  }
326
394
  }
327
395
 
396
+ /**
397
+ * 事件级去重。
398
+ *
399
+ * 平台超时会**重推同一条事件**(报文完全一样)。所以不能按签名去重——
400
+ * 那会把重推当攻击拒掉,等于直接丢消息。按事件 id 去重才对:第一次正常
401
+ * 处理,之后的重推一律 200 但不再分发。顺带也挡住「抓到一条合法请求
402
+ * 在时间窗内重放」。
403
+ *
404
+ * 只用 `payload.id`,不猜别的字段名:猜错会把两个不同事件判成同一个,
405
+ * 那是真的丢消息。真没有 id 就退回只靠时间戳窗口(sign.js 里的 300 秒),
406
+ * 并且提醒一次。
407
+ */
408
+ const evId = String(payload?.id ?? '');
409
+ if (evId) {
410
+ const exp = this.seenEvents.get(evId);
411
+ if (exp && exp > Date.now()) {
412
+ return this.#json(res, 200, {}); // 重复:静默接受,不重复处理
413
+ }
414
+ this.seenEvents.set(evId, Date.now() + 300_000);
415
+ if (this.seenEvents.size > 10_000) {
416
+ this.seenEvents.delete(this.seenEvents.keys().next().value);
417
+ }
418
+ } else if (!this.noIdWarned) {
419
+ this.noIdWarned = true;
420
+ log.warn(
421
+ '收到的 Webhook 事件没有 id 字段,无法做重放去重,只能依赖 300 秒时间戳窗口。' +
422
+ '如果官方后续给事件加了 id,这里会自动生效。',
423
+ );
424
+ }
425
+
328
426
  this.counters.received += 1;
329
427
  const eventName = String(payload?.t ?? '');
330
428
  const d = payload?.d ?? {};