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 +9 -7
- package/package.json +1 -1
- package/src/config.js +50 -1
- package/src/main.js +5 -1
- package/src/plugin/deps.js +15 -3
- package/src/web/server.js +141 -10
- package/src/webhook.js +104 -6
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.
|
|
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 框架自检(
|
|
427
|
-
│ ├── e2e.mjs 端到端测试(
|
|
428
|
-
│
|
|
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 #
|
|
474
|
-
npm run test:e2e # 端到端
|
|
475
|
-
npm run test:all # 全量约
|
|
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
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
|
-
|
|
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();
|
package/src/plugin/deps.js
CHANGED
|
@@ -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) =>
|
|
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
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
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 {{
|
|
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({
|
|
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
|
-
*
|
|
57
|
+
* **双开关**:只写 `verifySignature: false` 不生效,必须同时给
|
|
58
|
+
* `allowUnsigned: true`。关掉之后,任何知道回调地址 + appId 的人都能伪造
|
|
59
|
+
* 事件(含冒充群主触发管理命令),这个代价不该由一个配置项决定。
|
|
47
60
|
*/
|
|
48
|
-
this.verifySignature = verifySignature
|
|
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
|
-
|
|
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 ?? {};
|