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:
parent
633b41394c
commit
4cf128fcc8
|
|
@ -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,公网不可达 | 安全 |
|
||||
|
|
|
|||
|
|
@ -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/配置),不做多账户单实例 |
|
||||
|
|
|
|||
|
|
@ -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` |
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
72
src/main.rs
72
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<secrecy::Secret<String>> = None;
|
||||
assert!(debug_surface_requested(true, None) && token.is_none());
|
||||
assert!(debug_surface_requested(false, Some("1")) && token.is_none());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue