feat(debug): 开关信号改为 DEBUG_ENABLED 环境变量,启动命令不再需要改动

起因:用户反馈会临时改启动命令(比如给 start 加 --debug)。而 hoststack.yaml
声明的 start.command 优先级最高且每次部署都覆盖控制台旧值,所以每次开关调试面
都得改 YAML、提交、推送、部署——最麻烦的那条路。

改法:把双因子的「开启信号」从单一 argv 变成「argv 或 env 二者之一」:
- --debug 保留,本地开发照旧用
- 新增 DEBUG_ENABLED(1/true/TRUE/True/yes/YES,精确匹配),生产走控制台环境变量
- DEBUG_TOKEN 仍然必须非空。刻意不让 token 单独生效——token 是平台上的长期
  Secret,让它自己开门就等于把双因子退化成单因子

收益:生产开关调试面 = 控制台改一个 env + 重新部署,不改代码、不改 hoststack.yaml、
不重新构建。启动命令从此静态,顺带消解了"YAML 还是控制台"的争论——命令不再需要变,
放哪都无所谓。

实现:把判定抽成纯函数 debug_surface_requested(debug_arg, debug_enabled_var),
不直接读全局 env。env 是进程级全局态,测试里读它会和同二进制内其他测试竞争;
调用方传入已取好的值,测试就能覆盖真值集合、假值集合(含 "TRUE " 带空格、
"debug" 这类近似值)与"开了开关但没 token 仍然关闭"。

文档:deployment.md §2.1 双因子条件重写 + 环境变量清单;§5 表格;§5.1 启动命令
说明;reference.md §3 表与调试面描述;design.md 架构图注与 LLM_ALLOW_NET 锚点;
charter.md 两条 ID 描述。hoststack.yaml 的 start 注释从"临时加 --debug"改成
"用 DEBUG_ENABLED 切"。

顺带修掉加代码引发的锚点漂移(新代码把 main.rs 后面的行号整体后移 13):
design.md:366、design.md:739、reference.md:205/335/336/380 共 6 处。

验证:cargo test --bin message-weave = 65 passed / 0 failed(新增 4 个测试,
含真值集合、假值集合、无 token 仍关闭)。GATE-DOCS PASS 6/6,259 line-refs 全绿。
This commit is contained in:
anonsaber 2026-09-29 12:54:06 +08:00
parent 633b41394c
commit 4cf128fcc8
6 changed files with 118 additions and 35 deletions

View File

@ -62,7 +62,7 @@
13. **AI 结果不进入消息**:LLM 输出只用于决策,不落 Redis、不进入 Telegram 消息。
14. **AI 需用户级外部授权**:只有显式授权过且未过期的 chat 才触发 LLM;授权是外部行为,进程不代授权。
15. **业务白名单**:出站 Telegram chat 必须命中 `CHAT_ALLOWLIST`。
16. **诊断面默认关闭**:`/debug/*` 需要 `--debug` + `DEBUG_TOKEN` 双因子同时满足。
16. **诊断面默认关闭**:`/debug/*` 需要 `DEBUG_ENABLED`(或 `--debug`)+ `DEBUG_TOKEN` 双因子同时满足。
17. **诊断面不进网关**:`/debug/*` 不在 Worker `SAFE_ROUTES`,只能直连后端 origin。
18. **网关只透传、不决策**:Worker 不解析请求体、不校验业务逻辑、不下发 `Retry-After`。
19. **AI 结果不重试**:LLM 分析失败即视为「不通知」,不重试、不降级为无 AI 通知。
@ -270,8 +270,8 @@ Push callback 公网映射。
| `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 / 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`),默认绝对关闭 | 安全 |
| `MOD-DEBUG` | src/debug.rs / docs/deployment.md §2.1 / docs/design.md §7.6 / docs/reference.md §3 | 远程联调只读表面:`DEBUG_ENABLED`(或 `--debug`)+ `DEBUG_TOKEN` 双因子开启后挂载 `/debug/*`,否则不挂载 | 组件 |
| `SAF-DEBUG-GATE` | src/main.rs / src/debug.rs / docs/design.md §7.6 / docs/deployment.md §2.1 | 双因子门禁:「`DEBUG_ENABLED` 为真值或命令行带 `--debug`」**且** `DEBUG_TOKEN` 非空才挂载路由;缺任一完全不挂载(请求落通用 `404`),默认绝对关闭。开启信号走 env 而非 argv,使启动命令保持静态、开关可在平台控制台单点切换 | 安全 |
| `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/*` 不在网关 15 条安全路由内,Worker 一律 `404 route not forwarded`;只能直连后端 origin,公网不可达 | 安全 |

