From e2d52d3b3b4df67a8c020356467f066940f2010b Mon Sep 17 00:00:00 2001 From: anonsaber Date: Tue, 29 Sep 2026 19:48:40 +0800 Subject: [PATCH] docs: make opengaps.md say what the operator must do MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every blocker now states the concrete action required, the owner, and the expected result, instead of only explaining why it is blocked. The file was answering 'what is broken'; nobody could tell from it what to run. - new 你要做的事 table up front: 6 rows splitting each item between operator action and my action, so the reader sees at a glance which two items need a curl, which one needs an answer, which needs a downtime window, and which need nothing - item 1 Telegram: setWebhook + getWebhookInfo commands, allowed_updates narrowed to message to match the only field the service parses (message.text), and the rotation trap spelled out -- changing the SPA secret without re-running setWebhook leaves Telegram holding the old value, so real callbacks start failing with 401 silently, no error and no alert - item 2 PushSubscription: POST /api/push/register command plus the split of responsibility. Registration is performed by this service against JMAP, not in the Stalwart admin UI, and the verification code comes back over the callback and is verified automatically, so the operator never needs to obtain a code. Also documents the observable, the failure-code decode table, and the disable endpoint - item 3 rewritten: the operator action collapses to a single question (which component does the public mapping), because no Worker is in front of this deployment. The five TTL keys moved into a table with exact keys, purposes, TTLs and line anchors, and the pending 900s / verified 300s statuses are documented where they are set - item 4 spelled out as a concrete downtime window: stop the service while mail is still arriving, longer than Stalwart's delta retention, without running POST /reconcile during it, then I reconcile and check the cursor re-baselines instead of erroring - corrected getWebhookInfo field: it returns last_error_date, not last_error_time - anchor audit caught two of my own bad anchors on the first run -- the SPA-verification comment is at notify.rs:1163 and the callback URL validation at :1174, I had guessed :1151 and :1163, both off by 12. ANCHOR is now 272 refs / 0 errors, PATH 393 - GATE-DOCS 6/6 pass --- docs/opengaps.md | 106 ++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 92 insertions(+), 14 deletions(-) diff --git a/docs/opengaps.md b/docs/opengaps.md index e92c4e5..bae08d4 100644 --- a/docs/opengaps.md +++ b/docs/opengaps.md @@ -1,29 +1,107 @@ -# Open Gaps(`docs/opengaps.md`) +# 未完成项(Open Gaps) -> 只登记**仍未完成、仍未验证**的事项。已实现能力见 `docs/reference.md`、`docs/design.md`、`docs/deployment.md`; -> 被移除、废弃、明确拒绝的能力见 `docs/retired.md`。门禁命令见 `docs/deployment.md` 的 Gate 一节。 +本文件只列**仍未完成 / 仍未验证**的事项。已实现的接口与架构设计见 `docs/reference.md`、`docs/design.md`、`docs/deployment.md`,已退役的能力见 `docs/retired.md`。验证基线以 `docs/design.md`「P0 门禁」段落为准。 + +## 你要做的事 + +代码侧**没有**待你实现的项。四项阻塞里,两项需要你跑一条 curl,一项只需要你告诉我一个事实,一项需要你腾一个停机窗口。 + +| # | 事项 | 谁做 | 你要动手吗 | +| --- | --- | --- | --- | +| 1 | Telegram `setWebhook` | 你 | 跑 1 条 curl,然后发一条消息 | +| 2 | Stalwart `PushSubscription` | 你 | 跑 1 条 curl(注册由本服务自己向 JMAP 发起) | +| 3 | 回调公网映射 | 你 | 只回答一个问题:用哪个组件做映射 | +| 4 | `Email/changes` 的 `newState` 语义 | 你 + 我 | 腾一次停机窗口 | +| 5 | 单飞锁 TTL 实测 | 我 | 不需要的,依赖 #2 成功后我自己跑 | +| 6 | 允许名单收紧 | 你 | 仅当你要收紧时改业务配置,代码不用动 | ## 阶段目标 -`GATE-P0` 已过(fmt / clippy / check / test,Debian 容器内)。真实邮箱与真实 Telegram Bot 的**出站与查询方向**已在真机联调通过(见下文各条的「已验证」括注)。 -剩余目标收窄为:**在 Telegram 侧执行 `setWebhook`** 与 **`PushSubscription` 回调链路**,消掉下面 4 条阻塞项。 +剩余目标收窄为:**在 Telegram 侧执行 `setWebhook`**,以及在 Stalwart 侧走通 `PushSubscription` 回调链路,消掉下面 4 条阻塞项。出站查询方向(JMAP 只读 → Telegram 回帖)、`/search`、出站限流退避、多实例去重均已完成并真机通过。 -## 阻塞(需要真实环境,当前无法验证) +## 阻塞 -- **真实 Stalwart `PushSubscription`** — `Email/changes` 与 `Email/query` 搜索已在真实账号跑通(2026-09-28:`POST /reconcile` 持久化了 `baseline:` 游标,而该游标只在 `reconcile()` 返回 `Ok(new_state)` 之后写入(`notify.rs:331`),即 `fetchChanges` 端到端成功)。**未验证**:账号角色是否具备 push 权限、Stalwart 回调重试次数、TTL/对账间隔的匹配。**阻塞原因**:无真实推送回调可观察。禁止使用测试账号或伪造结果。 +### 1. 真实 Telegram 回调(Telegram → 本服务方向) -- **真实 Telegram 回调(Telegram→本服务方向)** — 应用侧入站 secret 头校验 + chat 白名单(`notify.rs:225-263`)、出站 `sendMessage`(`channel.rs`)、出站重试预算与重试耗尽终局错误(`channel.rs:182`)已在真实 Bot 上验证(2026-09-28:`POST /debug/notify` 投递成功;伪造 update 全链路「入队→worker→JMAP `Email/query` 搜索→回帖」通过;以上两条均经用户确认收到)。**未验证**:真实的 Telegram→本服务回调从未到达——向 Telegram 注册 webhook URL 是运维步骤(调 `setWebhook`),应用内不实现,**目前尚未执行**,因此 Telegram 侧既不知道本服务的 webhook URL,也不持有 secret 值。另:长轮询 `getUpdates` 从未实现(见 `docs/retired.md`)。**阻塞原因**:需在 Telegram 侧执行 `setWebhook`(`url=<公网地址>/webhook/tg`、`secret_token=<业务配置里的值>`,两者必须一致)并触发一次真实回调。 +出站方向已验证,但**Telegram → 本服务从未被真实流量验证过**:到目前为止 `/webhook/tg` 收到的全部是伪造 update。Telegram 侧还从未收到过 `setWebhook`,所以 Telegram 既不知道回调地址,手里也没有 secret。 -- **`Email/changes` 的 `newState` 语义** — 客户端已用「同 `sinceState` 翻倍 `maxChanges` 扩窗」消除按页漏批;2026-09-28 在真实账号上跑通 `fetchChanges` 并拿到 `baseline:` 游标,但 `newState` 是否表示"全部待报变更之后"仍需**停机积压后恢复**的场景才能判定,否则积压边界仍无法确证。**阻塞原因**:无可控的真实积压场景。 +**你要做的:** -- **Push callback 公网映射 + 单飞锁 TTL** — 单飞锁 `lock:push-register:{sha256(callback_url)}`(360s)与映射 `push:registration:{sha256(callback_url)}`(7d)的 TTL 需真实回调时序验证。**阻塞原因**:无真实推送回调可观察。 +1. 登记 webhook(`` = 业务配置里的 `telegram_webhook_secret`): + ```bash + curl -sS "https://api.telegram.org/bot/setWebhook" \ + -d url=https://messageweave-eu-1.motofans.club/webhook/tg \ + -d secret_token= \ + -d 'allowed_updates=["message"]' + ``` + 不传 `allowed_updates` 时默认订阅全部更新类型;显式收窄到 `message` 与本服务只解析 `message.text` 一致。 +2. 确认已登记: + ```bash + curl -sS "https://api.telegram.org/bot/getWebhookInfo" + ``` + 看 `result.url` 等于上面那个地址。**Telegram 不回显 `secret_token`**,所以 secret 写对没有只能靠第 3 步反过来验。 +3. 用允许名单里的 chat id 发一条 `/help`,然后告诉我「发了」。 + +我来收尾:`POST /api/worker` 排空队列并确认回帖。如果没收到,`getWebhookInfo` 里的 `last_error_message` 与 `last_error_date` 是最快的定位线索。 + +**轮换注意:** 在 SPA 改了 `telegram_webhook_secret` 而**没有**重跑 `setWebhook`,Telegram 手里就是旧值——之后所有真实回调都会被 401 拒掉,**不报错、无告警,只是静默收不到消息**。改了就重跑第 1 步。 + +### 2. 真实 Stalwart `PushSubscription` + +`PushSubscription/set` 的验证往返只在本地单测里跑过(测试指向一个永不连通的地址);生产环境从未注册过订阅,所以 Stalwart 从未发过一次真实 push 回调,`/push/jmap` 的验证握手分支从未被真实请求走过。 + +**先说清分工:** 注册动作由**本服务自己**向 JMAP 发起,不是在 Stalwart 后台点。调用即完成 `PushSubscription/set` create 并限制订阅类型;**验证码由 Stalwart 生成、经回调送回来,本服务自动完成验证,不接受 SPA 里配置的验证码**(`src/notify.rs:1163`)。所以你不需要去拿验证码,你只需要让回调地址能被 Stalwart 访问到。 + +**你要做的:** + +1. 确认回调地址公网可达,且 Stalwart 出站能访问到它(防火墙/代理放行)。是 Stalwart **回调**你,不是你去连它。 +2. 注册: + ```bash + curl -sS -X POST "https://messageweave-eu-1.motofans.club/api/push/register" \ + -H "Authorization: Bearer " \ + -H "content-type: application/json" \ + -d '{"callback_url":"https://messageweave-eu-1.motofans.club/push/jmap"}' + ``` + 成功 → `200 {"push_subscription_id":"..."}`,状态置 `pending`(TTL 900s,`src/notify.rs:1240`),在等 Stalwart 回来验证。 +3. Stalwart 的验证请求到达后,本服务自动验证并置 `verified`(TTL 300s,`src/notify.rs:1125`)。日志在 stdout(`SAF-LOG-STDOUT-ONLY`)。 +4. 确认已落库:再跑一次第 2 步,返回里多出 `"idempotent":true` 即说明订阅已持久化。也可反向确认——`POST /api/push/disable` 在从未注册过该地址时回 `404 push_subscription_not_found`。 + +**失败怎么读:** `400 invalid_request` = 回调地址不是 HTTPS,或 URL 里带了用户名/密码(本服务禁止这两者,`src/notify.rs:1174`);`409 conflict` = 注册单飞锁还被上一次请求占着,稍后重试;`503` = 业务配置此刻不可用或 JMAP 请求失败,锁已释放,可重试。 + +**取消注册:** 带 `{"callback_url":"..."}` 打 `POST /api/push/disable`。 + +### 3. Push callback 公网映射 + 单飞锁 TTL + +**这部分你不需要跑命令,只需要回答一个问题:公网映射由哪个组件负责。** 当前这个测试部署前面没有 Cloudflare Worker,我不知道该在哪里放行 `/push/jmap`。候选是 `cloudflare-worker/` 里已有的实现(从未部署)、nginx,或其他 ingress。 + +TTL 参数已在代码里写死、单测也已覆盖,但真实场景下的实际过期行为从未观测过: + +| 键 | 用途 | TTL | 位置 | +| --- | --- | --- | --- | +| `lock:push-register:{sha256(callback_url)}` | 注册单飞锁 | 360s | `src/notify.rs:1178` | +| `lock:reconcile` | 对账锁(长对账中每 90s 续租) | 300s | `src/notify.rs:289` / `:302` | +| `ratelimit:push-verify:{subscription_id}` | 验证限流 | 30s | `src/notify.rs:1095` | +| `dedup:tg:{update_id}` | Telegram 更新去重 | 86400s | `src/notify.rs:248` | +| `dedup:jmap:{account_id}:{email_id}` | JMAP 更新去重 | 86400s | `src/notify.rs:1134` | + +我来做的:订阅注册成功(第 2 项)之后,触发一次真实回调并记录这五个键的实际过期行为,把实测值回填本节。在这之前本节保持阻塞。 + +### 4. `Email/changes` 的 `newState` 语义 + +`jmap-client` 的 `fetchChanges` 在服务器无法回放旧增量时会返回 `newState`,要求客户端重新基线化。本代码库的 `since` 游标处理只在测试服务器上跑过,**尚未出现一次服务器要求重新基线化的真实场景**。 + +**你要做的:** 腾一次停机窗口——在邮箱还有新邮件进入的时候把本服务停掉,停到 Stalwart 的增量历史过期为止(保留时长由你的 Stalwart 配置决定;窗口要长于它)。停机期间**不要**跑 `POST /reconcile`。 + +我来做的:恢复后跑 `POST /reconcile`,再看 `/debug/worker` 的 `reconcile_cursor`。预期是重新变成一个**新的 `baseline:` 游标**(重新基线化成功),而不是报错或停在旧游标。实测语义回填本节。 ## 代码缺口(已定位) -仅 1 条,按产品决策保留不改;其余已实现,见各自 commit 与 `docs/reference.md`。真正的阻塞是上面的真实环境依赖。 +仅 1 条,按产品决策保留不改——**这不是待修项,也不需要你做任何事**。 -- **`/debug/notify` 空白名单不拦截**(`SAF-DEBUG-ALLOWLIST`)— 白名单校验只在白名单**非空**时才生效(`src/debug.rs:232-235`),因此业务白名单未配置时 `chat_id` 可为任意值。**已定位、已文档化、不改代码**:该面默认关闭且需 Bearer,且业务白名单为空时主产品本身即接受任意 chat,沿用同一语义不构成额外泄露面。若日后要收紧,需把语义改为「空白名单则拒绝」,并补单元测试、跑 `GATE-P0`。 +- **`SAF-DEBUG-ALLOWLIST`** — `POST /debug/notify` 的收件人校验在允许名单为空时**无条件放行**任意 `chat_id`(`src/debug.rs:233-235`:`!allowlist.is_empty() && !allowlist.contains(&chat_id)`)。空名单即「未启用收紧」,是刻意行为。要收紧:在业务配置里把允许名单填上,代码不用改。要改代码:先推翻这个决策。 -## 后续(可选项,产品决策后开启) +## 后续(多账号) -- **多账号支持**(`REQ-SINGLE-ACCOUNT`)— 当前单账号由产品决策固定,非阶段目标。 +单账号由产品决策固定,不在阶段目标内。当前 JMAP 登录与 `Email/query` 均为单一邮箱账号。 + +**需要你决定:** 是否需要多账号。要的话告诉我,我先把 `REQ-SINGLE-ACCOUNT` 的约束范围拆出来再动代码。