diff --git a/docs/charter.md b/docs/charter.md index 6b08213..aa475da 100644 --- a/docs/charter.md +++ b/docs/charter.md @@ -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,公网不可达 | 安全 | diff --git a/docs/deployment.md b/docs/deployment.md index 334a6af..9838de0 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -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 ` 承载令牌(`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/配置),不做多账户单实例 | diff --git a/docs/design.md b/docs/design.md index 36906a5..2342971 100644 --- a/docs/design.md +++ b/docs/design.md @@ -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` | diff --git a/docs/reference.md b/docs/reference.md index 9fb667f..2345db9 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -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 `, 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 diff --git a/hoststack.yaml b/hoststack.yaml index 77e3025..12863c8 100644 --- a/hoststack.yaml +++ b/hoststack.yaml @@ -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 " 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 diff --git a/src/main.rs b/src/main.rs index e6973b1..fae2ba8 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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> = None; + assert!(debug_surface_requested(true, None) && token.is_none()); + assert!(debug_surface_requested(false, Some("1")) && token.is_none()); + } }