View File

@ -59,10 +59,22 @@
**双因子启用条件(`SAF-DEBUG-GATE`,两者必须同时成立)**
1. 进程命令行必须带 `--debug`(读取于 `src/main.rs:82`)。
2. `DEBUG_TOKEN` 环境变量必须存在且非空(读取于 `src/main.rs:82-89`)。
1. 「开启信号」成立——满足**其一**即可:进程命令行带 `--debug`(本地 / 开发),或
`DEBUG_ENABLED` 环境变量为真值(`1` / `true` / `TRUE` / `True` / `yes` / `YES`,大小写
严格匹配,其余取值一律视为关闭)。判定逻辑在 `src/main.rs:15-25` 的纯函数
`debug_surface_requested`,读取点在 `src/main.rs:102-105`。
2. `DEBUG_TOKEN` 环境变量必须存在且非空(读取于 `src/main.rs:106-114`)。
缺一即不挂载:`debug_router()` 本身会构造出全部 7 条路由(`src/debug.rs:72-80`),但主入口**只在双因子成立时才合并它**(`src/notify.rs:1321-1322`)。所以未开启时 `/debug/*` 路由根本不存在,请求走 axum 兜底返回普通 `404 not found`——**不是** 401,也不会泄露「此路径存在」。开启成功时打一条 WARN 日志标记该面已打开(`src/main.rs:90-92`,`SAF-LOG-PURITY`:只记开启状态,从不记录 token 值)。
`DEBUG_ENABLED` 而不是 `DEBUG_TOKEN` 单独生效,是刻意的:**只看 token 是否配置会退化掉双因子**
——token 是长期存在平台上的 Secret,让它自己开门就等于没有第二道门。`--debug` 与
`DEBUG_ENABLED` 二选一即可,因为开关信号本身就是一道门,第二道门始终是 token。
**为什么生产用 `DEBUG_ENABLED` 而不是改启动命令加 `--debug`**:这样启动命令保持静态,
开关就成了控制台上一个环境变量的事,改一次 + 重新部署即可,不用提交、不用改
`hoststack.yaml`、不用重新构建。判定为纯函数(不直接读全局 env)以便单测覆盖,测试在
`src/main.rs:217-264`。
缺一即不挂载:`debug_router()` 本身会构造出全部 7 条路由(`src/debug.rs:72-80`),但主入口**只在双因子成立时才合并它**(`src/notify.rs:1321-1322`)。所以未开启时 `/debug/*` 路由根本不存在,请求走 axum 兜底返回普通 `404 not found`——**不是** 401,也不会泄露「此路径存在」。开启成功时打一条 WARN 日志标记该面已打开(`src/main.rs:115-117`,`SAF-LOG-PURITY`:只记开启状态,从不记录 token 值)。
**鉴权**
@ -90,7 +102,9 @@
- **`/debug/notify` 受 chat 白名单约束,但空白名单不拦截**:它复用生产同一份白名单快照(`src/notify.rs:1142`),判定条件是「白名单**非空**且 `chat_id` 不在其中」才回 `403 chat_not_allowed`(`src/debug.rs:232-235`)。因此已配置白名单时无法绕过业务侧发送限制;若白名单未配置(为空)则此判定不生效,`chat_id` 可任意指定——所以启用本面时应确认业务白名单已真正配置。`text` 缺省为固定联调文案,并按 1024 字符截断(`src/debug.rs:240-245`)。
- **不在 Worker 白名单内,只能直连 origin**:网关的 15 条安全路由(`cloudflare-worker/src/backends.js:9-25`)不含任何 `/debug/*`,Worker 对未白名单路径返回 `404 route not forwarded: /debug/...`(`cloudflare-worker/src/index.js:81-82`)。因此 `/debug/*` 只能通过直连后端 origin 访问;若必须经代理,请自行在代理层加鉴权,不要让公网可达。
> **建议**:生产环境不开启。需要远程联调时临时开启、用一次性 token,联调结束立即移除 `--debug` 与 `DEBUG_TOKEN` 后重启;长期暴露面走 §5 的 Secret 管理流程单独审批。
> **建议**:生产环境不开启。需要远程联调时在控制台设置 `DEBUG_ENABLED=1` 与一次性
> `DEBUG_TOKEN`,重新部署后联调;结束立即把 `DEBUG_ENABLED` 置空并轮换 `DEBUG_TOKEN`。
> 全程不需要改动启动命令或提交代码。长期暴露面走 §5 的 Secret 管理流程单独审批。
---
@ -138,8 +152,9 @@ HostStack 使用仓库根目录 `hoststack.yaml`:`runtime: rust` 的 agent 在
`debian:trixie-slim` 的 runner 容器,以 `start.command`(`./target/release/message-weave`)
启动,监听单个 HTTP 端口,并以 `/healthz` 做 interval 30 秒、timeout 5 秒的健康检查。
`install:` **是**合法的 schema 键——`install` / `build` / `start` 是三条独立命令,
依赖获取不写进 `build.command`。服务命令里**不带** `--debug`——远程联调开关只应临时加上
(改法见 `hoststack.yaml` 的注释,双因子见 §2.1)。`REDIS_URL` 与
依赖获取不写进 `build.command`。服务命令里**不带** `--debug`,也**不需要**带——远程联调开关
用 `DEBUG_ENABLED` 环境变量控制(见 §2.1),改控制台即可,启动命令保持静态。
`REDIS_URL` 与
`CONFIG_ENCRYPTION_KEY` 必须配置为 HostStack Secret;不得将密钥值写入 YAML、镜像、日志
或代码仓库。**本仓库的 Dockerfile 不由该路径执行**——runner 镜像是 HostStack 自带的
`debian:trixie-slim`,不是本文件的 `debian:bookworm-slim`,见 §3 与 §8.1 的说明。
@ -212,7 +227,7 @@ services 表里已存的值填进 payload,再用 `hoststack.yaml` 声明的值
| `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`) |
| `--debug` + `DEBUG_TOKEN` | 可选,默认关闭 | 远程联调面 `/debug/*` 的**双因子开关**(`SAF-DEBUG-GATE`):命令行必须带 `--debug` **且** `DEBUG_TOKEN` 非空才挂载 7 条路由,缺一即路由不存在、请求走通用 `404`。令牌为 `SecretString`,常数时间比较、不进日志(`SAF-LOG-PURITY`)。生产环境不配置,见 §2.1 |
| `DEBUG_ENABLED` + `DEBUG_TOKEN` | 可选,默认关闭 | 远程联调面 `/debug/*` 的**双因子开关**(`SAF-DEBUG-GATE`):`DEBUG_ENABLED` 为真值(或命令行带 `--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 业务配置字段(`REQ-LLM-OPENAI-COMPAT`);其中 `LLM_MAX_RETRIES` **不是**业务配置字段而是 Redis 运行参数(回落默认见 `docs/reference.md` §6.1);**仅当用户明确允许时才把邮件正文外发 AI**(`REQ-AI-EXTERNAL-CONSENT`) |
| `ACCOUNT_ID` | 默认空 | **单账户**(`REQ-SINGLE-ACCOUNT`):留空则取 session 主账户;多账户 = 部署多个 bot 实例(各自独立 token/配置),不做多账户单实例 |

View File

@ -344,7 +344,7 @@ message-weave/
| `ai` | reqwest 0.13 | `LlmClient` / `summarize` | mock OpenAI 兼容端点 | 已实现 |
| `notify` | axum;redis | HTTP 鉴权、全局开关、`/push/jmap` 入队、`Email/changes` 对账和游标提交 | webhook 校验失败路径覆盖 403 | 已覆盖真实对账路径;仍需真实 Stalwart 环境做端到端验收 |
| `web` | axum | `/config` 静态页 + `include_str!` 嵌入 + CSP | 前端由 `web/config.test.mjs` 覆盖 | 已实现 |
| `debug` | reqwest 0.13;redis 0.27 | 远端诊断面 `/debug/*`(`src/debug.rs`):6 条只读探针 `/debug/ping`、`/debug/config`、`/debug/redis`、`/debug/jmap`、`/debug/telegram`、`/debug/worker` + `POST /debug/notify`(走生产出站路径发一条测试消息,无独立实现) | 仅 `--debug` + `DEBUG_TOKEN` 双因子齐备时挂载路由;模块本身无条件编译 | 已实现,不进 Worker 白名单(`SAF-DEBUG-ORIGIN-ONLY`) |
| `debug` | reqwest 0.13;redis 0.27 | 远端诊断面 `/debug/*`(`src/debug.rs`):6 条只读探针 `/debug/ping`、`/debug/config`、`/debug/redis`、`/debug/jmap`、`/debug/telegram`、`/debug/worker` + `POST /debug/notify`(走生产出站路径发一条测试消息,无独立实现) | 仅 `DEBUG_ENABLED`(或 `--debug`)+ `DEBUG_TOKEN` 双因子齐备时挂载路由;模块本身无条件编译 | 已实现,不进 Worker 白名单(`SAF-DEBUG-ORIGIN-ONLY`) |
> 注:「现状」列是**模块级**口径(模块已落地),不代表行为完备。行为级缺口不在本表内:`docs/roadmap.md`「代码缺口」中 `/search`、Telegram 429 退避、多实例重复投递窗口均已实现,仅剩 1 条按产品决策保留不改(`SAF-DEBUG-ALLOWLIST`)。`worker` 模块的 `/search` 路径已随 `bfe0fd8` 落地。
@ -363,8 +363,8 @@ message-weave/
> 使用新配置,失败保留旧实例。该迁移替代下述阶段0环境变量清单,阶段0列表仅作为历史
> 兼容说明。
> **实际实现**(`ARCH-CONFIG-ENV`):进程启动只读 **2 个**进程级凭据加监听端口(`src/main.rs:31` / `:40` / `:49`,
> 另加可选 `DEBUG_TOKEN` 于 `:83`),直接 `std::env::var`,**不使用 figment、不使用 TOML/配置文件**。
> **实际实现**(`ARCH-CONFIG-ENV`):进程启动只读 **2 个**进程级凭据加监听端口(`src/main.rs:45` / `:54` / `:63`,
> 另加可选 `DEBUG_TOKEN` 于 `:107`),直接 `std::env::var`,**不使用 figment、不使用 TOML/配置文件**。
> **业务配置不再从环境变量读取**:下表字段全部经 Redis 业务配置由 `config` 层以 serde 反序列化装载,
> 并经 `validate_business_wire` / `validate_nonblank` 做 fail-closed 校验。
> 历史上存在的 `Config::from_env()` 完整 env 解析路径**已删除**(登记见 `docs/retired.md`),
@ -455,7 +455,7 @@ message-weave/
### 7.6 远程联调面为何默认绝对关闭
该面存在的唯一理由是缩短生产排障路径:在无法登录容器、只能靠外部请求观察系统时,需要有人能在不发版的前提下探到 JMAP/Telegram 连通性与当前业务配置。代价是它的入口软度必然高于三条写路径——除只读探测外还保留一次**真实出站发送**,且复用业务白名单而非独立白名单。因此设计选择是「默认关闭」而不是「默认开启、靠网关挡」:进程同时带 `--debug` 且设置了非空 `DEBUG_TOKEN` 时才会挂载这组路由(`SAF-DEBUG-GATE`);缺一时路由在路由器里**根本不存在**,请求落到 axum 通用 `404`,而非「存在但 401」——后者会泄漏路由存在性。也不存在第三种状态:没有「未配置即开放」的回退,也没有任何配置项能把它设为默认开启。再叠一层位置约束:它不在网关的安全路由白名单内,即便后端开错,经平台入口也会被 fail-closed 拒掉,唯一可达路径是直连后端 origin。这四处——双因子挂载、404 而非 401、无默认开启回退、网关不可达——共同构成「暴露面默认为零」的架构决策。启用方式、逐端点状态码与部署确认清单见 deployment.md §2.1。
该面存在的唯一理由是缩短生产排障路径:在无法登录容器、只能靠外部请求观察系统时,需要有人能在不发版的前提下探到 JMAP/Telegram 连通性与当前业务配置。代价是它的入口软度必然高于三条写路径——除只读探测外还保留一次**真实出站发送**,且复用业务白名单而非独立白名单。因此设计选择是「默认关闭」而不是「默认开启、靠网关挡」:进程满足「`DEBUG_ENABLED` 为真值或命令行带 `--debug`」**且**设置了非空 `DEBUG_TOKEN` 时才会挂载这组路由(`SAF-DEBUG-GATE`);缺一时路由在路由器里**根本不存在**,请求落到 axum 通用 `404`,而非「存在但 401」——后者会泄漏路由存在性。也不存在第三种状态:没有「未配置即开放」的回退,也没有任何配置项能把它设为默认开启。`DEBUG_ENABLED` 走环境变量而非启动命令,是为了让开关能在不发版的前提下从平台控制台改一次完成——启动命令保持静态。再叠一层位置约束:它不在网关的安全路由白名单内,即便后端开错,经平台入口也会被 fail-closed 拒掉,唯一可达路径是直连后端 origin。这四处——双因子挂载、404 而非 401、无默认开启回退、网关不可达——共同构成「暴露面默认为零」的架构决策。启用方式、逐端点状态码与部署确认清单见 deployment.md §2.1。
---
@ -736,7 +736,7 @@ LLM 能力只存在于 `src/ai.rs` 一个文件;**没有**配置 / 回退 /
| 变量 | 类型 | 默认 | 用途 |
|---|---|---|---|
| `LLM_ENABLED` | bool | `false` | 总开关;默认关闭,未启用时不校验 `LLM_API_KEY`/`LLM_BASE_URL`/`LLM_MODEL` |
| `LLM_ALLOW_NET` | bool | `false` | 运行时出站许可;`LLM_ENABLED && LLM_ALLOW_NET` 同时为真才构造 `LlmClient`,否则 `llm` 字段为 `None`(`src/main.rs:111-127`) |
| `LLM_ALLOW_NET` | bool | `false` | 运行时出站许可;`LLM_ENABLED && LLM_ALLOW_NET` 同时为真才构造 `LlmClient`,否则 `llm` 字段为 `None`(`src/main.rs:135-141`) |
| `LLM_API_KEY` | string | 可省略 | Bearer token;仅 `LLM_ENABLED=true` 时必填 |
| `LLM_BASE_URL` | URL | 可省略 | 仅启用时必填,且必须 `https`,否则 `AiError::InvalidEndpoint` |
| `LLM_MODEL` | string | 可省略 | 仅启用时必填;透传给 `/chat/completions` 的 `model` |

View File

@ -202,7 +202,7 @@ string `"30"`:
All HTTP API routes are registered in `router_with_worker_state_runtime_bootstrap_config`
(`notify.rs:1276`), with the routes wired at `notify.rs:1305-1319`. The three static SPA routes
in the last table row are the exception: they live in `web::router()` (`src/web.rs:19-24`) and
are merged into the router via `.merge(web::router())`: the setup-mode router at `src/main.rs:62`,
are merged into the router via `.merge(web::router())`: the setup-mode router at `src/main.rs:76`,
the production router at `src/main.rs:180`.
| Method | Path | Handler |
@ -224,8 +224,9 @@ the production router at `src/main.rs:180`.
| GET | `/api/status` | `setup_status` |
| GET | `/`, `/assets/config.js`, `/assets/styles.css` | `index` / `script` / `styles` |
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:82-89`,
The remote-debug surface (`src/debug.rs:72-80`) is merged **only when the debug surface is
requested — `--debug` on the command line or a truthy `DEBUG_ENABLED` env var — and `DEBUG_TOKEN`
is non-empty** (`SAF-DEBUG-GATE`, gate logic `src/main.rs:15-25`, wired at `src/main.rs:102-114`,
`src/notify.rs:1321-1322`); otherwise none of these routes exist and requests fall through to
axum's generic `404`, not a 401. All seven require `Authorization: Bearer <DEBUG_TOKEN>`,
checked by `debug_authorized` (`src/debug.rs:45`), which delegates to the production
@ -331,9 +332,10 @@ Three distinct layers. They are not interchangeable.
| Variable | Required | Default | Source |
|---|---|---|---|
| `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. The listener binds it directly (main.rs:35-38); `Config` carries no port field |
| `DEBUG_TOKEN` | no | — | `std::env::var` at main.rs:83, effective only when `--debug` is also passed (`SAF-DEBUG-GATE` / `SAF-DEBUG-ORIGIN-ONLY`) |
| `CONFIG_ENCRYPTION_KEY` | yes | — | `encryption_key_from_env` at main.rs:64 |
| `PORT` | no | `8080` | `unwrap_or(8080_u16)` at main.rs:45-48. The listener binds it directly (main.rs:49-52); `Config` carries no port field |
| `DEBUG_ENABLED` | no | — | `std::env::var` at main.rs:104; truthy values are `1`/`true`/`TRUE`/`True`/`yes`/`YES` (exact match); enables the debug surface on its own, paired with `--debug` as an alternative (`SAF-DEBUG-GATE`) |
| `DEBUG_TOKEN` | no | — | `std::env::var` at main.rs:107, effective only once the debug surface is requested via `DEBUG_ENABLED` or `--debug` (`SAF-DEBUG-GATE` / `SAF-DEBUG-ORIGIN-ONLY`) |
`RUN_MODE` **no longer exists**: the identifier was removed together with the legacy
`Config::from_env()` environment parser (registration in `docs/retired.md`). Both webhook
@ -375,7 +377,7 @@ The single prefixed name is `TELEGRAM_CHAT_ID`. Anything else documented as
`TELEGRAM_BOT_TOKEN` or similar is a documentation error, not a supported variable.
**Trust root.** The SPA admin credential is `CONFIG_ENCRYPTION_KEY` itself: read at startup
(main.rs:49) and held as `admin_token` (main.rs:78), it is checked in constant time by
(main.rs:63) and held as `admin_token` (main.rs:92), it is checked in constant time by
`worker_authorized` (notify.rs:446; compare at notify.rs:1136) at the top of both
`POST /api/bootstrap` (notify.rs:605) and `POST /api/admin/session` (notify.rs:775). It is
only compared against the request bearer — never echoed, logged, or stored. The session

View File

@ -32,7 +32,9 @@
# To drop this file, first correct all three commands in the dashboard UI,
# then delete and redeploy to verify. See docs/deployment.md §5.1.
#
# Do NOT add --debug here for production. See the note above start.command.
# Keep this line static. Do NOT add --debug here. The debug surface is
# toggled with the DEBUG_ENABLED env var (set it truthy in the dashboard and
# redeploy), so the start command never has to change. See docs/deployment.md §2.1.
services:
web:
type: web_service
@ -44,16 +46,16 @@ services:
command: cargo build --release --locked
start:
# Binary is named after the Cargo package: `message-weave`, not `app`.
# Do NOT add --debug here for production.
# Keep this static. Do NOT append --debug for production; the debug
# surface is toggled by the DEBUG_ENABLED env var instead, so this line
# never changes. DEBUG_TOKEN must also be set for either factor to work.
command: ./target/release/message-weave
# Temporary diagnosis only: change the line above to
# command: ./target/release/message-weave --debug
# and set the DEBUG_TOKEN secret. The debug surface is gated by BOTH
# factors — the argv flag AND a non-empty DEBUG_TOKEN — read once at
# process start. Then probe it from inside the container, never through
# the public domain:
# To temporarily open the debug surface: set DEBUG_ENABLED=1 and a one-off
# DEBUG_TOKEN in the dashboard, redeploy, then probe from inside the
# container, never through the public domain:
# curl -s -i -H "Authorization: Bearer <token>" http://127.0.0.1:8080/debug/config
# See docs/deployment.md §2.1 for the double-factor gate.
# Close it by clearing DEBUG_ENABLED and rotating DEBUG_TOKEN. See
# docs/deployment.md §2.1 for the double-factor gate.
healthCheck:
# /healthz only, never /ready. /ready also probes redis, the JMAP session
# and Telegram getMe, so a transient upstream blip would restart the

View File

@ -12,6 +12,20 @@ mod worker;
use secrecy::ExposeSecret;
use std::net::SocketAddr;
/// SAF-DEBUG-GATE, first factor. The debug surface is requested when the process was launched
/// with `--debug` (local/dev) or the `DEBUG_ENABLED` env var is truthy (production, where it can be
/// toggled from the HostStack dashboard without touching the start command).
///
/// Pure on purpose: env vars are process-global, so reading them inside a test races with every
/// other test in the binary. Callers pass the already-fetched values in.
fn debug_surface_requested(debug_arg: bool, debug_enabled_var: Option<&str>) -> bool {
debug_arg
|| matches!(
debug_enabled_var,
Some("1") | Some("true") | Some("TRUE") | Some("True") | Some("yes") | Some("YES")
)
}
use crate::channel::telegram::TelegramClient;
use crate::domain::jmap::{client::JmapClientBackend, JmapService};
use config::{encryption_key_from_env, Config};
@ -77,9 +91,19 @@ async fn main() -> Result<(), error::BotError> {
// Redis would leave the admin UI permanently locked out.
let admin_token = secrecy::SecretString::new(encryption_key_raw);
// SAF-DEBUG-GATE: dual factor — /debug/* is mounted only when the process is launched with
// --debug AND a non-empty DEBUG_TOKEN Secret exists. Missing either factor keeps the
// surface absolutely closed (no route registered, so requests get a generic 404).
let debug_token = if std::env::args().any(|arg| arg == "--debug") {
// --debug (local/dev) OR the DEBUG_ENABLED env var is truthy (production, toggleable from the
// HostStack dashboard without touching the start command), AND a non-empty DEBUG_TOKEN
// Secret exists. Missing either factor keeps the surface absolutely closed (no route
// registered, so requests get a generic 404).
//
// DEBUG_ENABLED exists so the start command stays static: toggling the debug surface from the
// dashboard is then one env change plus a redeploy, with no hoststack.yaml edit and no commit.
// DEBUG_TOKEN alone is deliberately NOT sufficient — that would collapse the dual factor.
let debug_enabled = debug_surface_requested(
std::env::args().any(|arg| arg == "--debug"),
std::env::var("DEBUG_ENABLED").ok().as_deref(),
);
let debug_token = if debug_enabled {
std::env::var("DEBUG_TOKEN")
.ok()
.filter(|value| !value.is_empty())
@ -191,10 +215,50 @@ fn install_rustls_provider() -> bool {
#[cfg(test)]
mod tests {
use super::install_rustls_provider;
use super::{debug_surface_requested, install_rustls_provider};
#[test]
fn rustls_provider_is_installable_without_network() {
assert!(install_rustls_provider());
}
// SAF-DEBUG-GATE: first factor. The start command is static, so the only way to flip the
// debug surface at deploy time is these inputs — hence they each get an explicit case.
#[test]
fn debug_flag_enables_the_surface() {
assert!(debug_surface_requested(true, None));
// the flag wins even when DEBUG_ENABLED is unset, so local `--debug` still works
assert!(debug_surface_requested(true, Some("")));
}
#[test]
fn debug_enabled_var_accepts_the_truthy_set() {
for value in ["1", "true", "TRUE", "True", "yes", "YES"] {
assert!(
debug_surface_requested(false, Some(value)),
"DEBUG_ENABLED={value:?} should enable the surface"
);
}
}
#[test]
fn debug_enabled_var_rejects_everything_else() {
for value in ["0", "false", "off", "no", "TRUE ", "yes_no", "debug"] {
assert!(
!debug_surface_requested(false, Some(value)),
"DEBUG_ENABLED={value:?} must not enable the surface"
);
}
assert!(!debug_surface_requested(false, None));
assert!(!debug_surface_requested(false, Some("")));
}
// The token is the second factor and is enforced independently: an enabled surface with no
// token must still stay closed.
#[test]
fn enabled_without_token_stays_closed() {
let token: Option<secrecy::Secret<String>> = None;
assert!(debug_surface_requested(true, None) && token.is_none());
assert!(debug_surface_requested(false, Some("1")) && token.is_none());
}
}