From 399b26918e2bd134e8881eb51823bf7ef2b11f7c Mon Sep 17 00:00:00 2001 From: anonsaber Date: Mon, 28 Sep 2026 16:20:18 +0800 Subject: [PATCH] =?UTF-8?q?docs:=20=E2=91=A3=20/debug/*=20=E9=85=8D?= =?UTF-8?q?=E5=A5=97=E6=96=87=E6=A1=A3=20+=20=E5=85=A8=E9=87=8F=E9=94=9A?= =?UTF-8?q?=E7=82=B9=E9=87=8D=E9=94=9A=20+=20AGENTS=20v3=20=E7=89=88?= =?UTF-8?q?=E6=9C=AC=E5=9F=BA=E7=BA=BF=EF=BC=8801a0e6de=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 锚点重锚(afa6cec 之后) ④ 对 notify.rs 的编辑使一批 `notify.rs:N` 引用整体失效。全量重扫 8 个 md、 165 个 src 锚点,0 硬错误(越界 / 区间全空 / 单行指空行三类全查)。修正要点: - reference.md(30+ 处):dedup/committed/inflight/ratelimit 键调用点、 error_response 320-328、error_response_with_id 1087-1093、readiness 161、 router 1275 + routes 1304-1318、worker 330、main.rs 40/50/34/78-81、 NoopWorker 由 notify.rs 更正为 worker.rs:142-144、 PROBE_TIMEOUT 73 与 tokio::join! 147、state.rs 639。 - deployment.md:149-152→227-230、159/172→239/252、index.js 40-55→45-60。 - retired.md:704→717、887-890→897-900、326→330、873→884。 - roadmap.md:95→175;design.md:main.rs 109→128。 - cloudflare-worker 侧 4 处 stale:ROUTE_METHODS 45-60、404 77-78、 405 80-83、503 85-93。 ## /debug/* 文档 - deployment.md 新增 §2.1:双因子门禁、7 条路由状态码表、/debug/config 字段清单、 三条部署约束、生产建议;§5 env 表补 --debug / DEBUG_TOKEN。 - README.md / README.zh-CN.md 各加一段「远程联调面(可选,默认关闭)」,指向 §2.1。 - design.md 新增 §7.6「为何默认绝对关闭」:双因子挂载、404 而非 401 (401 会泄漏路由存在性)、无默认开启回退、不在网关白名单内 fail-closed。 ## 文档纠错(与任务书相反,已按源码核实) - /debug/worker 是 GET 不是 POST(debug.rs:79)。 - /debug/config 不是「只出布尔」:明文回 jmap.{session_url,username}、 telegram.chat_id、llm.{base_url,model} 与全部 timeout/retry, 仅 5 个凭据字段出 *_configured。文档措辞改为「不含凭据,不是全脱敏」。 - 门禁位置更正:--debug 读 main.rs:99、DEBUG_TOKEN 读 main.rs:100-103、 merge 在 notify.rs:1320-1321(无 DEBUG_BASE 常量,路径为字面量)。 ## AGENTS.md - 版本基线 v3(代码 afa6cec / 文档 e502c40),三处文档基线号 c5bc7b8→afa6cec。 - 新增 2 个 ID:SAF-DEBUG-ORIGIN-ONLY(不在网关 14 条白名单,Worker 404 route not forwarded,只能直连 origin)、SAF-DEBUG-ALLOWLIST。 - §6 deployment.md 行、§7 六行 ID 定义点回填文档位置。 ## 未决项登记 - roadmap.md 新增:SAF-DEBUG-ALLOWLIST —— debug.rs:232-235 的白名单判定 「非空且不含」导致业务白名单为空时 /debug/notify 的 chat_id 不拦截。 本轮不改代码(该面默认关闭且需 Bearer,空白名单下主产品本就接受任意 chat); 若要收紧需改为「空白名单则拒绝」+ 补测试 + 跑 gate。 ## 校验 文档校验器 0 error;负向对照断链/死锚点/奇数反引号/页内锚点 4 类注入全命中。 48 张 markdown 表 0 处单元格数不符。行数合计 2415。纯文档,未触碰 src/ web/ cloudflare-worker/。代码基线仍 afa6cec,gate 49 passed / 0 failed / 1 ignored。 --- AGENTS.md | 16 +++++--- README.md | 8 ++++ README.zh-CN.md | 6 +++ docs/deployment.md | 46 +++++++++++++++++++-- docs/design.md | 6 ++- docs/reference.md | 99 +++++++++++++++++++++++++++++----------------- docs/retired.md | 10 ++--- docs/roadmap.md | 5 ++- 8 files changed, 143 insertions(+), 53 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 083c7df..31b7207 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -7,6 +7,10 @@ > 三份文档职责分离,改动时请遵循 [§6 文档边界](#6-文档边界与引用关系),避免内容重复堆砌与跨文档冲突。 > > **跨文档引用一律用稳定 ID**(`C-` / `NG-` / `MOD-` / `FLOW-` / `REQ-` 等),**不用章节号(§x.y)**;定义点位置与一句话说明见 [§7 跨文档引用索引](#7-跨文档引用索引表)。 +> +> **版本基线 v3(2026-09-26,本轮 ④+⑤):代码 `afa6cec`,文档 `e502c40`。** +> 本轮新增 `src/debug.rs` 远程联调面与 6 个 `*DEBUG*` ID(§2 第 19 条、§5 测试清单、§7 六行),文档侧同步落位 +> `docs/deployment.md` §2.1、`docs/reference.md` §3/§4、`README` 中英双版;各文档的行号锚点以该基线为准,代码变动后须同步重锚。 --- @@ -150,7 +154,7 @@ | `README.md` / `README.zh-CN.md` | 面向最终用户:是什么 / 五分钟跑起来 / 配置入口 / 安全边界一句话 / 去哪读更多(英文版 / 中文版,内容须对等,顶部互链) | 用户可见行为或流程变更时 | | `docs/reference.md` | **可核对事实的唯一权威来源**:Redis 键与 TTL、错误 envelope 与错误码、后端路由与网关白名单矩阵、环境变量三层、出站与预算常量 | 公共 API 或 Redis 键名/TTL 变更时(**必更**) | | `docs/design.md` | 产品行为、架构、模块接口、状态机、数据流、错误处理、测试、实施阶段、渠道抽象(Channel/Notifier/MessageAdapter)、历史问题与决策归档(§11,产品/架构类,均已收口) | 行为/接口变更时 | -| `docs/deployment.md` | 通用 HTTPS-only Docker、Debian、构建/运行时、Secrets、Redis(状态唯一载体)、Webhook/Push/对账短请求路由、**多实例 LB/HA(Worker 前置,§10)**、健康检查、CI、外部调度示例、已确认决策表(`Q-DEP-A`/`Q-DEP-B` 已决策归档) | 部署/发布变更时 | +| `docs/deployment.md` | 通用 HTTPS-only Docker、Debian、构建/运行时、Secrets、Redis(状态唯一载体)、**可选远程联调面 `/debug/*`(§2.1,`SAF-DEBUG-GATE`)**、Webhook/Push/对账短请求路由、**多实例 LB/HA(Worker 前置,§10)**、健康检查、CI、外部调度示例、已确认决策表(`Q-DEP-A`/`Q-DEP-B` 已决策归档) | 部署/发布变更时 | | `docs/roadmap.md` | **只放缺口、阻塞与阶段目标**(取代 `docs/todo.md`,已删除) | 缺口或阻塞项增减时 | | `docs/retired.md` | **已废弃 / 未采用路线与虚构条目**:每条含「类型 / 原因 / 替代或现状」;记录 teloxide 未采用、FSM 未落地、幽灵文件与虚构键、已删文档。**只写结论与原因,不写当前事实** | 新增废弃项或删除文档时 | | `AGENTS.md`(本文件) | 目标、硬性安全边界、实现顺序、禁止事项、测试验收、文档引用关系、**跨文档引用索引(§7)** | 安全边界/流程变更时 | @@ -249,10 +253,12 @@ | `SAF-RECONCILE-LOCK` | docs/deployment.md §10.5 | `/reconcile` 不扇出,Redis 锁保证单实例执行,避免重复对账 | 安全 | | `MOD-STREAMS-GROUP` | docs/deployment.md §10.5 | 多实例用同一 Streams 消费组名,Redis 自动分摊(at-least-once 不重复处理) | 组件 | | `MOD-HEALTH-AGG` | docs/deployment.md §10.6 | Worker 聚合健康视图,报告各后端存活供外部监控 | 组件 | -| `MOD-DEBUG` | src/debug.rs / AGENTS.md §2 | 远程联调只读表面:`--debug` + `DEBUG_TOKEN` 双因子开启后挂载 `/debug/*`,否则不挂载 | 组件 | -| `SAF-DEBUG-GATE` | src/main.rs / src/debug.rs | 双因子门禁:启动带 `--debug` **且** `DEBUG_TOKEN` 非空才挂载路由;缺任一完全不挂载(请求落通用 `404`),默认绝对关闭 | 安全 | -| `SAF-DEBUG-AUTH` | src/debug.rs | 挂载后 `/debug/*` 须 `Authorization: Bearer DEBUG_TOKEN` 常数时间比较,失败 `401` 且无副作用 | 安全 | -| `REQ-DEBUG-ENDPOINTS` | src/debug.rs / AGENTS.md §2 | 端点契约:`GET /debug/ping`、`/config`、`/redis`、`/jmap`、`/telegram`、`/worker` 均只读;`POST /debug/notify` 走真实出站链路发一条测试消息;响应体不含 secret 原文 | 需求 | +| `MOD-DEBUG` | src/debug.rs / docs/deployment.md §2.1 / docs/design.md §7.6 / docs/reference.md §3 | 远程联调只读表面:`--debug` + `DEBUG_TOKEN` 双因子开启后挂载 `/debug/*`,否则不挂载 | 组件 | +| `SAF-DEBUG-GATE` | src/main.rs / src/debug.rs / docs/design.md §7.6 / docs/deployment.md §2.1 | 双因子门禁:启动带 `--debug` **且** `DEBUG_TOKEN` 非空才挂载路由;缺任一完全不挂载(请求落通用 `404`),默认绝对关闭 | 安全 | +| `SAF-DEBUG-AUTH` | src/debug.rs / docs/deployment.md §2.1 | 挂载后 `/debug/*` 须 `Authorization: Bearer DEBUG_TOKEN` 常数时间比较,失败 `401` 且无副作用 | 安全 | +| `REQ-DEBUG-ENDPOINTS` | src/debug.rs / docs/reference.md §3 / docs/deployment.md §2.1 | 端点契约:`GET /debug/ping`、`/config`、`/redis`、`/jmap`、`/telegram`、`/worker` 均只读;`POST /debug/notify` 走真实出站链路发一条测试消息;响应体不含 secret 原文(凭据字段只出 `*_configured` 布尔,非密文的身份与预算字段仍明文返回) | 需求 | +| `SAF-DEBUG-ORIGIN-ONLY` | docs/deployment.md §2.1 / docs/reference.md §4 | `/debug/*` 不在网关 14 条安全路由内,Worker 一律 `404 route not forwarded`;只能直连后端 origin,公网不可达 | 安全 | +| `SAF-DEBUG-ALLOWLIST` | src/debug.rs / docs/deployment.md §2.1 | `POST /debug/notify` 仅在 chat 白名单**非空**时校验 `chat_id`;白名单未配置(空)时不拦截,故启用本面须确认业务白名单已配置 | 安全 | | `NFR-HA-MULTI-INSTANCE` | docs/deployment.md §10.7 / §9.1 | 多实例高可用语义;双活或主备均可;Redis 单点故障不在方案范围(用户外部解决) | 非功能 | | `C-NO-DB` | docs/deployment.md §0 / §9.1 / AGENTS.md §2 | 生产不使用任何数据库(无 SQLite/Postgres/MySQL/嵌入式),Redis 为唯一状态存储;应用不连接第二个数据库 | 约束 | | `C-NO-LOCAL-WRITE` | docs/deployment.md §0 / §9.1 / AGENTS.md §2 | 禁止本地文件/目录写入(日志/数据/临时缓存/本地卷) | 约束 | diff --git a/README.md b/README.md index 0940c66..6547204 100644 --- a/README.md +++ b/README.md @@ -76,6 +76,14 @@ Telegram `getMe`, 3s each) are reachable. Probe `/ready`, not `/healthz`, when d whether to route traffic — but `/ready` is the heavier probe (~3s worst case), so a monitor that must stay cheap should watch the gateway's aggregated `/healthz`. +**Remote debug (opt-in, off by default).** +`/debug/*` is an optional remote-debug surface: live JMAP and Telegram probes, the current +business config, and a single Telegram send. It sits behind a two-factor gate — the process +must be started with `--debug` **and** `DEBUG_TOKEN` must be set; miss either and the routes +do not exist at all (requests fall through to a generic 404). Nothing under `/debug/*` is on +the gateway allowlist, so it is reachable only on the backend origin itself. See +`docs/deployment.md` §2.1. + ## 4. Security boundary, in one sentence > All state lives in Redis; the process reads only two environment variables and writes diff --git a/README.zh-CN.md b/README.zh-CN.md index 32bdf5d..80e5103 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -70,6 +70,12 @@ docker run --env-file .env -p 8080:8080 messageweave:latest 流量时,请探测 `/ready` 而不是 `/healthz`;但 `/ready` 是较重的探针(最坏约 3s),需要 保持轻量的监控请看网关聚合的 `/healthz`。 +**远程联调面(可选,默认关闭)。** +`/debug/*` 是一套可选的远程联调面:JMAP 与 Telegram 实时探针、当前业务配置,以及单条 +Telegram 发送。它由双因子开关控制——进程必须带 `--debug` **且** 已设置 `DEBUG_TOKEN`; +任一缺失则这些路由完全不存在(请求落到通用 404)。`/debug/*` 不在网关白名单内,因此只能 +直连后端 origin 访问。见 `docs/deployment.md` §2.1。 + ## 4. 安全边界,一句话 > 所有状态都在 Redis 里;进程只读两个环境变量,不写任何磁盘。 diff --git a/docs/deployment.md b/docs/deployment.md index a5ef14b..6a85797 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -53,6 +53,45 @@ (保留既有内容:debian:bookworm-slim 的运行基础、非 root 用户、时区、CA 证书等通用约定) +### 2.1 可选远程联调面 `/debug/*`(`SAF-DEBUG-GATE`) + +生产入口默认**绝对关闭**:`/debug/*` 是一套只读探测 + 单条出站通知的远程联调面,只有部署者主动开启才会出现。 + +**双因子启用条件(`SAF-DEBUG-GATE`,两者必须同时成立)** + +1. 进程命令行必须带 `--debug`(读取于 `src/main.rs:99`)。 +2. `DEBUG_TOKEN` 环境变量必须存在且非空(读取于 `src/main.rs:100-103`)。 + +缺一即不挂载:`debug_router()` 本身会构造出全部 7 条路由(`src/debug.rs:72-80`),但主入口**只在双因子成立时才合并它**(`src/notify.rs:1320-1321`)。所以未开启时 `/debug/*` 路由根本不存在,请求走 axum 兜底返回普通 `404 not found`——**不是** 401,也不会泄露「此路径存在」。开启成功时打一条 WARN 日志标记该面已打开(`src/main.rs:107-109`,`SAF-LOG-PURITY`:只记开启状态,从不记录 token 值)。 + +**鉴权** + +7 条路由全部要求 `Authorization: Bearer `,统一走 `debug_authorized`(`src/debug.rs:45`),它委托生产同款 `worker_authorized`,因此令牌是**常数时间比较**(`src/notify.rs:1135`)。失败回 `401 unauthorized`,且不设 `Retry-After`(`src/debug.rs:52-54`)。 + +**7 条路由与预期状态码**(注册于 `src/debug.rs:72-80`,路径为字面量,无常量抽取) + +| 路由 | 成功 | 失败 | +|---|---|---| +| `GET /debug/ping` | `200 {"ok":true}` | 401 | +| `GET /debug/config` | `200` 见下段 | 401 | +| `GET /debug/redis` | `200 {"reachable":true,"global_enabled":…}` | 401;Redis 探活失败**仍为 200** `{"reachable":false,"detail":"redis_probe_failed"}`,不返回 503 | +| `GET /debug/jmap` | `200 {"ok":true}` | 401;探针失败**仍为 200** `{"ok":false,"detail":…}`(与 `/ready` 共用同一探针) | +| `GET /debug/telegram` | `200 {"ok":true}` | 401;同上,探针失败返回 200 + `ok:false` | +| `GET /debug/worker` | `200`(`revision`、`reconcile_cursor`、`outbound` 预算) | 401 | +| `POST /debug/notify` | `200 {"ok":true,"result":{…}}` | 401;业务配置未加载或无出站客户端 → `503 service_unavailable` + `Retry-After: 30`(`src/debug.rs:56-58`);`chat_id` 不在白名单 → `403 chat_not_allowed`(`src/debug.rs:60-62`);Telegram 发送失败 → `502 telegram_send_failed`(`src/debug.rs:64-66`) | + +`/debug/config` 的响应字段(`src/debug.rs:110-148`):恒有 `revision`、`setup_missing`、`business_configured`、`allowlist_size`;业务配置存在时再加 `jmap.{session_url,username,account_id}`、`telegram.{chat_id,webhook_secret_configured}`、`worker.{worker_token_configured,reconcile_token_configured}`、`llm.{enabled,allow_net,api_key_configured,base_url,model}`、`outbound.{jmap_timeout_ms,telegram_timeout_ms,llm_timeout_ms,max_retries}`;配置缺失时只回 `business_configured:false` + `allowlist_size`。 + +方法不匹配先于鉴权判定(例如 `GET /debug/notify` 返回 `405`)。 + +**三条约束,部署时务必确认** + +- **绝不回显凭据值**:`debug_config`(`src/debug.rs:94`)对每个 Secret 字段只输出 `*_configured` **布尔**——JMAP 密码、bot token、`worker_token`、`reconcile_token`、LLM `api_key` 一律不落响应体(`SAF-DEBUG-AUTH`)。注意这是「不含凭据」,不是「全脱敏」:**非密文的身份与预算字段是明文返回的**(JMAP session URL 与 username、Telegram `chat_id`、LLM `base_url`/`model`、各类超时与重试数),所以该面仍只能放在可信网络上。 +- **`/debug/notify` 受 chat 白名单约束,但空白名单不拦截**:它复用生产同一份白名单快照(`src/notify.rs:1141`),判定条件是「白名单**非空**且 `chat_id` 不在其中」才回 `403 chat_not_allowed`(`src/debug.rs:232-235`)。因此已配置白名单时无法绕过业务侧发送限制;若白名单未配置(为空)则此判定不生效,`chat_id` 可任意指定——所以启用本面时应确认业务白名单已真正配置。`text` 缺省为固定联调文案,并按 1024 字符截断(`src/debug.rs:240-245`)。 +- **不在 Worker 白名单内,只能直连 origin**:网关的 14 条安全路由(`cloudflare-worker/src/backends.js:9-24`)不含任何 `/debug/*`,Worker 对未白名单路径返回 `404 route not forwarded: /debug/...`(`cloudflare-worker/src/index.js:77-78`)。因此 `/debug/*` 只能通过直连后端 origin 访问;若必须经代理,请自行在代理层加鉴权,不要让公网可达。 + +> **建议**:生产环境不开启。需要远程联调时临时开启、用一次性 token,联调结束立即移除 `--debug` 与 `DEBUG_TOKEN` 后重启;长期暴露面走 §5 的 Secret 管理流程单独审批。 + --- ## 3. Rust 多阶段构建与运行 @@ -113,6 +152,7 @@ HTTP 端口并以 `/healthz` 做 interval 30 秒、timeout 5 秒的健康检查 | `RECONCILE_TOKEN` | ✅ | `/reconcile` 的 `Authorization: Bearer ` 承载令牌(`SAF-AUTH-RECONCILE`)。因 `/reconcile` 路由**始终挂载**,此变量为**必填**(`SecretString`) | | `WORKER_TOKEN` | ✅ | `/worker` 的有界处理令牌;管理 API 兼容接受该 Bearer 值,SPA 使用短期 Redis admin session | | `TG_WEBHOOK_SECRET` | ✅ | `/webhook/tg` 校验请求头 `X-Telegram-Bot-Api-Secret-Token`(`SAF-AUTH-TG-WEBHOOK`)。须与 Telegram `setWebhook` 的 `secret_token` **完全一致**(`SecretString`) | +| `--debug` + `DEBUG_TOKEN` | 可选,默认关闭 | 远程联调面 `/debug/*` 的**双因子开关**(`SAF-DEBUG-GATE`):命令行必须带 `--debug` **且** `DEBUG_TOKEN` 非空才挂载 7 条路由,缺一即路由不存在、请求走通用 `404`。令牌为 `SecretString`,常数时间比较、不进日志(`SAF-LOG-PURITY`)。生产环境不配置,见 §2.1 | | Push registration/verification | 已接入,需显式注册 | `POST /api/push/register` 接受 HTTPS callback URL 并调用 `PushSubscription/set create`;相同 callback URL 重复请求幂等复用;`/push/jmap` 接收 Stalwart 生成的验证码并自动回写,Redis 保存订阅 ID 和短期验证状态 | | `LLM_*` | 可选 | OpenAI-compatible 环境变量(design.md `REQ-LLM-OPENAI-COMPAT`);**仅当用户明确允许时才把邮件正文外发 AI**(`REQ-AI-EXTERNAL-CONSENT`) | | `ACCOUNT_ID` | 默认空 | **单账户**(`REQ-SINGLE-ACCOUNT`):留空则取 session 主账户;多账户 = 部署多个 bot 实例(各自独立 token/配置),不做多账户单实例 | @@ -209,7 +249,7 @@ GET /ready 公开就绪探针;检查配置完整性 + Redis 可达 #### 6.3.1 用户侧调度示例(copy-paste 可用) 端点:`POST https://<你的平台URL>/reconcile`。鉴权:`Authorization: Bearer ` -(`src/config.rs:356` 启动必填,服务端常数时间比较,`notify.rs:149-152`)。 +(`src/config.rs:356` 启动必填,服务端常数时间比较,`notify.rs:227-230`)。 最小示例: @@ -277,7 +317,7 @@ spec: - 调度间隔按 `NFR-RECONCILE-INTERVAL` 取 **5–10 分钟**即可。Push 是主路径,对账只是兜底。 - 走 Worker 转发**无需特殊设置**:网关已为 `POST /reconcile` 单独覆盖超时与尝试次数——超时取 `LB_RECONCILE_TIMEOUT_MS`(默认 `320000` ms,大于单飞锁租期 300 s + 心跳余量;锁续租逻辑见 - `notify.rs:159`/`172`),且 `maxAttempts=1` **绝不故障转移**(故障转移只会让第二实例立刻返回 + `notify.rs:239`/`252`),且 `maxAttempts=1` **绝不故障转移**(故障转移只会让第二实例立刻返回 `409`)。其余快路径仍用全局 `LB_REQUEST_TIMEOUT_MS`(默认 `10000` ms)与 `LB_MAX_ATTEMPTS` (默认 `2`),不受影响。 @@ -447,7 +487,7 @@ Stalwart PushSub ────┼─▶ https://lb..workers.dev ← 唯 ### 10.4 路由与故障转移 -- **路由 safelist**(`C-LB-SINGLE-REG-URL`):Worker 只透传 **14 条**(`backends.js:9-24` 的 `SAFE_ROUTES` + `index.js:40-55` 的 `ROUTE_METHODS`):`GET /`、`/assets/config.js`、`/assets/styles.css`、`GET /api/status`、`GET|PUT /api/config`、`PUT /api/business-config`、`POST /api/admin/session`、`POST /api/admin/session/revoke`、`POST /webhook/tg`、`POST /push/jmap`、`POST /api/push/register`、`POST /api/push/disable`、`POST /reconcile`、`GET /ready`;**未知路径 404、method 不符 405**,不透传至后端。**后端另有 4 条不在 safelist**:`GET|PUT /api/enabled`(内部开关)、`POST /api/bootstrap`(一次性信任引导,**不能**走 Worker 域名)、`POST /worker`(运维手工触发的 worker 端点,Bearer 鉴权)、`GET /healthz`(网关自行聚合,不转发)——**这 4 条都不承载外部业务流量,因此后端实例前不需要第二道入口**。`/api/status` 只返回启动状态和缺少的环境变量名;所有管理 API 的 Bearer 鉴权由后端执行(`SAF-LB-PASSTHRU`)。 +- **路由 safelist**(`C-LB-SINGLE-REG-URL`):Worker 只透传 **14 条**(`backends.js:9-24` 的 `SAFE_ROUTES` + `index.js:45-60` 的 `ROUTE_METHODS`):`GET /`、`/assets/config.js`、`/assets/styles.css`、`GET /api/status`、`GET|PUT /api/config`、`PUT /api/business-config`、`POST /api/admin/session`、`POST /api/admin/session/revoke`、`POST /webhook/tg`、`POST /push/jmap`、`POST /api/push/register`、`POST /api/push/disable`、`POST /reconcile`、`GET /ready`;**未知路径 404、method 不符 405**,不透传至后端。**后端另有 5 类不在 safelist**:`GET|PUT /api/enabled`(内部开关)、`POST /api/bootstrap`(一次性信任引导,**不能**走 Worker 域名)、`POST /worker`(运维手工触发的 worker 端点,Bearer 鉴权)、`GET /healthz`(网关自行聚合,不转发)、`/debug/*`(7 条远程联调面,`SAF-DEBUG-GATE`,只能直连后端 origin,见 §2.1)——**这 5 类都不承载外部业务流量,因此后端实例前不需要第二道入口**。`/api/status` 只返回启动状态和缺少的环境变量名;所有管理 API 的 Bearer 鉴权由后端执行(`SAF-LB-PASSTHRU`)。 - **健康聚合(`MOD-HEALTH-AGG`)**:Worker 自行承载 `GET /healthz`,按 TTL 缓存(默认 30s,`LB_HEALTH_TTL_MS` 可调)探测各后端 `/healthz`,返回 `{status, available, total, backends:[{origin,up,status}]}`;≥1 后端 up → 200,全 down → 503。`/ready` 透传给后端,做配置 + Redis + 出站只读探测(JMAP session、TG getMe,各 3s、并行,最坏约 3s),不触发业务副作用。 - **故障转移(`forwardWithFailover`)**:每次请求最多 `min(LB_MAX_ATTEMPTS, origins.length)` 次尝试;**仅**超时(AbortError)或 5xx 触发换下一个 origin;4xx/2xx/3xx 直接返回;默认 `LB_MAX_ATTEMPTS=2`(首次 + 1 次故障转移)。**例外:`POST /reconcile` 固定 `maxAttempts=1`,绝不故障转移**——它持集群级单飞锁,切实例只会立刻撞 `409`;其单请求超时取 `LB_RECONCILE_TIMEOUT_MS`(默认 `320000` ms),其余路由不受影响。 - **随机分摊**:起点 origin 按 `Math.random` 随机化,实现双活;单 origin 配置时退化为确定性。 diff --git a/docs/design.md b/docs/design.md index 078baf2..1a990a3 100644 --- a/docs/design.md +++ b/docs/design.md @@ -436,6 +436,10 @@ message-weave/ **验证**:静态检查代码不出现本地路径常量 / `std::fs` 非测试调用 / 文件日志后端;运行期通过容器 `/proc/mounts` 与 `docker inspect` 确认无本地卷挂载;部署检查清单见 deployment.md §8.2。 +### 7.6 远程联调面为何默认绝对关闭 + +该面存在的唯一理由是缩短生产排障路径:在无法登录容器、只能靠外部请求观察系统时,需要有人能在不发版的前提下探到 JMAP/Telegram 连通性与当前业务配置。代价是它的入口软度必然高于三条写路径——除只读探测外还保留一次**真实出站发送**,且复用业务白名单而非独立白名单。因此设计选择是「默认关闭」而不是「默认开启、靠网关挡」:进程同时带 `--debug` 且设置了非空 `DEBUG_TOKEN` 时才会挂载这组路由(`SAF-DEBUG-GATE`);缺一时路由在路由器里**根本不存在**,请求落到 axum 通用 `404`,而非「存在但 401」——后者会泄漏路由存在性。也不存在第三种状态:没有「未配置即开放」的回退,也没有任何配置项能把它设为默认开启。再叠一层位置约束:它不在网关的安全路由白名单内,即便后端开错,经平台入口也会被 fail-closed 拒掉,唯一可达路径是直连后端 origin。这四处——双因子挂载、404 而非 401、无默认开启回退、网关不可达——共同构成「暴露面默认为零」的架构决策。启用方式、逐端点状态码与部署确认清单见 deployment.md §2.1。 + --- ## 8. 错误处理与可观测性 @@ -705,7 +709,7 @@ src/ai.rs # LlmClient(唯一实现):summarize() 打 OpenAI 兼容 /chat/ | 变量 | 类型 | 默认 | 用途 | |---|---|---|---| | `LLM_ENABLED` | bool | `true` | 总开关 | -| `LLM_ALLOW_NET` | bool | `true` | 运行时出站许可;`LLM_ENABLED && LLM_ALLOW_NET` 同时为真才构造 `LlmClient`,否则用 `LlmClient::noop()`(`src/main.rs:109`) | +| `LLM_ALLOW_NET` | bool | `true` | 运行时出站许可;`LLM_ENABLED && LLM_ALLOW_NET` 同时为真才构造 `LlmClient`,否则用 `LlmClient::noop()`(`src/main.rs:128`) | | `LLM_API_KEY` | string | 必填 | Bearer token;缺失即视为未启用 | | `LLM_BASE_URL` | URL | 必填 | 必须 `https`,否则 `AiError::InvalidEndpoint` | | `LLM_MODEL` | string | 必填 | 透传给 `/chat/completions` 的 `model` | diff --git a/docs/reference.md b/docs/reference.md index d3e33d8..f9bbae5 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -3,7 +3,7 @@ > **This file is the single source of truth for verifiable facts.** > When any other document disagrees with this one, this one wins. > -> **Verified against commit `c5bc7b8`.** Line numbers in this file were read from that +> **Verified against commit `afa6cec`.** Line numbers in this file were read from that > commit with the working tree clean. > > **Maintenance responsibility.** Any change to the public API of `src/config.rs`, @@ -71,11 +71,11 @@ Consent never auto-renews. | Key | TTL | Writer | Reader | |---|---|---|---| -| `stalwart:jmap`, `stalwart:telegram` | Stream, no EX | `State::enqueue` (state.rs:241, `XADD` at state.rs:243); call sites notify.rs:123 (TG webhook) and notify.rs:772 (JMAP push); reconcile path via `claim_dedup_and_enqueue` (worker.rs:373) | `read_batch` over consumer group `stalwart-workers`, consumer `http-worker` (notify.rs:265-266, :280) | -| `delivery:inflight:{stream}:{id}` | EX 60 | key built at notify.rs:315, `claim_dedup` at notify.rs:316 | `release_dedup` on completion (notify.rs:329) | -| `delivery:committed:{stream}:{id}` | EX 604_800 | key built at notify.rs:294, `claim_dedup` at notify.rs:328 | `dedup_exists` before send (notify.rs:295) | +| `stalwart:jmap`, `stalwart:telegram` | Stream, no EX | `State::enqueue` (state.rs:241, `XADD` at state.rs:243); call sites notify.rs:203 (TG webhook) and notify.rs:859 (JMAP push); reconcile path via `claim_dedup_and_enqueue` (worker.rs:373) | `read_batch` over consumer group `stalwart-workers`, consumer `http-worker` (notify.rs:360, :428) | +| `delivery:inflight:{stream}:{id}` | EX 60 | key built at notify.rs:395, `claim_dedup` at notify.rs:396 | `release_dedup` on completion (notify.rs:409, 416, 425) | +| `delivery:committed:{stream}:{id}` | EX 604_800 | key built at notify.rs:374, `claim_dedup` at notify.rs:408 | `dedup_exists` before send (notify.rs:375) | | `retry:{stream}:{id}` | EX 86_400 | `retry_or_dlq` Lua script (key state.rs:434, `INCR` state.rs:438, `EXPIRE` state.rs:439) | reclaim path | -| `stalwart:jmap:dlq`, `stalwart:telegram:dlq` | Stream, no EX | Lua `XADD` at state.rs:441; name built at notify.rs:348, `max_attempts` 3 at notify.rs:349 | **nothing in code reads it** | +| `stalwart:jmap:dlq`, `stalwart:telegram:dlq` | Stream, no EX | Lua `XADD` at state.rs:441; name built at notify.rs:428 (`max_attempts` 3, same call) | **nothing in code reads it** | There is no `delivery:pending:{stream}` key — "pending" refers to the Redis Streams pending-entries list (PEL), which Redis maintains internally. @@ -96,9 +96,9 @@ back — replay is an operator action, not a service feature. | Key | TTL | Writer | Reader | |---|---|---|---| -| `dedup:tg:{update_id}` | EX 86_400 | key at notify.rs:118, `claim_dedup` at notify.rs:119 | — | -| `dedup:jmap:{account}:{email}` | EX 86_400 | key at notify.rs:767, `claim_dedup` at notify.rs:768; same key rebuilt at worker.rs:360 and claimed with `claim_dedup_and_enqueue` at worker.rs:373 | — | -| `ratelimit:push-verify:{subscription}` | EX 30 | key at notify.rs:728, `claim_dedup` at notify.rs:729 | — | +| `dedup:tg:{update_id}` | EX 86_400 | key at notify.rs:198, `claim_dedup` at notify.rs:199, `release_dedup` at :208 | — | +| `dedup:jmap:{account}:{email}` | EX 86_400 | key at notify.rs:854, `claim_dedup` at notify.rs:855, `release_dedup` at :864; same key rebuilt at worker.rs:360 and claimed with `claim_dedup_and_enqueue` at worker.rs:373 | — | +| `ratelimit:push-verify:{subscription}` | EX 30 | key at notify.rs:815, `claim_dedup` at notify.rs:816 (`push_verify_rate_limited` at :819) | — | | `state:jmap:since` | **No EX** | `set_reconcile_state` (state.rs:629; key at state.rs:632, `SET` without `EX`) | `get_reconcile_state` (state.rs:621) | `state:jmap:since` is the only state key that survives indefinitely without an explicit @@ -109,12 +109,12 @@ re-baseline on the next restart. | Key | TTL | Writer | Reader | |---|---|---|---| -| `lock:reconcile` | 300 | `acquire_lock` (notify.rs:159) | `renew_lock` heartbeat 90 (notify.rs:172) | -| `lock:push-register:{sha256(callback_url)}` | 360 | `acquire_lock` (notify.rs:819) | `release_lock` (notify.rs:835, 907) | +| `lock:reconcile` | 300 | `acquire_lock` (notify.rs:239) | `renew_lock` heartbeat 90 (notify.rs:252) | +| `lock:push-register:{sha256(callback_url)}` | 360 | `acquire_lock` (notify.rs:906) | `release_lock` (notify.rs:922, 937, 950, 967, 984, 994) | **Why the registration lock is 360 s and not 300 s.** The lock must outlive the longest configured JMAP request timeout (300 s), otherwise a slow registration could admit a -duplicate. Source comment, notify.rs:816: +duplicate. Source comment, notify.rs:904-905: > The lock must outlive the configured 300s maximum JMAP request timeout; this prevents a > slow create from admitting a duplicate. @@ -123,10 +123,10 @@ duplicate. Source comment, notify.rs:816: | Key | TTL | Writer | Reader | |---|---|---|---| -| `push:subscription:{id}` | EX 300 (caller-supplied, min 1) | `remember_push_subscription` (state.rs:647, called at notify.rs:746) | `push_subscription_verified` (state.rs:663) | +| `push:subscription:{id}` | EX 300 (caller-supplied, min 1) | `remember_push_subscription` (state.rs:639, called at notify.rs:833) | `push_subscription_verified` (state.rs:663) | | `push:subscription:id` | No EX | `remember_push_subscription_id` (state.rs:672) | current-subscription lookup | -| `push:subscription:{id}:status` | 900 / 300 / 86_400 (min 1 enforced) | `set_push_subscription_status` (state.rs:679, key at state.rs:687); pending at notify.rs:873, verified at notify.rs:758, disabled at notify.rs:944 | **none** | -| `push:registration:{sha256(callback_url)}` | EX 604_800 | `remember_push_subscription_for_callback` (state.rs:732, key at state.rs:739, `EX` at state.rs:742; called at notify.rs:890) | `get_push_subscription_for_callback` (state.rs:721); removed by `remove_push_subscription_for_callback` (state.rs:748) | +| `push:subscription:{id}:status` | 900 / 300 / 86_400 (min 1 enforced) | `set_push_subscription_status` (state.rs:679, key at state.rs:687); pending at notify.rs:960, verified at notify.rs:845, disabled at notify.rs:1031 | **none** | +| `push:registration:{sha256(callback_url)}` | EX 604_800 | `remember_push_subscription_for_callback` (state.rs:732, key at state.rs:739, `EX` at state.rs:742; called at notify.rs:977) | `get_push_subscription_for_callback` (state.rs:721); removed by `remove_push_subscription_for_callback` (state.rs:748) | | `push:orphan:{subscription_id}` | EX 604_800 | `record_push_orphan` (state.rs:712) | orphan sweep | **Two distinct keys that are frequently confused. Read this before editing either.** @@ -171,13 +171,13 @@ Every error response uses one envelope shape: `Retry-After` has two emission sites, both in `notify.rs`, and the value is always the literal string `"30"`: -- `error_response` (`notify.rs:316-324`, inserted at `:320`) sets it **only when its `retry` - flag is true**. The readiness failure path (`notify.rs:157`) passes `retry = true`, so `/ready` +- `error_response` (`notify.rs:320-328`, inserted at `:324`) sets it **only when its `retry` + flag is true**. The readiness failure path (`notify.rs:161`) passes `retry = true`, so `/ready` is covered by that rule rather than by a special case. -- `error_response_with_id` (`notify.rs:1076-1084`, inserted at `:1082`) sets it +- `error_response_with_id` (`notify.rs:1087-1093`, inserted at `:1090`) sets it **unconditionally**. It is used only for the push register/disable failures - `push_state_unavailable` and `push_destroy_failed` (`notify.rs:941`, `:958`, `:975`, - `:1012`), which are always `503`. + `push_state_unavailable` and `push_destroy_failed` (`notify.rs:952`, `:969`, `:986`, + `:1023`), which are always `503`. `GET /api/status` is the exception that reports setup state with a non-envelope body. @@ -185,7 +185,7 @@ string `"30"`: ## 3. Backend routes -All registered in `router_with_worker_state_runtime_bootstrap_config` (notify.rs:1250-1277). +All registered in `router_with_worker_state_runtime_bootstrap_config` (notify.rs:1275), with the routes wired at `notify.rs:1304-1318`. | Method | Path | Handler | |---|---|---| @@ -206,6 +206,23 @@ All registered in `router_with_worker_state_runtime_bootstrap_config` (notify.rs | GET | `/api/status` | `setup_status` | | GET | `/`, `/assets/config.js`, `/assets/styles.css` | `index` / `web_config` | +The remote-debug surface (`src/debug.rs:72-80`) is merged **only when the process is started +with `--debug` and `DEBUG_TOKEN` is non-empty** (`SAF-DEBUG-GATE`, `src/main.rs:99-103`, +`src/notify.rs:1320-1321`); otherwise none of these routes exist and requests fall through to +axum's generic `404`, not a 401. All seven require `Authorization: Bearer `, +checked by `debug_authorized` (`src/debug.rs:45`), which delegates to the production +`worker_authorized` so the comparison is constant time (`src/notify.rs:1135`). + +| Method | Path | Handler | +|---|---|---| +| GET | `/debug/ping` | `debug_ping` | +| GET | `/debug/config` | `debug_config` | +| GET | `/debug/redis` | `debug_redis` | +| GET | `/debug/jmap` | `debug_jmap` | +| GET | `/debug/telegram` | `debug_telegram` | +| GET | `/debug/worker` | `debug_worker` | +| POST | `/debug/notify` | `debug_notify` | + Response conventions: - `GET /healthz` returns **200 unconditionally**. It is a liveness probe and must not be @@ -215,7 +232,7 @@ Response conventions: completeness (`setup_missing` empty), Redis reachability, and two upstream probes — `GET {jmap_origin}/.well-known/jmap` with the configured Basic credentials, and `GET https://api.telegram.org/bot/getMe`. Both probes share `PROBE_TIMEOUT` = 3000ms - (`notify.rs:69`) and run **in parallel** (`tokio::join!`, `notify.rs:143`), so the worst case + (`notify.rs:73`) and run **in parallel** (`tokio::join!`, `notify.rs:147`), so the worst case is a single timeout, about 3s. On success the body is a report whose `jmap`/`telegram` fields are real probe results. The probes are plain reusable functions (`probe_jmap_session`, `probe_telegram_get_me`) also used by the remote-debug path, so they must not be duplicated. @@ -223,6 +240,13 @@ Response conventions: session URL unauthenticated would report not-ready forever and make ingress stop routing. - `GET /api/status` returns **503** with body `{"status":"configuration-setup","missing":[...]}` when a required environment variable is absent. +- `/debug/*` returns **503** in exactly one place: `POST /debug/notify` when the business + config is not loaded or there is no outbound client — `service_unavailable` with + `Retry-After: 30` (`src/debug.rs:56-58`). The three probe endpoints instead report failure + **inside a 200 body** (`{"ok":false,"detail":...}`), so an unreachable upstream never looks + like an outage. `POST /debug/notify` also returns `403 chat_not_allowed` when the chat is + outside a *non-empty* allowlist (`src/debug.rs:232-235`) and `502 telegram_send_failed` on + a send failure (`src/debug.rs:64-66`). - Static assets are served with a strict CSP; see §7. --- @@ -230,15 +254,15 @@ Response conventions: ## 4. Gateway vs backend route matrix > **Independently verified.** Source: `cloudflare-worker/src/backends.js` `SAFE_ROUTES` -> (backends.js:9-24), 14 entries, alongside `ROUTE_METHODS` (index.js:40-55) +> (backends.js:9-24), 14 entries, alongside `ROUTE_METHODS` (index.js:45-60) > which fixes one method set per path. The worker entry point is `src/index.js` > (wrangler.toml:20); `src/lb.js` performs forwarding and bounded failover (`SAF-LB-PASSTHRU`, > `C-NO-LONG-CONN`). The gate is **unconditional and fail-closed**. The worker reads no configuration switches at -all: a path missing from `SAFE_ROUTES` returns **404** (index.js:72-73), a registered -path with the wrong method returns **405** (index.js:76-77), and a missing or unparseable -backend pool returns **503** rather than passing the request through (index.js:84-87). +all: a path missing from `SAFE_ROUTES` returns **404** (index.js:77-78), a registered +path with the wrong method returns **405** (index.js:80-83), and a missing or unparseable +backend pool returns **503** rather than passing the request through (index.js:85-93). **Forwarded by the worker (14):** @@ -247,25 +271,26 @@ backend pool returns **503** rather than passing the request through (index.js:8 `/webhook/tg` · `/push/jmap` · `/api/push/register` · `/api/push/disable` · `/reconcile` · `/ready` -**Registered on the backend but NOT forwarded (4):** +**Registered on the backend but NOT forwarded (5):** | Path | Why it is absent from the gateway | |---|---| | `GET, PUT /api/enabled` | Internal operational switch, not exposed through the public gateway | | `POST /api/bootstrap` | One-shot trust bootstrap; kept off the public path | -| `POST /worker` | Operator-invoked worker trigger (Bearer-auth'd, `notify.rs:326`); not part of the public gateway path | +| `POST /worker` | Operator-invoked worker trigger (Bearer-auth'd, `notify.rs:330`); not part of the public gateway path | | `GET /healthz` | Liveness is aggregated by the gateway itself | +| `/debug/*` (7 routes) | Opt-in remote-debug surface (`SAF-DEBUG-GATE`); absent from `SAFE_ROUTES`, so it is reachable **only** by talking to the backend origin directly | -Consequence: none of the four carries external business traffic, so no second ingress is +Consequence: none of the five carries external business traffic, so no second ingress is needed in front of the backend instances. The SPA's first-boot flow still cannot drive `/api/bootstrap` through the worker — bootstrap must be performed against the backend origin directly, or the bootstrap path must be added to the gateway allowlist. `POST /api/push/register` and `POST /api/push/disable` **are** forwarded. Both are safe to -proxy: the callback URL is supplied by the client in the request body (`notify.rs:851`, -validated as a URL at :868), and every push subscription record is written to and read back +proxy: the callback URL is supplied by the client in the request body (`notify.rs:874`, +validated as a URL at :891), and every push subscription record is written to and read back from the shared Redis (`lock:push-register:{sha256(url)}`, `get_push_subscription_for_callback` -at `notify.rs:894`), so it does not matter which backend instance the worker picks. Push +at `notify.rs:917`), so it does not matter which backend instance the worker picks. Push registration therefore no longer requires hitting a specific backend address — see deployment.md §10.5. @@ -279,18 +304,18 @@ Three distinct layers. They are not interchangeable. | Variable | Required | Default | Source | |---|---|---|---| -| `REDIS_URL` | yes | — | read at main.rs:39 | -| `CONFIG_ENCRYPTION_KEY` | yes | — | `encryption_key_from_env` at main.rs:46 | -| `PORT` | no | `8080` | `unwrap_or(8080_u16)` at main.rs:33 | +| `REDIS_URL` | yes | — | read at main.rs:40 | +| `CONFIG_ENCRYPTION_KEY` | yes | — | `encryption_key_from_env` at main.rs:50 | +| `PORT` | no | `8080` | `unwrap_or(8080_u16)` at main.rs:34 | | `RUN_MODE` | no | `webhook` | `unwrap_or_else` at config.rs:338 | `RUN_MODE` accepts only `webhook` or `reconcile`; anything else is rejected at boot -(main.rs:71-78). Both modes currently route to the same no-op router; the distinction is +(main.rs:78-81). Both modes currently route to the same no-op router; the distinction is load-bearing for future splits, not for behaviour today. **Missing a required variable does not crash the process.** It logs a warning and serves `router_configuration_setup`, which builds a router over `MemoryState` with empty tokens and -a `NoopWorker` (notify.rs:1097-1109). The container stays up and answers requests while doing +a `NoopWorker` (worker.rs:142-144). The container stays up and answers requests while doing no business work. This is a deliberate fail-closed-to-setup posture, and it is the single most likely cause of "the container is healthy but nothing happens". @@ -346,7 +371,7 @@ There is **no** `LLM_MAX_RETRIES` environment variable; retry count lives in | `MAX_BASELINE_EMAILS` | 10_000 | worker.rs:249 | | `RECONCILE_BUDGET` | 20 s | worker.rs:250 | | `CHANGE_WINDOW_CAP` | 4_096 | worker.rs:306 | -| Lock TTL / heartbeat | 300 s / 90 s | notify.rs:159 / 172 | +| Lock TTL / heartbeat | 300 s / 90 s | notify.rs:239 / 252 | `max_changes` is a function parameter, not a constant. diff --git a/docs/retired.md b/docs/retired.md index 19e02ec..3899c85 100644 --- a/docs/retired.md +++ b/docs/retired.md @@ -4,7 +4,7 @@ > 目的是**留原因、防复发**——以后看到这些名字,能立刻知道该用什么。 > 当前真实实现与可核对事实见 `docs/reference.md`;缺口与阻塞见 `docs/roadmap.md`;当前设计见 `docs/design.md`。 > 本文**没有行动项**。若某条变成要做的事,去 `docs/roadmap.md` 登记,本文条目保留为决策依据。 -> 验证基线:`c5bc7b8`。 +> 验证基线:`afa6cec`。 --- @@ -74,10 +74,10 @@ |---|---|---|---| | `channel/telegram/{mod,commands,session,render}.rs` | 虚构文件 | 目标目录树里画出,从未创建 | 命令解析在 `parse_intent`(`src/worker.rs`);会话/授权态走 Redis TTL(`docs/reference.md` 的 AI 授权态一节);渲染函数在 `src/channel.rs` 内 | | `delivery:pending:{stream}` | 虚构键 | 与 Redis Streams 的 pending-entries list(PEL)混淆——PEL 由 Redis 内部维护,不是可写键 | 真实键:`delivery:inflight:{stream}:{id}`(EX 60)与 `delivery:committed:{stream}:{id}`(EX 604_800),见 `docs/reference.md` 的投递流水线键一节 | -| `check_config_reload` | 虚构函数 | 未提交草稿中的名字,从未进入代码 | 热更新为 `refresh_business_config`(`src/notify.rs:704`) | -| `push:registration:{sha256(callback_url)}` 曾被写成「360s 注册单飞锁」 | 事实误标 | 该键是 7d 回调→订阅 ID 映射 | 真正的 360s 单飞锁是 `lock:push-register:{sha256(callback_url)}`(`src/notify.rs:887-890`) | +| `check_config_reload` | 虚构函数 | 未提交草稿中的名字,从未进入代码 | 热更新为 `refresh_business_config`(`src/notify.rs:717`) | +| `push:registration:{sha256(callback_url)}` 曾被写成「360s 注册单飞锁」 | 事实误标 | 该键是 7d 回调→订阅 ID 映射 | 真正的 360s 单飞锁是 `lock:push-register:{sha256(callback_url)}`(`src/notify.rs:897-900`) | | `MESSAGWEAVE_DOMAIN` 曾被写成「生产未配置时 Worker 白名单失效」 | 虚构断言(未提交草稿/讨论中出现,未进文档) | 该环境变量全仓零命中;Worker 白名单是**无条件 fail-closed**:未知路径 404、method 不符 405、后端缺失或解析失败 503(`cloudflare-worker/src/index.js:77-92`) | 无需配置开关,白名单恒生效 | -| `read_batch` / `retry_or_dlq`「Redis 出错时仍可能返回 `Ok(())`,消费循环因此不会因单次失败退出」 | 虚构断言 | 消费入口是 HTTP handler `worker`(`notify.rs:326`),**不是后台循环**:全仓 `src/` 零命中 `select!`,无信号处理、无常驻 worker 进程。`read_batch`(`state.rs:343`)经 `?` 把 Redis 错误原样上抛(:379),唯一被丢弃的结果是 XGROUP `CREATE` 的 `BUSYGROUP` 幂等保护(:376);`retry_or_dlq` 同样经 `?` 上抛。调用方对每一处 `Err` 都返回 `503 service_unavailable` + `retryable=true`,故「循环不因单次失败退出」这一语义前提本身不成立 | 无——不存在该缺口;`docs/roadmap.md`「代码缺口」4 → 3,`docs/design.md`「已知边界」同句已删 | +| `read_batch` / `retry_or_dlq`「Redis 出错时仍可能返回 `Ok(())`,消费循环因此不会因单次失败退出」 | 虚构断言 | 消费入口是 HTTP handler `worker`(`notify.rs:330`),**不是后台循环**:全仓 `src/` 零命中 `select!`,无信号处理、无常驻 worker 进程。`read_batch`(`state.rs:343`)经 `?` 把 Redis 错误原样上抛(:379),唯一被丢弃的结果是 XGROUP `CREATE` 的 `BUSYGROUP` 幂等保护(:376);`retry_or_dlq` 同样经 `?` 上抛。调用方对每一处 `Err` 都返回 `503 service_unavailable` + `retryable=true`,故「循环不因单次失败退出」这一语义前提本身不成立 | 无——不存在该缺口;`docs/roadmap.md`「代码缺口」4 → 3,`docs/design.md`「已知边界」同句已删 | --- @@ -86,7 +86,7 @@ | `channel/mod.rs` | 虚构文件 | 渠道层就是 `src/channel.rs`,不是 `channel/` 目录 | 无 | | `notify::push_handler` / `notify::worker` / `notify::reconcile` 作为模块路径 | 虚构模块路径 | 这些是 `src/notify.rs` 内的自由函数(`jmap_push` / `worker` / `reconcile`),不是模块路径 | 无 | | `mod_dedup` / `mod_streams` / `mod_sincestate` | 虚构模块名 | `state.rs` / `notify.rs` 都是平铺文件,无子模块;去重与 Streams 逻辑以自由函数存在 | 无 | -| `PushVerification` 类型 | 虚构类型 | 未定义;`register_push`(`notify.rs:873`)内联处理回调 URL 与验证码回写 | 无 | +| `PushVerification` 类型 | 虚构类型 | 未定义;`register_push`(`notify.rs:884`)内联处理回调 URL 与验证码回写 | 无 | | `CancellationToken` | 虚构类型 | 未使用;无优雅关闭、无信号处理(`src/` 零命中 `tokio::signal` / `ctrl_c`) | 无 | | `Preview` 类型 / 4000 字符长邮件保护 / `[继续查看原文]` 按钮 / `/llm-fallback` 按钮 | 虚构类型与 UI | 全部未实现;授权后把全文交给 LLM,失败即回退前 300 字符,无任何截断标注或按钮 | 无 | | `LlmErr`(5 变体) | 虚构枚举 | 真实是 `AiError`,仅 3 个变体(`InvalidEndpoint` / `Request` / `Response`) | 无 | diff --git a/docs/roadmap.md b/docs/roadmap.md index 616e840..e2e1154 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -2,7 +2,7 @@ > 只放**缺口**、**阻塞**与**阶段目标**。已实现能力见 `README.md`、`docs/design.md`、`docs/deployment.md`; > 可核对的事实(Redis 键、TTL、错误码、路由)见 `docs/reference.md`。 -> 验证基线:`c5bc7b8`。门禁命令见 `docs/deployment.md` 的 Gate 一节。 +> 验证基线:`afa6cec`。门禁命令见 `docs/deployment.md` 的 Gate 一节。 ## 阶段目标 @@ -12,7 +12,7 @@ ## 阻塞(需要真实环境,当前无法验证) - **真实 Stalwart 邮箱联调** — 未做任何真实邮箱端到端集成。JMAP adapter 代码与配置校验已完成并通过本地 `cargo test`,但 `Email/changes`、`PushSubscription` 路径未在真实账号上验证。`REQ-JMAP-SESSION-URL` 已实现(`D-G1-1`)但待真机验证。**阻塞原因**:无可用 Stalwart 账号/凭据;禁止使用测试账号或伪造结果。 -- **真实 Telegram Bot 联调** — 入站 `POST /webhook/tg` 的 secret 头校验 + chat 白名单(`notify.rs:95` 起)、出站 `sendMessage`(`channel.rs`)均已在代码内并有单元测试,但**都没有在真实 Bot 上跑过**。另:向 Telegram 注册 webhook URL 是运维步骤(调 `setWebhook`),应用内不实现;长轮询 `getUpdates` 从未实现(见 `docs/retired.md`)。**阻塞原因**:无 Bot Token;禁止向真实用户发消息。 +- **真实 Telegram Bot 联调** — 入站 `POST /webhook/tg` 的 secret 头校验 + chat 白名单(`notify.rs:175` 起)、出站 `sendMessage`(`channel.rs`)均已在代码内并有单元测试,但**都没有在真实 Bot 上跑过**。另:向 Telegram 注册 webhook URL 是运维步骤(调 `setWebhook`),应用内不实现;长轮询 `getUpdates` 从未实现(见 `docs/retired.md`)。**阻塞原因**:无 Bot Token;禁止向真实用户发消息。 - **真实 Redis TLS 连接** — `rediss://` 握手、密码特殊字符 URL 编码、`CONFIG_ENCRYPTION_KEY` 热更新未实测。**阻塞原因**:无托管 Redis 实例。 - **`Email/changes` 的 `newState` 语义** — 客户端已用「同 `sinceState` 翻倍 `maxChanges` 扩窗」消除按页漏批,但 `newState` 是否表示"全部待报变更之后"仍需真实 Stalwart 复验,否则停机积压边界无法判定。**阻塞原因**:无可用 Stalwart 账号/凭据。 - **Push callback 公网映射** — 单飞锁 `lock:push-register:{sha256(callback_url)}`(360s)与映射 `push:registration:{sha256(callback_url)}`(7d)的 TTL 需真实回调时序验证。**阻塞原因**:无真实推送回调可观察。 @@ -22,6 +22,7 @@ - **`/search` + `SearchSnippet/get` 高亮** — 未实现。`src/` 中 `SearchSnippet` / `search_snippet` 零命中,worker 的 `Intent` 枚举也无 `Search` 变体,因此该路径无法进入。设计文档中的接口表已标注「(未实现)」。 - **Telegram 429 退避实测** — `src/channel.rs` 的 `max_retries`(默认 3,硬上限 5)不针对 Telegram 服务端 30 msg/s 限流做专门退避;429 行为无实测数据。需真实 Bot 压测后再决定是否引入指数退避。 - **多实例重复投递窗口(残余)** — XAUTOCLAIM 空闲阈值已按批大小缩放(批大小 × 单条上限 300s);仅当单条事件处理耗时接近 300s 上限时,多实例部署下另一实例仍可能提前认领(仅重复不丢)。单实例不受影响。 +- **`/debug/notify` 空白名单不拦截**(`SAF-DEBUG-ALLOWLIST`)— 白名单校验只在白名单**非空**时才生效(`src/debug.rs:232-235`),因此业务白名单未配置时 `chat_id` 可为任意值。**已定位、已文档化、本轮不改代码**:该面默认关闭且需 Bearer,且业务白名单为空时主产品本身即接受任意 chat,沿用同一语义不构成额外泄露面。若日后要收紧,需把语义改为「空白名单则拒绝」,并补单元测试、跑 `GATE-P0`。 ## 验收待办