MessageWeave/docs/deployment.md

38 KiB
Raw Blame History

MessageWeave 部署方案(通用 HTTPS-only Docker)

部署目标:通用 Docker 容器平台(任何能跑 Docker 的 VPS / 云主机 / k8s / 自托管容器平台), 不绑定 Cloud Run / Lambda / CF Workers / Deno Deploy 等任何具体平台(NG-SERVERLESS-BIND)。

保持 Docker health / PORT 等通用 HTTP 约定(C-PORT)。 部署形态 = 短请求模型:Telegram Webhook + Stalwart JMAP Push HTTPS 回调 + 外部 HTTPS Cron 对账。 硬约束见 §0;非目标见 §1; 架构 / 产品行为以 design.md 为准,本文只写部署/运维。


0. 硬约束

下述约束均为不可违反项(对应 AGENTS.md 硬边界)。跨文档引用一律用稳定 ID,索引见 AGENTS.md「跨文档引用索引表」。

ID 约束
C-DOCKER 必须是 Docker 容器;禁止非 Docker 直装
C-DEBIAN-SLIM Debian 系 slim,禁止 Alpine/musl
C-NO-SECRET-IN-IMAGE secrets 不进镜像(构建 ARG/ENV 不得含 token/密码)
C-RUSTLS rustls + rustls-native-roots,不依赖系统 OpenSSL
C-HTTPS-INBOUND 只提供 HTTPS 入站:容器内仅监听明文 HTTP,TLS 由平台入口/反向代理统一终止;无代理场景才允许容器内自服务 TLS
C-HTTPS-URL 公网 HTTPS 入口由平台提供:平台/反代给 bot 一个公网 HTTPS URL(如 https://bot.example.com),Telegram Webhook 与 Stalwart Push 回调都指向该 URL 的路径;bot 自身不申请证书、不监听 443
C-NO-TCP-EXPOSE 不依赖 TCP 端口暴露:全容器单监听端口 PORT(默认 8080);对外只走 HTTP 路由(webhook / push / reconcile / health);不暴露任何附加 TCP 端口(如 admin 端口)
C-NO-LONG-CONN 不使用任何长连接:无 JMAP EventSource/SSE、无 WebSocket、无 Telegram 长轮询。实时性仅由 Push HTTPS 回调 + 外部 HTTPS Cron 对账 保证
C-REDIS-ONLY-STATE 无状态形态只依赖外部 Redis:会话、去重、Redis Streams、熔断计数、sinceState 游标 全部走 Redis;不用 SQLite、不用本地卷(NG-SQLITE-PERSIST / NG-LOCAL-VOLUME)
C-REDIS-MANAGED-AOF Redis 由用户托管(自建或托管服务)并开启 AOF 持久化:Bot 不自建、不管理 Redis 进程;AOF 保证 Streams 队列 / 去重表 / sinceState 重启不丢
C-NO-DB 生产不使用任何数据库:无 SQLite/Postgres/MySQL/嵌入式数据库;Redis 是唯一的状态存储(C-REDIS-ONLY-STATE)。应用不得自带、不自建、不连接第二个数据库实例
C-NO-LOCAL-WRITE 禁止本地文件/目录写入:无日志文件、无数据文件、无临时缓存、不挂载本地卷(NG-LOCAL-VOLUME/NG-SQLITE-PERSIST)
C-LOG-STDOUT-ONLY 日志只写 stdout/stderr(容器平台/运行时负责采集落盘);禁用 rolling-file、FileAppender 等文件日志后端;日志中不得出现密钥、邮件正文、AI 请求/响应内容(SAF-LOG-PURITY)
SAF-LOG-PURITY 日志/Redis 写入内容仅限:结构化事件 ID、状态机转移、计数、时间戳、脱敏后的请求摘要(request id、状态码、耗时);禁止 password/token/secret/Authorization 原文、JMAP 正文、LLM prompt/completion、附件内容
C-NO-STATEFUL-RECOVERY 禁止依赖进程内状态做生产恢复:任何"重启续跑"(去重、sinceState、Streams 断点、熔断计数、会话)一律由外部 Redis + JMAP 对账(FLOW-RECONCILE/C-REDIS-ONLY-STATE)实现;进程内缓存仅为性能优化,丢失必须安全可重入

1. 非目标(Non-Goals)

以下均为历史早期参考,非目标、不再支持。不得在实现中复活为运行模式。

ID 非目标 说明
NG-SERVER-MODE RUN_MODE=server 常驻运行模式 早期 EventSource+长轮询 常驻设计,已删除
NG-POLLING-SSE JMAP EventSource/SSE 长连接订阅 与 C-NO-LONG-CONN 冲突;实时通道只用 Push 回调
NG-LONG-POLLING Telegram 长轮询运行模式 与 C-NO-LONG-CONN 冲突;只用 Webhook
NG-SQLITE-PERSIST SQLite 会话 / sinceState 本地持久化 状态只走外部 Redis(C-REDIS-ONLY-STATE)
NG-LOCAL-VOLUME /app/data 等本地卷持久化 无本地持久层(C-REDIS-ONLY-STATE)
NG-SERVERLESS-BIND 绑定 Cloud Run / Lambda / CF Workers / Deno 等具体平台 部署目标是通用 Docker 容器平台;不绑定平台

2. Debian Slim 选型

(保留既有内容:debian:bookworm-slim 的运行基础、非 root 用户、时区、CA 证书等通用约定)


3. Rust 多阶段构建与运行

3.1 构建阶段

(保留既有 rust:bookworm 构建 + 缓存分层约定;产物为单个静态编译二进制 message-weave)

实现现状:单端口 axum 入口、jmap-client =0.4.2 只读 adapter、Redis Streams、Telegram Webhook、JMAP Push 入队和 /reconcile 已接入;三条入口路由鉴权 fail-closed 落地(R1/SAF-AUTH-*)。本地 Debian 容器门禁已通过;真实 Stalwart、Redis、Telegram 的端到端链路仍需部署后验证。配置引导模式会在启动变量缺失时保持 HTTP/SPA 可访问,不伪造业务成功。

3.2 运行阶段(收敛为单端口)

  • EXPOSE 8080(遵循 C-NO-TCP-EXPOSE,不再暴露 9191 admin 端口)
  • ENV PORT=8080、RUN_MODE=webhook(默认)
  • 健康检查走同一 HTTP 监听(见 §7),不依赖独立端口
  • 不声明 VOLUME,无 /app/data 本地卷(NG-LOCAL-VOLUME)

4. 运行时用户 / 证书 / TLS 与公网 HTTPS 入口

  • 非 root 用户运行(保留既有约定)
  • 公网 HTTPS 入口(C-HTTPS-URL 已确认):运维在平台上给 bot 配一个公网 HTTPS URL(如 https://bot.example.com),平台 ingress/反代把 https://…/webhook/tg、/push/jmap、/reconcile 路由到容器 PORT。bot 自身不申请证书、不监听 443;证书由平台/反代管理(C-HTTPS-INBOUND)。
  • 需要在 Stalwart 与 Telegram 两侧使用这个公网 URL:Telegram setWebhook 指向 /webhook/tg;管理员调用 POST /api/push/register 并提交该 URL 的 /push/jmap 路径,后端负责执行 PushSubscription/set create。服务不会自动猜测平台公网域名。
  • 多实例 LB/HA 时登记的是 Worker URL 而非各后端 URL(C-LB-SINGLE-REG-URL,§10):Telegram / Stalwart / Cron 只认 Worker 的稳定域名;后端平台入口不对外登记。
  • Secrets 运行期注入(环境变量/容器平台 secret),见 §5(同文件内章节链接)

5. Secret 管理与环境变量

5.1 HostStack 原生 Rust 部署

HostStack 使用仓库根目录 hoststack.yaml:Rust runtime 执行 cargo build --release --locked,服务命令为 ./target/release/message-weave,监听单个 HTTP 端口并以 /healthz 做 interval 30 秒、timeout 5 秒的健康检查。REDIS_URL 与 CONFIG_ENCRYPTION_KEY 必须配置为 HostStack Secret;不得将密钥值写入 YAML、镜像、日志 或代码仓库。现有 Dockerfile 部署方式仍受支持。

全局业务开关通过受保护的 GET|PUT /api/enabled 管理,持久化 Redis key 为 config:enabled,默认关闭且读取失败 fail-closed。关闭时 /webhook/tg、/push/jmap、 /reconcile、/worker 返回 503,不执行入队、ACK 或业务处理;SPA、/api/status、管理 会话和配置 API 保持可用,打开后立即生效。

迁移后生产环境仅需 REDIS_URL 与 CONFIG_ENCRYPTION_KEY;下表中的业务环境变量是历史阶段说明(走 Config::from_env() 环境变量引导路径时),不应再注入生产容器,表内 ✅ 也不代表当前生产必须配置。业务密钥通过受保护的 /api/bootstrap 或管理员 PUT 写入 Redis,并在成功后热重建客户端。

(保留既有 Secrets 注入约定:禁入镜像、secrecy 包裹、日志屏蔽;新增运行模式相关变量)

变量 必填 说明
BOT_TOKEN ✅ Telegram Bot Token
TELEGRAM_CHAT_ID ✅ Worker 元数据通知目标 chat id(与入站白名单分离)
JMAP_SESSION_URL ✅ Stalwart JMAP session URL(REQ-JMAP-SESSION-URL):填服务基地址(https://mail.example.com)或完整 …/.well-known/jmap 均可;代码归一化为 origin/base 后再交 jmap-client,不产生重复路径。仅 HTTPS;禁止 URL 内嵌凭据(SAF-JMAP-URL)
JMAP_USERNAME / JMAP_PASSWORD ✅ Stalwart 认证 = App Password + Basic(已确认 C-AUTH-APP-BASIC):账号填邮箱,密码填在 Stalwart 生成的应用专用密码(可独立吊销/设到期);不用主密码、不用 OAuth。通常内置 user 角色已包含 PushSubscription 的读取/创建/修改/删除权限,无需手工配置;若实际调用返回 forbidden,再到 /admin → Management → Directory → Accounts/Roles 检查
CHAT_ALLOWLIST ✅ 聊天白名单(硬约束 SAF-CHAT-ALLOWLIST):逗号分隔整数 chat id;处理任何事件前先校验,非白名单直接拒绝
REDIS_URL ✅ 外部 Redis(用户托管 + AOF)(C-REDIS-ONLY-STATE/C-REDIS-MANAGED-AOF):session / dedup / Streams / fuse / sinceState 全部在此;支持 redis:// 与带默认 ACL 用户的 rediss://default:<url-encoded-password>@host:6379/0,Redis 进程不在此 compose 内
PORT 默认 8080 单监听端口(C-NO-TCP-EXPOSE)
RUN_MODE 默认 webhook webhook / reconcile 二选一(NG-SERVER-MODE 已删除)
RECONCILE_TOKEN ✅ /reconcile 的 Authorization: Bearer <token> 承载令牌(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)
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/配置),不做多账户单实例

AI 授权期限由用户选择(临时一次、今天、7天或直到撤销),Redis 仅保存 chat id、授权状态和带 TTL 的到期时间;不会保存正文或摘要。到期后摘要请求回到元数据模式并提示重新授权。

业务鉴权变量由 Redis config:business 管理,不再要求作为启动环境变量。缺少 REDIS_URL 或非法/缺失 CONFIG_ENCRYPTION_KEY 时服务仍监听并提供 SPA、/api/status 与探针,状态为 configuration-setup,不会伪造持久化成功;配置恢复后再启用业务入口鉴权。 示例占位见仓库根目录 .env.example(仅占位符,严禁放入真实密钥)。

配置管理页面:服务根路径 / 提供嵌入 Rust 二进制的 SPA。输入 Redis URL 的 ACL 密码后,POST /api/admin/session 签发 900 秒 admin session;页面只在内存中保存 opaque session。运行参数通过 /api/config 读取和保存;完整业务配置通过 PUT /api/business-config 替换并热加载。业务配置 API 不提供 GET,密钥不会回显;每次完整替换都需重新输入必填密钥。配置保存在外部 Redis(C-REDIS-ONLY-STATE),静态资源编译时随二进制打包,无运行期本地文件。


6. 运行模式与短请求模型

6.0 Redis-only 配置迁移与 bootstrap 威胁模型(C-REDIS-ONLY-STATE)

生产进程接受 REDIS_URL 与唯一额外启动密钥 CONFIG_ENCRYPTION_KEY(32 字节随机 高熵 hex,仅应用运行时持有);其中 Redis ACL 认证是唯一 bootstrap 信任根;业务 密钥和运行参数不再从环境变量读取。空 Redis 仅提供配置页面及一次性的 bootstrap 会话:请求必须以 Redis ACL 密码作为 Bearer 凭据,服务端只做常数时间比较,绝不在 响应、日志或配置值中回显该密码。bootstrap 使用原子 SET NX 写入完整业务配置, 竞争请求只有一个成功;无密码 Redis 或认证失败时拒绝初始化。

该 ACL 身份必须具备 config:business、config:outbound 及 admin-session 键的读写权限, 因为它同时是应用 Redis 连接凭据与空库 bootstrap 根信任。bootstrap 完成后公网业务配置 接口只接受 900 秒 admin session;持有 ACL 密码者仍等同根信任,必须按生产 Redis 凭据 同等级保护,不得把该身份误配置为禁止配置键访问。

成功 bootstrap 后,后续配置读写只能使用 Redis 中保存的管理员会话(短 TTL,注销 或配置更新时失效);业务端点使用 Redis 配置中下发的独立令牌。管理员会话只保存 不可逆哈希和过期时间,重启后按 Redis TTL 恢复,进程不保存本地状态。GET 永不回显 任何密钥,配置缺失或业务依赖未就绪时保持 HTTP/SPA 可用但业务路由返回未就绪。

config:business 使用 AES-256-GCM(版本、随机 nonce、认证 tag 均在密文 envelope 中) 写入 Redis;应用启动时使用 CONFIG_ENCRYPTION_KEY 解密,密钥永不写 Redis、日志、响应 或浏览器存储。该保护只防 Redis 内容泄露,不替代 rediss、VPN 或受信网络对 redis:// 窃听/篡改的防护;公网 Redis 必须使用 TLS/VPN/隧道。

bootstrap 或管理员 PUT 修改 config:business 后,服务会先校验并构建全部 JMAP/Telegram/LLM 客户端,再原子替换 worker;构建失败保留旧配置与任务。后续请求即时使用新快照,多实例在 请求边界按 revision 有界检查并尝试刷新;失败时保留旧实例,避免忙循环。

TLS 必须由 Cloudflare 或受信任反向代理终结;代理到容器的链路只能位于受控私网, 并应校验可信转发头后才允许 bootstrap。容器端口不得直接暴露公网。SPA 仅在当前页面 内存保存 opaque admin session,不使用 localStorage、sessionStorage 或 Cookie 持久化。

仅两种运行模式;无长连接(C-NO-LONG-CONN)。

RUN_MODE 命令 说明
webhook(默认) message-weave webhook(或单一入口按 env 分派) 单进程:TG Webhook handler + JMAP Push 回调 handler + Redis Streams worker + /reconcile + health
reconcile message-weave reconcile 一次性对账补差后 exit,由外部调度器/容器任务触发

6.1 入口路由(单端口 PORT,全部经平台 HTTPS URL 入站)

# 平台公网 URL: https://bot.example.com  → 反代 → 容器 127.0.0.1:8080
GET  /                  业务配置与运行参数管理 SPA
GET  /assets/config.js  SPA 脚本;GET /assets/styles.css  SPA 样式
GET  /api/status              公开启动状态;只返回 ready/mode/missing 环境变量名
POST /api/admin/session       Bearer Redis ACL 密码;成功返回 900 秒 admin session
POST /api/admin/session/revoke Bearer admin session;成功返回 204
GET|PUT /api/config           Bearer admin session 或 WORKER_TOKEN;Redis 错误返回 503
PUT /api/business-config      Bearer admin session 或 WORKER_TOKEN;完整替换,成功返回 204
POST /webhook/tg      TG Webhook → [鉴权 SAF-AUTH-TG-WEBHOOK: 头 X-Telegram-Bot-Api-Secret-Token]
                      → 快速 2xx ACK → 幂等去重(MOD-DEDUP) → 命令处理 → 同步回复(快路径)
POST /push/jmap       Stalwart Push 回调 → [鉴权 SAF-AUTH-JMAP-PUSH: Body pushSubscriptionId + verificationCode]
                      → 幂等去重(MOD-DEDUP) → 入 Redis Streams(MOD-STREAMS) → 立即 2xx ACK(慢任务异步)
POST /reconcile      外部 HTTPS Cron 触发 → [鉴权 SAF-AUTH-RECONCILE: Authorization: Bearer RECONCILE_TOKEN]
                      → 对账补差 FLOW-RECONCILE
GET  /healthz        liveness(ARCH-HEALTHZ:进程存活;公开探针 SAF-PROBE-PUBLIC,无鉴权、无敏感信息)
GET  /ready          公开就绪探针;检查配置完整性与 Redis 可访问性,就绪返回 200+就绪报告 JSON,未就绪返回 503+标准错误 envelope(无鉴权、无敏感信息)

/reconcile 使用 Redis owner-token 单飞锁并在长任务期间续租;每次最多处理 100 页、10,000 封基线邮件或 20 秒。已完成入队的 changes 页会提交最新游标;冷启动基线超出预算时把基线状态与位置编码后持久化,下一次从断点继续,不从头扫描,也不会提前跳过未列举邮件。

写入口鉴权(fail-closed,SAF-AUTH-*):Webhook、Push、Reconcile 以及 Push 注册接口都必须先通过鉴权,失败返回 401 且不产生副作用;比较使用常数时间(subtle,防时序侧信道)。secret 未配置 → 启动失败,无"缺省放行"。 健康探针(SAF-PROBE-PUBLIC):/healthz、/ready 仅返回健康状态、不含敏感信息。/healthz 只表示进程存活;/ready 检查配置完整性与 Redis 可访问性,不发起 JMAP/Telegram 请求,也不触发邮件同步或其他副作用。 "公网 HTTPS 入口"是什么(C-HTTPS-URL):平台给 bot 一个公网 HTTPS 域名,外部(Telegram / Stalwart / 调度器)通过它访问上面这些路径;容器只处理明文 HTTP,TLS 由平台终止。运维只需在平台上配置域名/证书并确保 4 条路径可达。

6.2 Redis Streams / worker(MOD-STREAMS)

  • Push 回调只做:校验 → 去重 → 入队 → ACK;不阻塞
  • worker(同容器后台 task 或独立 worker 容器,2 选 1 均由 compose/编排决定): XREADGROUP → Email/changes → 推送 TG → 推进 sinceState → XACK
  • 消费组 at-least-once:未 ACK 消息自动重投;处理幂等(MOD-DEDUP 二次兜底)

6.3 外部 HTTPS Cron 对账(FLOW-RECONCILE)

  • "外部 Cron"是什么(已确认):bot 不自建定时器、不持有调度(C-NO-LONG-CONN,无 tokio-cron)。由外部调度器周期性发起 POST https://<平台URL>/reconcile(带 RECONCILE_TOKEN)。端点执行鉴权、全局开关检查与 Redis 单飞锁,调用 JMAP Email/changes 分页并将事件幂等入 Streams;全部入队成功后才持久化 state:jmap:since,依赖失败返回 503 供调度器重试。
  • 建议间隔 5–10 分钟(NFR-RECONCILE-INTERVAL):兼顾"少延迟"与"低开销";这是可用性的兜底频率。
  • 可用调度器(任选其一,均为外部):系统 crontab+curl / k8s CronJob / GitHub Actions scheduled / 第三方 cron 服务。
  • 对账逻辑:用 sinceState 调 Email/changes 拉增量 → 与已处理 email_id 求差 → 补发通知 → 推进 sinceState。
  • Redis 丢失恢复:sinceState 存 Redis(MOD-SINCESTATE,AOF 持久化 C-REDIS-MANAGED-AOF);即便 Redis 全丢,对账扫描 JMAP(Email/query 最近 N 封 + changes)也能重建游标并补发——事实源在 JMAP,Redis 只是加速层。

6.4 可靠性策略(Reliability)

目标:至少 99.9% 通知可用性(NFR-NOTIFY-SLA),允许少量延迟(不追求秒级保证)。下述机制共同保证"不丢、少重、可恢复"。

机制 ID 做法
Streams ACK / 重试 MOD-STREAMS worker 用消费组 XREADGROUP;处理成功才 XACK;未 ACK 消息在被认领后重投(at-least-once)。处理失败时不 ACK,自然重试;设最大投递次数上限,超限转死信(XADD 到 dlq)并告警,避免毒丸阻塞
幂等去重 MOD-DEDUP 以 (account, email_id) 为幂等键,SET NX(TTL 覆盖重投窗口)。重复投递直接跳过,保证 at-least-once 下不重复通知。TG 侧同理用 update_id
Push 重试 FLOW-NEW-MAIL /push/jmap 校验/入队后立即 2xx;若入队失败(Redis 抖动)返回非 2xx,让 Stalwart 按自身策略重试;配合对账兜底
对账恢复 FLOW-RECONCILE 外部 Cron 每 5–10 分钟调用 /reconcile;从 Redis 恢复 state:jmap:since,按 Email/changes 补差,入队成功后推进游标,失败返回 503
运行监控 / 告警 Uptime Kuma 使用 HTTP(s) Monitor 检查 /healthz(进程存活)和 /ready(配置/Redis 就绪),分别期望 200;对 503、超时、TLS/DNS/路由故障告警。应用不引入 Prometheus、Exporter 或额外指标端口。

6.5 99.9% 可用性目标与边界(NFR-NOTIFY-SLA)

  • 定义:在外部依赖(Stalwart / Telegram / Redis)可用的前提下,通知链路的可用性目标 ≥ 99.9%(约每月 ≤43 分钟不可用)。
  • 允许的延迟:用户已确认接受少量通知延迟——正常情况下 Push 回调应为秒级;依赖抖动/需对账兜底时,延迟上界为对账间隔(≤10 分钟)。
  • 非目标边界:99.9% 是通知可用性目标,不含 Stalwart/Telegram/Redis 自身故障时间;三者任一长时间不可用属外部依赖故障,不计入本 bot 的可用性预算(但 bot 应在恢复后经对账自动补齐)。
  • 降级行为:Redis 不可用时,去重/会话退化为进程内短窗口、Push 回调返回非 2xx 交 Stalwart 重试;恢复后由对账补齐。不因 Redis 抖动而漏发最终通知(以对账为准)。

7. 健康检查(通用 HTTP 约定)

  • GET /healthz → liveness(ARCH-HEALTHZ;进程存活,长期语义)
  • GET /ready → 基础就绪探针(ARCH-READY-BASELINE):检查配置完整性与 Redis 可访问性;就绪 200(就绪报告 JSON),不就绪 503(标准错误 envelope {"error":"service_unavailable","request_id":<id>} + Retry-After: 30);不代表 Redis/JMAP/TG 依赖已通过端到端检查。Uptime Kuma 按状态码(期望 200)监控,不受响应体变化影响。
  • 两者均为公开探针(SAF-PROBE-PUBLIC):无鉴权、仅返回健康状态、不含敏感信息。
  • /ready 已检查配置完整性与 Redis 可访问性,但不代表 JMAP/Telegram 已完成真实端到端验收;编排与 Uptime Kuma 可用它做基础就绪探测。
  • Docker HEALTHCHECK 指向同一监听端口(app 内置 health 子命令或 wget 同端口),不依赖独立端口(C-NO-TCP-EXPOSE)
  • 仅保留通用平台映射(compose HEALTHCHECK、k8s probe);不写 Cloud Run/Fly 等特指内容(NG-SERVERLESS-BIND)

8. CI 发布与镜像验收

8.0 代码门禁(GATE-P0,进入阶段1前必须通过)

  • cargo fmt --check
  • cargo clippy --all-targets -- -D warnings
  • cargo test

以上三条在 Debian rust:1-slim-bookworm 容器内执行(C-DEBIAN-SLIM);详见 design.md §10.0。

容器以 --user 1000:1000 运行,并把 RUSTUP_HOME/CARGO_HOME 挂载到仓库内持久缓存目录(-v "$PWD/.gate-cache/rustup:$RUSTUP_HOME" -v "$PWD/.gate-cache/cargo:$CARGO_HOME"),否则缓存写入因权限失败、工具链每次重建;.gate-cache/ 已在 .gitignore 中,不得提交。门禁结果必须基于当前仓库快照重跑得到,不得复用先前快照的通过结果。

(其余 CI:镜像构建 → 镜像扫描 → 非 root → health 端点到容器验证;compose 示例收敛为单端口 + 外部 Redis URL + 无 volume)

8.1 docker-compose 示例(收敛)

services:
  bot:
    image: messageweave:latest
    ports:
      - "8080:8080"        # 平台 ingress/反代映射 HTTPS → 8080(C-HTTPS-INBOUND)
    environment:
      PORT: "8080"
      RUN_MODE: webhook
      # BOT_TOKEN / JMAP_* / CHAT_ALLOWLIST / REDIS_URL
      # RECONCILE_TOKEN / TG_WEBHOOK_SECRET(写入口鉴权;Push verification 由 Stalwart 动态生成)
    healthcheck: { test: ["CMD", "message-weave", "health", "--addr", "127.0.0.1:8080"], interval: 30s }
    # REDIS_URL 指向外部、已认证的 Redis;不在此 compose 中运行 Redis。

注意:本示例对接 Compose;真实部署可去掉 ports 直接挂到平台 ingress(HTTPS-only)。

8.2 镜像与部署验证检查项(红线自检)

以下检查在每次发布前/CI 中执行,用于固化 §0 新增红线:

# 检查项 关联 ID 命令/方法
1 镜像内无数据库引擎:不装/不含 SQLite、Postgres、MySQL 二进制或数据文件 C-NO-DB docker run --rm <image> sh -c 'command -v sqlite3 psql mysql 2>/dev/null; [ -z "$(find / -maxdepth 4 -name "*.db" -o -name "*.sqlite*" 2>/dev/null)" ]' 应为空
2 容器内不出现本地可写挂载:compose/k8s 不声明 volumes: 用于数据/日志目录 C-NO-LOCAL-WRITE / NG-LOCAL-VOLUME 人工/CI lint 检查 compose 文件、k8s manifest 不含本地 volume 声明
3 日志仅 stdout/stderr:应用启动参数不得含 file/rolling-file 后端;tracing 配置为 stdout C-LOG-STDOUT-ONLY 检查代码:grep -rEn 'rolling|FileAppender|RollingFileAppender' src/ 应为空;RUST_LOG 目标不含 file:
4 日志/Redis 无敏感数据:日志字段白名单 + CI 扫描 SAF-LOG-PURITY 代码评审 + 静态扫描(如 gitleaks 扫描 Redis 写入调用,验证 SETEX/SET 参数无密钥/正文/AI 内容)
5 重启恢复不依赖进程内状态:所有"续跑"逻辑必须读 Redis;进程内缓存仅性能优化 C-NO-STATEFUL-RECOVERY 代码评审:grep -rn 'static mut|lazy_static' src/ 不得用于生产恢复路径
6 Redis 由外部提供:容器不运行 redis-server;compose 不启动 Redis 服务 C-REDIS-MANAGED-AOF docker run --rm <image> sh -c 'command -v redis-server && exit 1 || exit 0' 应为 0

任一检查失败,禁止发布。


9. 已确认决策与少量待办

9.1 已确认决策(不再作为待确认问题)

ID 决策
REQ-SINGLE-ACCOUNT 单账户实现;多账户暂用多个 bot 实例(各自 token/配置),不做多账户单实例
C-AUTH-APP-BASIC Stalwart 认证 = App Password + Basic(不用主密码、不用 OAuth)
REQ-AI-EXTERNAL-CONSENT 仅当用户明确允许时才把邮件正文发往外部 AI(默认不外发)
C-REDIS-MANAGED-AOF Redis 由用户托管并开启 AOF 持久化(Bot 不自建/不管理 Redis)
C-HTTPS-URL 公网 HTTPS 入口由平台提供(平台给 bot 一个 HTTPS URL;bot 不持证书、不监听 443)
NFR-RECONCILE-INTERVAL 外部 Cron 定期 HTTPS POST /reconcile,建议 5–10 分钟;容器不自建定时器
NFR-NOTIFY-SLA 允许少量通知延迟;通知可用性目标 ≥ 99.9%(边界见 §6.5)
ARCH-LB-WORKER 多实例高可用:免费 Cloudflare Worker 作为唯一对外入口 + 故障转移,后端为 2+ 个不同 serverless 平台的同镜像实例,共享同一 Redis(详见 §10)
SAF-LB-PASSTHRU 信任模型 = 透传(A):Worker 不改写鉴权信息,后端必须继续 fail-closed 校验(小平台无防火墙/ACL,"后端不对公网暴露"不可行)
SAF-RECONCILE-LOCK /reconcile 不扇出:用 Redis 锁保证同一时刻仅一个实例执行
MOD-HEALTH-AGG Worker 暴露聚合健康视图,供外部监控
NFR-HA-MULTI-INSTANCE 双活或主备均可;Redis 单点故障不在本方案范围(用户外部解决,短暂不可用可接受)
C-NO-DB 生产不使用任何数据库:Redis 是唯一状态存储;应用不自建/不连接第二个数据库
C-NO-LOCAL-WRITE 禁止本地文件/目录写入:无日志文件、无数据文件、无临时缓存、不挂载本地卷
C-LOG-STDOUT-ONLY 日志只写 stdout/stderr(平台负责采集落盘);禁用文件日志后端
SAF-LOG-PURITY 日志与 Redis 写入不得包含:密钥、邮件正文、AI 请求/响应、附件内容;仅允许结构化事件、计数、时间戳、脱敏摘要
C-NO-STATEFUL-RECOVERY 禁止依赖进程内状态做生产恢复:重启恢复一律走 Redis + JMAP 对账(FLOW-RECONCILE);进程内缓存仅为性能优化,丢失必须安全可重入

9.2 仍需确认(部署相关,仅此)

# 待确认 关联 ID
Q-DEP-A 平台 URL / 域名与证书由谁配置(平台自动证书 or 自管反代),以及 4 条路径的可达性验证方式 C-HTTPS-URL
Q-DEP-B 外部调度器具体选型(系统 crontab / k8s CronJob / CI scheduled / 第三方 cron)—— 仅影响运维方式,不改变架构 NFR-RECONCILE-INTERVAL

其余产品/架构问题见 design.md;不再有平台特定部署问题(NG-SERVERLESS-BIND)。


10. 多实例高可用与 Worker 前置负载均衡(ARCH-LB-WORKER)

目标:在多个 serverless 平台各部署一份相同镜像(各自不同的 HTTPS 入口),最前面用一个免费 Cloudflare Worker 作为唯一对外入口 + 故障转移;所有实例共享同一个外部 Redis。双活(round-robin)或主备(active-standby)均可。业务代码无需改动——之所以可行,是因为既有设计已满足前提(见下)。

10.1 拓扑(ARCH-LB-WORKER)

Telegram setWebhook ─┐
Stalwart PushSub ────┼─▶ https://lb.<you>.workers.dev      ← 唯一登记 URL(C-LB-SINGLE-REG-URL)
外部 Cron ───────────┘        │  Cloudflare Worker(免费)
                              ├─▶ https://a.<platform1>/…  ─┐
                              └─▶ https://b.<platform2>/…  ─┼─▶ 同一个外部 Redis(C-REDIS-ONLY-STATE)
                                 (同一镜像、同一配置、同一组 secret)─┘
  • 只登记 Worker 的稳定 URL(C-LB-SINGLE-REG-URL):Telegram setWebhook、Stalwart PushSubscription.url、外部 Cron 都指向 Worker(https://lb.example.com/webhook/tg、/push/jmap、/reconcile)。后端易变的平台入口不对外登记。
  • 后端 = 与 §3 相同的镜像、相同 env、相同 Redis;不引入长连接(Worker 纯请求-响应,C-NO-LONG-CONN)。

10.2 为什么可直接支持多实例(既有前提)

前提 ID 作用
状态仅外部 Redis C-REDIS-ONLY-STATE 任意实例可服务任意请求,无需粘性会话
幂等去重 MOD-DEDUP 重复/重试投递不会重复通知,故"多实例 + 重试"安全
对账补差 FLOW-RECONCILE 兜住 Push 丢失 / cold-start 窗口

因此 LB/HA 是纯运维拓扑变化;领域逻辑与渠道层无需改动。

10.3 信任模型 = 透传(A)(SAF-LB-PASSTHRU)

  • Worker 原样转发 header / body / pushSubscriptionId / verificationCode,不做鉴权改写。
  • 后端必须继续 fail-closed 校验三条入口(SAF-AUTH-*)——不可省。原因:小平台通常不提供防火墙/ACL,后端 HTTPS 入口可能被公网直连;因此"仅靠 Worker 防护"不成立(方案 B「后端不对公网暴露」在本场景不可行)。
  • 多实例必须共享业务鉴权配置(C-LB-SHARED-SECRETS):TG_WEBHOOK_SECRET / RECONCILE_TOKEN 在所有实例上完全一致;Push verification 状态由 Redis 共享。

10.4 路由与故障转移

  • 路由 safelist(C-LB-SINGLE-REG-URL):Worker 只透传 GET /、SPA 静态资源、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 /reconcile、GET /ready;未知路径 404、method 不符 405,不透传至后端。POST /api/push/register 不在 safelist 内(Worker 对其返回 404),Push 注册必须按 §10.5 直接访问某个后端实例地址,不能走 Worker 域名。/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/Telegram 副作用。
  • 故障转移(forwardWithFailover):每次请求最多 min(LB_MAX_ATTEMPTS, origins.length) 次尝试;仅超时(AbortError)或 5xx 触发换下一个 origin;4xx/2xx/3xx 直接返回;默认 LB_MAX_ATTEMPTS=2(首次 + 1 次故障转移)。
  • 随机分摊:起点 origin 按 Math.random 随机化,实现双活;单 origin 配置时退化为确定性。
  • 全失败兜底:返回 503 All Backends Unavailable,交由 Telegram / Stalwart 自动重投(不丢消息)。
  • 超时预算(LB_REQUEST_TIMEOUT_MS,默认 10s)> 最坏 cold start。
  • 重复兜底:Worker 重试 + Telegram 重投造成的重复由 MOD-DEDUP 吸收;幂等键 TTL 必须覆盖 Worker 重试与 Telegram 重投窗口。
  • 不代理 Redis/JMAP(C-NO-DB / C-REDIS-ONLY-STATE):Worker 只做请求转发,后端各连自己的 Redis;Worker 不做数据库侧检查。
  • 无长连接(C-NO-LONG-CONN):纯请求-响应,body 一次性 arrayBuffer 回灌;无 WS/SSE/长轮询。

10.5 各入口差异(重要)

入口 是否可扇出/重复 处理
/webhook/tg 可安全重复(update_id 去重) Worker 选一实例;重试安全
/push/jmap 可安全重复((account,email_id) 去重) Worker 选一实例
/reconcile ❌ 不可扇出 Redis 锁(SAF-RECONCILE-LOCK)保证同一时刻仅一个实例执行,避免重复对账
Streams worker 用相同消费组名(MOD-STREAMS-GROUP) Redis XREADGROUP 自动分摊给多实例;at-least-once 下同一消息不会重复处理

10.6 聚合健康视图(MOD-HEALTH-AGG)

  • Worker 额外暴露聚合端点,报告各后端存活数与整体可用性,供外部监控/告警使用;比单实例探针更有意义。

10.7 边界与不在本方案范围

  • Redis 单点故障不在本方案范围(NFR-HA-MULTI-INSTANCE):由用户在外部解决;其短暂不可用可接受(Redis 全丢可由 FLOW-RECONCILE 从 JMAP 重建 sinceState;dedup/会话丢失只导致少量重复通知,符合 NFR-NOTIFY-SLA)。
  • Cloudflare Worker 免费额度:预期足够(requests/day、subrequest、CPU/超时上限需上线后实测确认)。
  • 不引入长连接、不引入平台特定绑定(C-NO-LONG-CONN/NG-SERVERLESS-BIND)。

10.8 部署 Cloudflare Worker(ARCH-LB-WORKER 具体步骤)

代码位于仓库子目录 cloudflare-worker/,非本 Rust 二进制的一部分;Worker 只做请求转发,不运行 Redis/JMAP,不引入数据库(C-NO-DB)。

前置

  • 已按 §3 部署 ≥1 个后端镜像(多实例则部署 2+ 个),各后端 PORT/路由同 §6.1。
  • 后端各实例 env 必须共享同一组 secret(C-LB-SHARED-SECRETS)。
  • 已注册 Cloudflare 账号与 Workers 计划。

配置(所有值通过 wrangler secret 注入,禁止写入 wrangler.toml 或 git 明文)

cd cloudflare-worker
npm install --no-audit --no-fund
npx wrangler secret put BACKEND_ORIGINS_JSON   # JSON 数组,见下方示例
# 可选(带默认值)
npx wrangler secret put LB_REQUEST_TIMEOUT_MS   # 默认 10000ms
npx wrangler secret put LB_MAX_ATTEMPTS         # 默认 2(首次 + 1 次故障转移)
npx wrangler secret put LB_HEALTH_TTL_MS        # 默认 30000ms

BACKEND_ORIGINS_JSON 示例(仅允许 https:// origin,其它会被 Worker 启动即 503,C-HTTPS-INBOUND):

[
  {"url":"https://messageweave-a.example.com","weight":100},
  {"url":"https://messageweave-b.example.com","weight":100}
]

部署与验证

cd cloudflare-worker
npx wrangler deploy --env production
# 验证 LB 健康聚合
curl https://<your-worker>.workers.dev/healthz

Telegram / Stalwart / Cron 登记的唯一 URL 改为 https://<your-worker>.workers.dev/webhook/tg 等(C-LB-SINGLE-REG-URL);各后端平台的 HTTPS 入口不再对外登记(小平台入口可能被公网直连,故后端鉴权不可省,SAF-LB-PASSTHRU)。

验证(发布前必做)

  1. cd cloudflare-worker && npm test(Node 单测,代理逻辑 + 健康聚合 + 超时/失败重试 + safelist)。
  2. npx wrangler deploy --env production 后 curl /healthz 返回 200 且 available ≥ 1。
  3. curl -X POST https://<worker>/webhook/tg -H "Content-Type: application/json" -d '{"test":1}' 应得到后端返回(非 503)。
  4. curl -X POST https://<worker>/unknown 应得到 404;curl -X GET https://<worker>/reconcile 应得到 405。
  5. 日志仅出现 method/path/origin/失败类别,绝不含 header/body/secret(SAF-LOG-PURITY)。

边界说明

  • Worker 未实现 /reconcile 的 Redis 锁(SAF-RECONCILE-LOCK)——该锁由后端在 /reconcile 处理器内部执行;Worker 只做透传。详见 SAF-RECONCILE-LOCK 条目。
  • Worker 不代理 Redis、不检查数据库侧可用性(C-NO-DB / C-REDIS-ONLY-STATE)。
  • Worker 不落地 JMAP/Telegram 的 secret;Push verification 由后端通过 JMAP API 动态完成并只在 Redis 短期保存状态。

附:稳定 ID 引用索引

跨文档稳定 ID 的唯一权威索引表在 AGENTS.md §7「跨文档引用索引表」。 本文使用到的 ID(C-* / NG-* / MOD-* / FLOW-* 等)均在其中登记定义点位置与一句话说明;内容搬家时只改 AGENTS.md 索引表的"文件/锚点"列,所有引用本身零改动。