qq-bot-roost 0.8.8 → 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.8",
3
+ "version": "0.8.10",
4
4
  "type": "module",
5
5
  "description": "栖 Roost:零依赖的 QQ 官方机器人框架,填 AppID/AppSecret 即连,插件可写任意语言",
6
6
  "license": "SEE LICENSE IN LICENSE",
@@ -36,7 +36,10 @@
36
36
  "create": "node scripts/create-plugin.mjs",
37
37
  "test:plugin": "node scripts/test-plugin.mjs",
38
38
  "live": "node scripts/live-test.mjs",
39
- "market:index": "node scripts/build-market-index.mjs"
39
+ "market:index": "node scripts/build-market-index.mjs",
40
+ "sums": "node scripts/build-sums.mjs",
41
+ "sums:check": "node scripts/build-sums.mjs --check",
42
+ "sums:verify": "node scripts/build-sums.mjs --verify"
40
43
  },
41
44
  "author": {
42
45
  "name": "倦飞之鸟"
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/fsutil.js CHANGED
@@ -23,7 +23,20 @@ export async function writeJsonAtomic(file, data) {
23
23
  const tmp = `${file}.tmp-${process.pid}-${Date.now()}`;
24
24
  const text = `${JSON.stringify(data, null, 2)}\n`;
25
25
  try {
26
- await fs.writeFile(tmp, text, 'utf8');
26
+ /**
27
+ * `mode: 0o600` —— 只有属主能读写。
28
+ *
29
+ * 这些 JSON 里有 `config/bot.json`、`config/bots/<id>.json`,都带明文
30
+ * AppSecret;还有权限表、群档案。Linux 默认 umask 022 下新建文件是 644,
31
+ * 同机其他用户直接 `cat` 就能拿到机器人凭证。
32
+ *
33
+ * 注意两点:
34
+ * - mode 只在**创建**时生效,而这里的 tmp 每次都是新文件,所以一定吃到
35
+ * - rename 会保留临时文件的权限,覆盖掉旧文件原来的 644 —— 也就是说
36
+ * 用户只要保存过一次配置,老文件的权限就被修正了,不需要单独做迁移
37
+ * - Windows 上 mode 被忽略,不影响运行
38
+ */
39
+ await fs.writeFile(tmp, text, { encoding: 'utf8', mode: 0o600 });
27
40
  await fs.rename(tmp, file);
28
41
  } catch (e) {
29
42
  await fs.rm(tmp, { force: true }).catch(() => {});
package/src/hashdir.js CHANGED
@@ -77,16 +77,24 @@ async function collect(root) {
77
77
  }
78
78
 
79
79
  /**
80
- * 算目录的内容指纹。
80
+ * 按给定的相对路径清单算指纹。
81
81
  *
82
- * @param {string} dir 插件目录
82
+ * 为什么要单独一个入口:仓库根目录里有 `data/`、`logs/`、`config/`、`plugins/`
83
+ * 这些**不会被 tarball 打包**的东西,直接 `hashDir(ROOT)` 会把它们算进去,
84
+ * 跟「解包后的 tarball」永远对不上。
85
+ *
86
+ * 所以发布流程用 `git ls-files` 拿到「真正会被打进包的文件」,再喂给这里;
87
+ * 更新校验则用 `hashDir(解包目录)`。两边必须用同一套拼接方式,否则校验必失败。
88
+ *
89
+ * @param {string} root 根目录
90
+ * @param {string[]} rels 相对路径(`/` 分隔);内部会去重并排序,顺序无关
83
91
  * @returns {Promise<string>} 64 位十六进制
84
92
  */
85
- export async function hashDir(dir) {
86
- const files = await collect(dir);
93
+ export async function hashFileList(root, rels) {
94
+ const files = [...new Set(rels)].sort();
87
95
  const h = crypto.createHash('sha256');
88
96
  for (const rel of files) {
89
- const buf = normalize(await fs.readFile(path.join(dir, rel)));
97
+ const buf = normalize(await fs.readFile(path.join(root, rel)));
90
98
  h.update(rel);
91
99
  h.update('\0');
92
100
  h.update(String(buf.length));
@@ -96,3 +104,38 @@ export async function hashDir(dir) {
96
104
  }
97
105
  return h.digest('hex');
98
106
  }
107
+
108
+ /**
109
+ * 算目录的内容指纹。
110
+ *
111
+ * @param {string} dir 插件目录
112
+ * @param {{exclude?: string[], only?: string[]}} [opts]
113
+ * - `exclude`:精确匹配要跳过的相对路径(如 `SHA256SUMS` 这种会自引用的文件)
114
+ * - `only`:只算这些路径(`src` 这类目录前缀,或 `package.json` 这类文件名)。
115
+ * 用途:GitHub 的 tarball 是**全量**(含 .github、scripts、sdk),
116
+ * 而 Gitee 源只拉会被更新覆盖的那几个条目,两边目录内容天生不同。
117
+ * 用 `only` 把范围收窄到「真正会被替换的代码」,两边才算得出同一个值。
118
+ * @returns {Promise<string>} 64 位十六进制
119
+ */
120
+ export async function hashDir(dir, opts = {}) {
121
+ /**
122
+ * 根目录不存在时必须报错,**不能静默返回空哈希**。
123
+ *
124
+ * `collect()` 里对每个目录都有 try/catch(遍历时子目录读不到要跳过),
125
+ * 但连根目录都不存在时它也会安静地返回空数组,算出来就是
126
+ * `e3b0c442…`(空字符串的 sha256)—— 那是一个**合法值**,会像真指纹一样
127
+ * 参与比对,现象是「内容全变了」而不是「路径写错了」,排查时非常误导。
128
+ */
129
+ const st = await fs.stat(dir).catch(() => null);
130
+ if (!st?.isDirectory()) throw new Error(`算指纹失败:目录不存在或不是目录 —— ${dir}`);
131
+
132
+ const skip = new Set(opts.exclude ?? []);
133
+ const only = (opts.only ?? []).map((p) => String(p));
134
+ let files = (await collect(dir)).filter((rel) => !skip.has(rel));
135
+ if (only.length) {
136
+ files = files.filter((rel) =>
137
+ only.some((p) => rel === p || rel.startsWith(p.endsWith('/') ? p : `${p}/`)),
138
+ );
139
+ }
140
+ return hashFileList(dir, files);
141
+ }
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/mirror.js CHANGED
@@ -86,9 +86,36 @@ const AUTO_TTL = 10 * 60_000;
86
86
  export function configureMirror(globalCfg) {
87
87
  const n = globalCfg?.network ?? {};
88
88
  const id = String(n.mirror ?? 'auto').trim() || 'auto';
89
+
90
+ /**
91
+ * 自定义下载前缀必须是 https。
92
+ *
93
+ * 这个值会被直接拼在 tarball 地址前面(见 applyMirror),而这个项目**更新包
94
+ * 没有内容校验**(updater.js 的 expectSha256 恒为 null)。也就是说:
95
+ * 填一个 http 地址,等于让链路上的任何人决定你下一版跑什么代码。
96
+ *
97
+ * 不合法就退回默认(不用它),而不是抛错 —— 配置文件写错了不该让整个框架起不来。
98
+ */
99
+ let custom = String(n.mirrorCustom ?? '').trim();
100
+ if (custom) {
101
+ let u = null;
102
+ try {
103
+ u = new URL(custom.includes('://') ? custom : `https://${custom}`);
104
+ } catch {
105
+ u = null;
106
+ }
107
+ if (!u || u.protocol !== 'https:') {
108
+ log.warn(
109
+ `network.mirrorCustom 必须是 https:// 开头,已忽略:${custom}\n` +
110
+ ' (自定义下载源会决定你下一版跑什么代码,明文 http 等于把它交给链路上的任何人)',
111
+ );
112
+ custom = '';
113
+ }
114
+ }
115
+
89
116
  current = {
90
117
  id: PRESETS.some((p) => p.id === id) ? id : 'auto',
91
- custom: String(n.mirrorCustom ?? '').trim(),
118
+ custom,
92
119
  // Gitee 私人令牌:选填。公开仓库匿名也能用,填了只是把 API 配额提上去
93
120
  giteeToken: String(n.giteeToken ?? '').trim(),
94
121
  };
@@ -160,6 +187,17 @@ export function applyMirror(url, preset, custom = current.custom) {
160
187
  // 允许用户只填域名,自动补协议和结尾斜杠
161
188
  let b = base.trim();
162
189
  if (!/^https?:\/\//i.test(b)) b = `https://${b}`;
190
+ /**
191
+ * 自定义前缀必须是 https —— 这里再挡一道。
192
+ *
193
+ * configureMirror 已经校验过配置里的值,但 `probeMirrors` 的 custom 是
194
+ * 面板请求体直接传进来的(「测速」按钮),不经过 configureMirror。
195
+ * 不合法就**退回原始地址**,不要拼一个 http 前缀上去。
196
+ */
197
+ if (preset.kind === 'custom' && !/^https:\/\//i.test(b)) {
198
+ log.warn(`自定义下载源必须是 https,已忽略:${base}`);
199
+ return url;
200
+ }
163
201
  if (!b.endsWith('/')) b += '/';
164
202
  // 已经是这个前缀了就别套第二次
165
203
  if (url.startsWith(b)) return url;
@@ -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
 
@@ -0,0 +1,28 @@
1
+ /**
2
+ * 更新会替换的代码条目(单一来源)
3
+ * ================================
4
+ *
5
+ * 三处共用:
6
+ * 1. `updater.js` 备份与替换(rsync 式覆盖,绝不碰 config/data/logs/plugins)
7
+ * 2. `gitee.js` 的前缀过滤(Gitee 没有 archive 直链,只能按清单逐个拉)
8
+ * 3. `scripts/build-sums.mjs` 算内容指纹的范围
9
+ *
10
+ * 为什么指纹也按这个范围算:GitHub 的 tarball 是**全量**(还带 .github、
11
+ * scripts、sdk、types),而 Gitee 源只拉下面这几个条目。如果指纹按「包里的
12
+ * 全部文件」算,两条下载路径永远对不上 —— 一边写死校验失败,一边等于没校验。
13
+ *
14
+ * 改这个清单要连带重跑 `npm run sums`,否则已发布的指纹会全部失配。
15
+ */
16
+ export const CODE_ENTRIES = [
17
+ 'src',
18
+ 'web',
19
+ 'test',
20
+ 'docs',
21
+ 'package.json',
22
+ 'README.md',
23
+ 'LICENSE',
24
+ 'start.bat',
25
+ ];
26
+
27
+ /** 绝对不能删/覆盖的目录(双保险) */
28
+ export const PROTECTED_ENTRIES = new Set(['config', 'data', 'logs', 'plugins', 'node_modules', '.git']);
package/src/updater.js CHANGED
@@ -20,6 +20,8 @@ import { createLogger } from './logger.js';
20
20
  import { fetchAndExtract } from './tarball.js';
21
21
  import { fetchViaMirror, currentMirror } from './mirror.js';
22
22
  import { fetchGiteeTree, detectGiteeRef, giteeRawUrl } from './gitee.js';
23
+ import { hashDir } from './hashdir.js';
24
+ import { CODE_ENTRIES, PROTECTED_ENTRIES as PROTECTED } from './update-scope.js';
23
25
  import { ROOT } from './config.js';
24
26
 
25
27
  const log = createLogger('update');
@@ -27,11 +29,11 @@ const log = createLogger('update');
27
29
  /** 仓库信息。换成自己的 fork 就改这里 */
28
30
  const REPO = 'tdcq9594/qq-bot-roost';
29
31
 
30
- /** 允许被更新覆盖的顶层条目。**用户数据一个都不在里面** */
31
- const CODE_ENTRIES = ['src', 'web', 'test', 'docs', 'package.json', 'README.md', 'LICENSE', 'start.bat'];
32
-
33
- /** 绝对不能删的目录,双保险 */
34
- const PROTECTED = new Set(['config', 'data', 'logs', 'plugins', 'node_modules', '.git']);
32
+ /**
33
+ * 允许被更新覆盖的顶层条目、以及绝对不能碰的目录,都在 ./update-scope.js。
34
+ * 内容指纹用的是**同一份清单**——GitHub tarball 是全量、Gitee 只拉这几个条目,
35
+ * 范围不一致的话两条下载路径算出来的指纹永远对不上。
36
+ */
35
37
 
36
38
  /**
37
39
  * Gitee 源下拉取的文件范围:只拉会被更新覆盖的代码条目。
@@ -100,9 +102,58 @@ async function updatedButNotRestarted() {
100
102
  * 包一层而不是往 checkUpdate 里塞:里面有好几个返回分支(Gitee /
101
103
  * 主站 302 / API 兜底 / 404 / 失败),每个都手写一遍 needsRestart 迟早漏一个。
102
104
  */
105
+ /**
106
+ * 拉 SHA256SUMS,得到「版本 → 内容指纹」的映射。
107
+ *
108
+ * 两个源都试:GitHub raw(经下载源改写)和 Gitee raw。任何一边拿到都行,
109
+ * 因为指纹是「代码内容」的,与从哪个源下载无关。
110
+ *
111
+ * @returns {Promise<Map<string,string>>} 版本号 → 64 位十六进制
112
+ */
113
+ async function fetchSumsMap() {
114
+ const urls = [
115
+ `https://raw.githubusercontent.com/${REPO}/master/SHA256SUMS`,
116
+ `https://gitee.com/${REPO}/raw/master/SHA256SUMS`,
117
+ ];
118
+ for (const u of urls) {
119
+ try {
120
+ const res = await fetchViaMirror(u, {
121
+ headers: { 'User-Agent': 'qq-bot-roost-updater' },
122
+ signal: AbortSignal.timeout(8000),
123
+ });
124
+ if (!res.ok) continue;
125
+ const text = await res.text();
126
+ const map = new Map();
127
+ for (const line of text.split(/\r?\n/)) {
128
+ const m = /^([a-f0-9]{64})\s+v?([0-9][\w.+-]*)$/i.exec(line.trim());
129
+ if (m) map.set(m[2].replace(/^v/i, ''), m[1].toLowerCase());
130
+ }
131
+ if (map.size) return map;
132
+ } catch {
133
+ // 换下一个源
134
+ }
135
+ }
136
+ return new Map();
137
+ }
138
+
139
+ /**
140
+ * 查某个版本的内容指纹。拿不到返回 null —— 真正的拒绝发生在 applyUpdate,
141
+ * 因为「只检查不安装」时面板仍应正常显示有新版本,只是点更新会失败。
142
+ */
143
+ async function fetchTreeSha256(version) {
144
+ try {
145
+ const map = await fetchSumsMap();
146
+ return map.get(String(version).replace(/^v/, '')) ?? null;
147
+ } catch {
148
+ return null;
149
+ }
150
+ }
151
+
103
152
  export async function checkUpdate() {
104
153
  const needsRestart = await updatedButNotRestarted();
105
154
  const r = await checkUpdateCore();
155
+ // 有新版本才值得多跑一次网络请求
156
+ if (r?.ok && r.hasUpdate) r.treeSha256 = await fetchTreeSha256(r.latest);
106
157
  return { ...r, needsRestart };
107
158
  }
108
159
 
@@ -442,6 +493,37 @@ async function applyUpdateOnce(info) {
442
493
  throw new Error('下载的包里没有 src/main.js,不是合法的框架包,已中止');
443
494
  });
444
495
 
496
+ /**
497
+ * 内容指纹校验 —— **必须放在备份和替换之前**。
498
+ *
499
+ * 这一步失败就等于什么都没发生过:文件没动、备份也没建。
500
+ *
501
+ * 为什么必须严格:更新会直接覆盖 `src/`、`web/` 这些要执行的代码。
502
+ * 只要能改掉下载地址(自定义镜像、上游仓库被入侵、链路劫持),
503
+ * 就能决定用户下一版跑什么。指纹对不上就说明「下载到的东西不是作者发的」。
504
+ *
505
+ * 为什么没有指纹也要拒绝:留一个「没指纹就放行」的口子,攻击者只要把
506
+ * SHA256SUMS 里那条删掉或让仓库回到没有该文件的形态就能绕过。
507
+ * 代价是发布流程必须每版都带指纹 —— 这由 CI 的 `npm run sums:check` 守着。
508
+ */
509
+ if (!rel.treeSha256) {
510
+ throw new Error(
511
+ `v${rel.latest} 没有发布内容指纹(SHA256SUMS 里查不到),已拒绝自动更新。\n` +
512
+ '这不是你的网络问题:作者这一版可能漏了生成指纹。\n' +
513
+ '可以手动到 GitHub Releases 下载安装,或等下一版。你的代码没有任何改动。',
514
+ );
515
+ }
516
+ const gotHash = await hashDir(workDir, { only: CODE_ENTRIES });
517
+ if (gotHash !== rel.treeSha256) {
518
+ throw new Error(
519
+ `更新内容校验失败,已中止(你的代码没有任何改动)。\n` +
520
+ ` 期望 ${rel.treeSha256}\n` +
521
+ ` 实际 ${gotHash}\n` +
522
+ '可能是下载源被篡改,或者发布时改了代码却忘了重跑 npm run sums。',
523
+ );
524
+ }
525
+ log.info(`内容指纹校验通过(${gotHash.slice(0, 12)}…)`);
526
+
445
527
  // 备份现有代码
446
528
  await fs.mkdir(backupDir, { recursive: true });
447
529
  for (const name of CODE_ENTRIES) {
package/src/web/server.js CHANGED
@@ -43,6 +43,19 @@ import {
43
43
  const log = createLogger('webui');
44
44
  const WEB_ROOT = path.join(ROOT, 'web');
45
45
 
46
+ /**
47
+ * 进程实例标识。
48
+ *
49
+ * 面板用它判断「服务是不是重启过」。重启意味着两件事同时发生:
50
+ * - 页面令牌全部重新生成 → 已打开的插件页 iframe 手里拿的全是废牌,
51
+ * 之后每个请求都会被判成跨站(403)
52
+ * - 会话 Cookie 全部失效
53
+ *
54
+ * 前端拿到这个值一变化,就可以立刻重载 iframe,而不是干等兜底轮询
55
+ * (用户会看到一条红色报错闪过,体验很差)。
56
+ */
57
+ const PROCESS_STARTED_AT = Date.now();
58
+
46
59
  /**
47
60
  * 主面板的 CSP。
48
61
  *
@@ -321,6 +334,65 @@ export async function startWebServer({
321
334
  );
322
335
  }
323
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
+
324
396
  // res -> 这个连接正在看哪个机器人,状态推送时按各自的目标过滤
325
397
  const sseClients = new Map();
326
398
  /** 同时最多多少个 SSE 连接(一个页面一个) */
@@ -433,6 +505,11 @@ export async function startWebServer({
433
505
  : people.find((p) => p.openid === c.id)?.name ?? '',
434
506
  }));
435
507
  }
508
+ /**
509
+ * 进程实例标识:前端一看到它变了就知道服务重启过,
510
+ * 页面令牌和会话都已作废,需要立刻重载插件页而不是等下轮轮询。
511
+ */
512
+ snap.instanceId = `${process.pid}-${PROCESS_STARTED_AT}`;
436
513
  return snap;
437
514
  };
438
515
 
@@ -585,7 +662,7 @@ export async function startWebServer({
585
662
  sessions.delete(sid);
586
663
  }
587
664
 
588
- const ip = req.socket?.remoteAddress || 'unknown';
665
+ const ip = clientIp(req);
589
666
  const now = Date.now();
590
667
  const rec = authFails.get(ip);
591
668
  if (rec && now - rec.start < AUTH_WINDOW && rec.count >= AUTH_MAX_FAILS) {
@@ -645,7 +722,9 @@ export async function startWebServer({
645
722
  */
646
723
  if (ptPage && String(req.headers.origin || '') === 'null') return true;
647
724
 
648
- const host = String(req.headers['host'] || '');
725
+ // 反代场景下要用 X-Forwarded-Host,不能直接看 req.headers.host:
726
+ // Nginx 默认把 Host 改写成 $proxy_host,与浏览器的 Origin 永远不等。
727
+ const host = clientHost(req);
649
728
  for (const h of ['origin', 'referer']) {
650
729
  const v = req.headers[h];
651
730
  if (!v) continue;
@@ -853,6 +932,40 @@ export async function startWebServer({
853
932
  }
854
933
 
855
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
+
856
969
  // ---------------- API ----------------
857
970
  if (pathname.startsWith('/api/')) {
858
971
  const ptPage = pickPageToken(url);
@@ -884,11 +997,25 @@ export async function startWebServer({
884
997
  */
885
998
  if (req.method === 'OPTIONS') {
886
999
  const origin = String(req.headers.origin || '');
1000
+ /**
1001
+ * 允许的头**回显浏览器要的那个**,不写死。
1002
+ *
1003
+ * 踩过的坑:原来写死 `Content-Type`,看着没错,但浏览器报的是
1004
+ * 「Request header field content-type is not allowed by
1005
+ * Access-Control-Allow-Headers in preflight response」。
1006
+ * 回显 `Access-Control-Request-Headers` 就永远不会对不上 ——
1007
+ * 它问什么我给什么,反正是插件页自己的只读请求。
1008
+ *
1009
+ * **不要加 `Access-Control-Max-Age`**:加了浏览器会把这次预检结果
1010
+ * 缓存好几分钟,一旦某次响应是错的(或者服务端刚改过逻辑),
1011
+ * 后面再改代码都没用 —— 浏览器压根不会重新来问,只会一直报同一个错,
1012
+ * 排查时会以为是代码没生效。本地面板的预检几乎没有开销,不值得缓存。
1013
+ */
1014
+ const wantHeaders = String(req.headers['access-control-request-headers'] || '');
887
1015
  res.writeHead(204, {
888
1016
  ...(origin === 'null' ? { 'Access-Control-Allow-Origin': 'null', Vary: 'Origin' } : {}),
889
1017
  'Access-Control-Allow-Methods': 'GET, POST, PATCH, PUT, DELETE, OPTIONS',
890
- 'Access-Control-Allow-Headers': 'Content-Type, X-Access-Key, X-Token',
891
- 'Access-Control-Max-Age': '600',
1018
+ 'Access-Control-Allow-Headers': wantHeaders || 'Content-Type, X-Access-Key, X-Token',
892
1019
  });
893
1020
  return res.end();
894
1021
  }
@@ -910,9 +1037,41 @@ export async function startWebServer({
910
1037
  * 完全不受保护——跨站页面一个 text/plain 表单就能把它们打出去。
911
1038
  */
912
1039
  if (req.method !== 'GET' && req.method !== 'HEAD' && !isJsonRequest(req)) {
1040
+ /**
1041
+ * 这里以前完全静默 —— 而它恰恰是「插件页一片空白」最可能的入口:
1042
+ * CORS 预检(OPTIONS)没有 body,如果哪天 OPTIONS 分支漏了或顺序错了,
1043
+ * 预检就会掉进这里拿 415,浏览器判定预检失败,页面只看到
1044
+ * `TypeError: Failed to fetch`,服务端一条日志都没有,排查全靠猜。
1045
+ */
1046
+ log.warn(
1047
+ `拒绝写操作:${req.method} ${pathname}` +
1048
+ `(Content-Type: ${req.headers['content-type'] || '无'},需要 application/json)`,
1049
+ );
913
1050
  return json(res, 415, { error: '写操作需要 Content-Type: application/json' });
914
1051
  }
915
1052
  if (!isSameOrigin(req, ptPage)) {
1053
+ /**
1054
+ * 这里踩过一个很隐蔽的坑:插件页是 opaque origin,请求 `Origin` 恒为
1055
+ * 字符串 `null`。它发的请求如果没带有效 `pt`(比如面板重启后 iframe
1056
+ * 还拿着旧令牌),就会落到这个分支。
1057
+ *
1058
+ * 两个后果叠加,导致排查极其困难:
1059
+ * 1. 403 响应没带 CORS 头 → 浏览器不给页面读状态码,页面只看到
1060
+ * `TypeError: Failed to fetch`,看起来像网络断了或服务没起
1061
+ * 2. 这里不写日志 → 服务端一条记录都没有,像是请求根本没到
1062
+ *
1063
+ * 所以:给 opaque origin 补上 CORS 头(让它能读到 403 和错误文案),
1064
+ * 并且**一定要留一条 warn**。没带令牌的情况单独说清楚,否则下次
1065
+ * 还是得靠猜。
1066
+ */
1067
+ if (String(req.headers.origin || '') === 'null') {
1068
+ res.setHeader('Access-Control-Allow-Origin', 'null');
1069
+ res.setHeader('Vary', 'Origin');
1070
+ }
1071
+ log.warn(
1072
+ `拒绝跨站请求:${req.method} ${pathname}` +
1073
+ `(Origin: ${req.headers.origin || '无'}${ptPage ? ',页面令牌有效' : ',无有效页面令牌'})`,
1074
+ );
916
1075
  return json(res, 403, { error: '跨站请求被拒绝' });
917
1076
  }
918
1077
 
@@ -1146,7 +1305,36 @@ export async function startWebServer({
1146
1305
  if (typeof body.appSecret === 'string' && body.appSecret && body.appSecret !== '********') {
1147
1306
  cfg.appSecret = body.appSecret.trim();
1148
1307
  }
1149
- if (body.baseUrl) cfg.baseUrl = body.baseUrl.trim();
1308
+ /**
1309
+ * baseUrl 必须校验,否则这里等于「把 access_token 送人」的开关。
1310
+ *
1311
+ * `api.js` 用这个 baseUrl 拼请求,并带上 `Authorization: QQBot <token>`。
1312
+ * 面板一旦被改成第三方地址,下一次 API 调用就把机器人令牌发到那台机器,
1313
+ * 拿到令牌就能冒充机器人在它所在的任何群里发消息 —— 面板弱口令会因此
1314
+ * 直接升级成「机器人身份被完全接管」。
1315
+ *
1316
+ * 用 `*.qq.com` 而不是写死 `api.sgroup.qq.com`:官方历史上用过
1317
+ * `api.bot.qq.com` 这类别名(现有配置里就有),写死会把用户自己的
1318
+ * 合法配置挡回去。qq.com 是腾讯的域,第三方拿不到子域。
1319
+ */
1320
+ if (body.baseUrl !== undefined && body.baseUrl !== '') {
1321
+ let u = null;
1322
+ try {
1323
+ u = new URL(String(body.baseUrl).trim());
1324
+ } catch {
1325
+ return json(res, 400, { error: 'baseUrl 不是合法 URL' });
1326
+ }
1327
+ const host = u.hostname.toLowerCase();
1328
+ const official = host === 'qq.com' || host.endsWith('.qq.com');
1329
+ if (u.protocol !== 'https:' || !official) {
1330
+ return json(res, 400, {
1331
+ error:
1332
+ 'baseUrl 只允许 https 的腾讯官方域名(如 https://api.sgroup.qq.com)。\n' +
1333
+ '改成第三方地址会把 access_token 发过去,等于交出机器人身份。',
1334
+ });
1335
+ }
1336
+ cfg.baseUrl = u.origin;
1337
+ }
1150
1338
  if (body.intents !== undefined) {
1151
1339
  const n = Number(body.intents);
1152
1340
  if (!Number.isInteger(n) || n < 0 || n > 0x7fffffff) {
@@ -1269,7 +1457,12 @@ export async function startWebServer({
1269
1457
  * 面板据此显示「统计不可用」而不是画一堆 0。
1270
1458
  */
1271
1459
  if (pathname === '/api/stats' && req.method === 'GET') {
1272
- const days = Number(url.searchParams.get('days')) || 7;
1460
+ /**
1461
+ * `days` 必须夹紧。同文件下面的 `/api/stats/export` 早就 clamp 到
1462
+ * 1~365 了,这里漏了 —— 传个极大值就能让 SQLite 扫全表,
1463
+ * 面板上一次点击就变成一次自伤式 DoS。
1464
+ */
1465
+ const days = Math.max(1, Math.min(365, Number(url.searchParams.get('days')) || 7));
1273
1466
  try {
1274
1467
  return json(res, 200, rt.stats?.overview(days) ?? { available: false });
1275
1468
  } catch (e) {
@@ -1985,25 +2178,41 @@ export async function startWebServer({
1985
2178
 
1986
2179
  // SSE
1987
2180
  if (pathname === '/api/stream' && req.method === 'GET') {
1988
- res.writeHead(200, {
1989
- 'Content-Type': 'text/event-stream; charset=utf-8',
1990
- 'Cache-Control': 'no-cache',
1991
- Connection: 'keep-alive',
1992
- });
1993
2181
  /**
1994
- * 连接数上限。SSE 是长连接,每个连接占一个 fd + 一份缓冲;
2182
+ * 连接数上限必须**先检查再写头**。
2183
+ *
2184
+ * 之前是「先 writeHead(200, SSE 头) 再判满、判满再 writeHead(503)」,
2185
+ * 满载时对同一个 res 写两次头,Node 直接抛 `ERR_HTTP_HEADERS_SENT`,
2186
+ * 请求崩掉、连接也不释放。SSE 是长连接,每个连接占一个 fd + 一份缓冲;
1995
2187
  * 面板被脚本反复刷新时不设限就是稳定泄漏。
1996
2188
  */
1997
2189
  if (sseClients.size >= MAX_SSE) {
1998
2190
  res.writeHead(503, { 'Content-Type': 'text/plain; charset=utf-8' });
1999
2191
  return res.end('SSE 连接数已满,请关闭多余的页面后重试');
2000
2192
  }
2193
+ res.writeHead(200, {
2194
+ 'Content-Type': 'text/event-stream; charset=utf-8',
2195
+ 'Cache-Control': 'no-cache',
2196
+ Connection: 'keep-alive',
2197
+ });
2001
2198
  res.write(`event: status\ndata: ${JSON.stringify(statusPayload(rt))}\n\n`);
2002
2199
  sseClients.set(res, rt.id);
2003
2200
  const ping = setInterval(() => {
2004
2201
  try {
2005
2202
  res.write(': ping\n\n');
2006
- } 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
+ }
2007
2216
  }, 15000);
2008
2217
  req.on('close', () => {
2009
2218
  clearInterval(ping);
@@ -2223,7 +2432,27 @@ export async function startWebServer({
2223
2432
 
2224
2433
  /* ---------------- 监听(端口被占用自动顺延) ---------------- */
2225
2434
 
2226
- 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
+ }
2227
2456
  let actualPort;
2228
2457
  try {
2229
2458
  actualPort = await listenWithFallback(server, host, port, {
package/src/webhook.js CHANGED
@@ -14,7 +14,16 @@
14
14
  * 这点和官方示例一致——一个回调地址服务多个机器人,省得每个机器人开一个端口。
15
15
  */
16
16
  import http from 'node:http';
17
+ import fs from 'node:fs';
18
+ /**
19
+ * 这里**必须具名导入** `join` / `dirname`,不能 `import path from 'node:path'`。
20
+ * constructor 有个参数就叫 `path`(回调路径),会把模块级的 `path` 遮蔽掉,
21
+ * 于是 `path.join(...)` 变成「字符串.join」→ `TypeError: path.join is not a function`,
22
+ * 整个 Webhook 服务直接起不来。这个坑是靠实测抓到的,不是看代码能看出来的。
23
+ */
24
+ import { join, dirname } from 'node:path';
17
25
  import { createLogger } from './logger.js';
26
+ import { DIRS } from './paths.js';
18
27
  import { verifyRequest, verifyEventSignature } from './sign.js';
19
28
 
20
29
  const log = createLogger('webhook');
@@ -24,25 +33,119 @@ const MAX_BODY = 1024 * 1024;
24
33
 
25
34
  export class WebhookServer {
26
35
  /**
27
- * @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
28
41
  */
29
- 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
+ } = {}) {
30
51
  this.host = host;
31
52
  this.port = port;
32
53
  this.path = path.startsWith('/') ? path : `/${path}`;
33
54
  /**
34
55
  * 是否校验事件的 Ed25519 签名。
35
56
  *
36
- * 默认开。关掉只应出现在「验签总是失败、但又急着收消息」的排查场景,
37
- * 关掉之后任何知道回调地址 + appId 的人都能伪造事件。
57
+ * **双开关**:只写 `verifySignature: false` 不生效,必须同时给
58
+ * `allowUnsigned: true`。关掉之后,任何知道回调地址 + appId 的人都能伪造
59
+ * 事件(含冒充群主触发管理命令),这个代价不该由一个配置项决定。
60
+ */
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
+ * 在时间窗内重放」。
38
84
  */
39
- this.verifySignature = verifySignature !== false;
85
+ this.seenEvents = new Map();
40
86
  /** @type {Map<string, {secret:string, onEvent:Function, onError?:Function}>} */
41
87
  this.bots = new Map();
42
88
  this.server = null;
43
89
  this.running = false;
44
90
  /** 收到过多少事件,面板显示用 */
45
91
  this.counters = { received: 0, rejected: 0, verify: 0 };
92
+ /**
93
+ * 回调验证(op=13)的状态,按 appId 记:`{ okAt: 时间戳, hits: [最近一分钟的尝试] }`。
94
+ *
95
+ * 为什么需要它,见 #handle 里 op=13 那段长注释。简单说:这个接口会用
96
+ * AppSecret 给「任意提交内容」签名,而事件验签用的是同一把密钥、同一种
97
+ * 拼接方式 —— 放开了就等于对外提供签名服务。
98
+ */
99
+ this.verifyState = new Map();
100
+ this.verifyFile = join(DIRS.data, 'webhook-verified.json');
101
+ this.#loadVerifyState();
102
+ }
103
+
104
+ /** 读回落盘的回调验证记录:重启不该让平台被迫重新验证一次 */
105
+ #loadVerifyState() {
106
+ try {
107
+ const obj = JSON.parse(fs.readFileSync(this.verifyFile, 'utf8'));
108
+ for (const [id, at] of Object.entries(obj)) {
109
+ const t = Number(at);
110
+ if (Number.isFinite(t) && t > 0) this.verifyState.set(id, { okAt: t, hits: [] });
111
+ }
112
+ } catch (e) {
113
+ // 文件不存在是正常的(第一次跑),其它错误才值得说一句
114
+ if (e.code !== 'ENOENT') log.warn(`回调验证记录读不出来(忽略):${e.message}`);
115
+ }
116
+ }
117
+
118
+ #saveVerifyState() {
119
+ try {
120
+ fs.mkdirSync(dirname(this.verifyFile), { recursive: true });
121
+ const obj = {};
122
+ for (const [id, s] of this.verifyState) if (s.okAt) obj[id] = s.okAt;
123
+ const tmp = `${this.verifyFile}.tmp`;
124
+ fs.writeFileSync(tmp, JSON.stringify(obj, null, 2));
125
+ fs.renameSync(tmp, this.verifyFile); // 原子替换,避免写一半断电
126
+ } catch (e) {
127
+ log.warn(`回调验证记录写不进去(不影响运行):${e.message}`);
128
+ }
129
+ }
130
+
131
+ /**
132
+ * 重置某个机器人的回调验证状态。
133
+ *
134
+ * 用途:平台那边改了回调地址 / 换了域名,需要重新走一次 op=13 验证。
135
+ * 面板可以挂一个「重新验证回调」按钮调它,也可以直接删
136
+ * `data/webhook-verified.json` 里对应的那条。
137
+ *
138
+ * @returns {boolean} 是否真的重置了(原来有记录才返回 true)
139
+ */
140
+ resetVerify(appId) {
141
+ const id = String(appId ?? '').trim();
142
+ if (!id) return false;
143
+ const s = this.verifyState.get(id);
144
+ if (!s) return false;
145
+ this.verifyState.set(id, { okAt: 0, hits: [] });
146
+ this.#saveVerifyState();
147
+ log.info(`[${id}] 已重置回调验证状态,等待平台重新验证`);
148
+ return true;
46
149
  }
47
150
 
48
151
  /**
@@ -131,7 +234,8 @@ export class WebhookServer {
131
234
  async #handle(req, res) {
132
235
  // 只接受 POST;健康检查给 GET
133
236
  if (req.method === 'GET') {
134
- 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' });
135
239
  }
136
240
  if (req.method !== 'POST') {
137
241
  return this.#json(res, 405, { error: 'method not allowed' });
@@ -143,6 +247,36 @@ export class WebhookServer {
143
247
  return this.#json(res, 404, { error: 'not found' });
144
248
  }
145
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
+
146
280
  const url = new URL(req.url || '/', 'http://localhost');
147
281
  const appId =
148
282
  String(req.headers['x-bot-appid'] ?? '').trim() || String(url.searchParams.get('appid') ?? '').trim();
@@ -173,14 +307,68 @@ export class WebhookServer {
173
307
 
174
308
  // op=13:平台验证回调地址,用 AppSecret 签名后原样返回
175
309
  if (payload?.op === 13) {
310
+ /**
311
+ * ⚠️ 整段 Webhook 里最危险的就是这里,改之前把这段读完。
312
+ *
313
+ * op=13 的签名内容是 `${event_ts}${plain_token}`,事件验签是
314
+ * `${timestamp}${rawBody}` —— **同一把密钥、同一种拼接、没有域分离**。
315
+ * 而 op=13 在验签之前、也不需要任何认证,只要 appId 在册就回签。
316
+ *
317
+ * 于是它可以被当成「任意内容签名服务」:把要伪造的事件体当 plain_token
318
+ * 提交,拿回来的签名直接能过事件验签 —— 冒充任意 openid 触发管理命令。
319
+ * 也就是说 `verifySignature: true` 在攻击者面前是形同虚设的。
320
+ *
321
+ * 签名内容由官方协议规定,应用层改不了(改了平台就不认)。所以只能
322
+ * 收紧「谁能调、能调几次」:
323
+ *
324
+ * 1. **plain_token 形态校验**:事件体必然是 JSON,一定含 { } " \ 或空白;
325
+ * 合法 token 是平台生成的随机串。这条零成本、不影响正常流程,是主防线。
326
+ * 2. **24 小时内只回签一次**:平台只在配置回调地址时验证。
327
+ * 不做成「永久一次」是因为换域名/端口要重新验证,永久锁死会让用户
328
+ * 卡在「回调地址验证失败」上,反而制造新的排查成本。
329
+ * 3. **每分钟最多 3 次尝试**,把爆破与滥用压到没有价值。
330
+ */
331
+ const st = this.verifyState.get(appId) ?? { okAt: 0, hits: [] };
332
+ const now = Date.now();
333
+ st.hits = st.hits.filter((t) => now - t < 60_000);
334
+
335
+ if (st.okAt && now - st.okAt < 24 * 3600_000) {
336
+ this.counters.rejected += 1;
337
+ log.warn(
338
+ `[${appId}] 拒绝重复的 op=13:该 appId 24 小时内已通过验证。` +
339
+ `如果确实要重新验证(换回调地址等),调 resetVerify() 或删掉 ` +
340
+ `data/webhook-verified.json 里这一条`,
341
+ );
342
+ return this.#json(res, 403, { error: 'already verified' });
343
+ }
344
+ if (st.hits.length >= 3) {
345
+ this.counters.rejected += 1;
346
+ log.warn(`[${appId}] op=13 尝试过频(每分钟上限 3 次),已限速`);
347
+ return this.#json(res, 429, { error: 'too many validation attempts' });
348
+ }
349
+ st.hits.push(now);
350
+ this.verifyState.set(appId, st);
351
+
352
+ const tok = String(payload?.d?.plain_token ?? '');
353
+ if (!tok || tok.length > 128 || /[\s{}"\\]/.test(tok)) {
354
+ this.counters.rejected += 1;
355
+ log.warn(
356
+ `[${appId}] op=13 的 plain_token 形态异常,已拒绝(疑似把事件体当 token 提交)`,
357
+ );
358
+ return this.#json(res, 400, { error: 'invalid validation' });
359
+ }
360
+
176
361
  const signed = verifyRequest(bot.secret, payload);
177
362
  if (!signed) {
178
363
  this.counters.rejected += 1;
179
364
  log.warn(`[${appId}] 回调验证缺少 plain_token / event_ts`);
180
365
  return this.#json(res, 400, { error: 'invalid validation' });
181
366
  }
367
+ st.okAt = now;
368
+ this.verifyState.set(appId, st);
369
+ this.#saveVerifyState();
182
370
  this.counters.verify += 1;
183
- log.info(`[${appId}] Webhook 回调验证通过`);
371
+ log.info(`[${appId}] Webhook 回调验证通过(24 小时内不再重复回签)`);
184
372
  return this.#json(res, 200, signed);
185
373
  }
186
374
 
@@ -205,6 +393,36 @@ export class WebhookServer {
205
393
  }
206
394
  }
207
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
+
208
426
  this.counters.received += 1;
209
427
  const eventName = String(payload?.t ?? '');
210
428
  const d = payload?.d ?? {};
package/web/app.js CHANGED
@@ -725,9 +725,25 @@ function renderStatus(s) {
725
725
  }
726
726
  }
727
727
 
728
+ /** 上一次看到的进程实例标识,用来判断服务是不是重启过 */
729
+ let lastInstanceId = '';
730
+
728
731
  async function pollStatus() {
729
732
  try {
730
- renderStatus(await api('/api/status'));
733
+ const d = await api('/api/status');
734
+ /**
735
+ * 进程重启 ⇒ 页面令牌全部重新生成、会话也全失效。
736
+ * 已打开的插件页 iframe 手里还是旧令牌,接下来每个请求都会被判成跨站(403),
737
+ * 用户看到的就是「切过去先红一下,等一会儿才好」。
738
+ *
739
+ * 这里一发现实例换了就立刻刷新令牌并重载 iframe,不必等兜底轮询。
740
+ */
741
+ const iid = String(d?.instanceId || '');
742
+ if (lastInstanceId && iid && iid !== lastInstanceId) {
743
+ refreshPageTokens();
744
+ }
745
+ lastInstanceId = iid;
746
+ renderStatus(d);
731
747
  } catch (e) {
732
748
  $('#connText').textContent = '面板断开';
733
749
  $('#connPill').className = 'status-pill offline';
@@ -1351,6 +1367,17 @@ async function loadPluginPages() {
1351
1367
  frame.dataset.key = p.key;
1352
1368
  // 页面令牌:不是面板密钥,只对这一个插件自己的接口有效
1353
1369
  frame.dataset.token = p.token || '';
1370
+ /**
1371
+ * **直接设上地址(预加载),不再等用户点开。**
1372
+ *
1373
+ * 以前这里是懒加载:注释写「地址在点开的时候才算」,好处是不点就不请求。
1374
+ * 但代价是第一次点插件标签要走完「下载 35KB HTML → 解析几百行内联脚本 →
1375
+ * 拉数据 → 渲染」,主观上就是卡一下,用户容易以为坏了。
1376
+ *
1377
+ * 现在建标签时就开始加载,点过去就是现成的。切换机器人时这个函数会重跑,
1378
+ * 地址里带上了新的 bot 参数,所以也不会串台。
1379
+ */
1380
+ frame.src = pluginPageUrl(p.key, p.token || '');
1354
1381
  sec.appendChild(frame);
1355
1382
  main.appendChild(sec);
1356
1383
  }
@@ -1385,6 +1412,64 @@ function pluginPageUrl(key, token) {
1385
1412
  return `/api/pages/${encodeURIComponent(key)}/html${s ? `?${s}` : ''}`;
1386
1413
  }
1387
1414
 
1415
+ /**
1416
+ * 跟着进程重启自动换令牌。
1417
+ *
1418
+ * 页面令牌只存在服务端内存里,进程重启就全部重新生成。而面板页如果是在重启
1419
+ * **之前**打开的,iframe 里还挂着旧令牌,插件页的所有请求都会被判成跨站
1420
+ * (403),表现为「数据空白」或者直接 `Failed to fetch` —— 用户完全看不出
1421
+ * 是重启导致的,只会觉得面板坏了。
1422
+ *
1423
+ * 这里定期问一次页面列表:令牌变了就更新 iframe 的地址并重载它。
1424
+ * 只在令牌真的变化时才重载(也就是重启过),平时不会打断用户操作。
1425
+ */
1426
+ async function refreshPageTokens() {
1427
+ let list = null;
1428
+ try {
1429
+ list = await api('/api/pages');
1430
+ } catch {
1431
+ return 0;
1432
+ }
1433
+ if (!Array.isArray(list)) return 0;
1434
+
1435
+ let reloaded = 0;
1436
+ for (const p of list) {
1437
+ for (const f of $$('.plugin-page')) {
1438
+ if (f.dataset.key !== p.key) continue;
1439
+ if (f.dataset.token === (p.token || '')) continue;
1440
+ f.dataset.token = p.token || '';
1441
+ // 只重载已经打开过的(没加载过的不去碰,免得白跑一次请求)
1442
+ if (f.getAttribute('src')) {
1443
+ f.src = pluginPageUrl(f.dataset.key, f.dataset.token);
1444
+ reloaded += 1;
1445
+ }
1446
+ }
1447
+ }
1448
+ return reloaded;
1449
+ }
1450
+
1451
+ // 15 秒足够快,又不会给面板添负担;令牌不变时这里什么都不做
1452
+ setInterval(() => {
1453
+ refreshPageTokens();
1454
+ }, 15000);
1455
+
1456
+ /**
1457
+ * 切回面板时立刻补一次。
1458
+ *
1459
+ * 两个真实场景:
1460
+ * 1. 浏览器会把后台标签页的定时器降频(Chrome 最长到 1 分钟一次),
1461
+ * 所以「去改个配置再切回来」最容易撞上旧令牌
1462
+ * 2. 保存配置会触发 config-watch 热加载,插件被卸载重载 → 页面令牌
1463
+ * 跟着重新生成 → 已打开的 iframe 立刻变无效
1464
+ * 回来这一下立刻修,比等定时器体感好得多。
1465
+ */
1466
+ window.addEventListener('focus', () => {
1467
+ refreshPageTokens();
1468
+ });
1469
+ document.addEventListener('visibilitychange', () => {
1470
+ if (!document.hidden) refreshPageTokens();
1471
+ });
1472
+
1388
1473
  /* ---------------- 统计 ---------------- */
1389
1474
 
1390
1475
  /** 每天一根柱子,纯 CSS 不用图表库——多引一个库就破了零依赖 */