debug: 新增 /debug/* 远程联调面,双因子门禁默认绝对关闭(01a0e319)

新增 src/debug.rs:/debug/ping、/config、/redis、/jmap、/telegram、/worker 六个只读
探针 + POST /debug/notify 一条测试出站消息。

SAF-DEBUG-GATE 双因子,缺任一即完全不挂载路由(请求落到通用 404,无"未配置即放行"
降级):
- 启动命令带 --debug(main.rs 最小 std::env::args 解析,未新增依赖)
- Secret 环境变量 DEBUG_TOKEN 非空

两层执行点:main.rs 推导 debug_token 并传入 router builder(None 则 debug_router 不被
merge);debug.rs 侧 auth_token 缺失时直接返回空 Router。

SAF-DEBUG-AUTH:每个 handler 先做 Authorization: Bearer DEBUG_TOKEN 常数时间比较
(复用 worker_authorized),失败 401,鉴权通过前不产生任何副作用。

探针只读侧全部复用现成实现,不另写一套:Redis 走 is_enabled,JMAP 走
probe_jmap_session,Telegram 走 probe_telegram_get_me,出站走唯一构造点
business_client(notify.rs 新增 pub(crate))到 send_text,/debug/notify 与
refresh_business_config 共用同一条客户端构造路径。

SAF-NO-SECRET-ECHO / SAF-LOG-PURITY:/debug/config 只回"是否配置"、计数与超时等
运行时参数,bot token / JMAP password / worker / webhook / reconcile token / LLM key
一律不出现在响应体;DEBUG_TOKEN 只在鉴权内部暴露,不回显、不入日志,日志仅一条
"debug endpoints enabled" 且不含令牌值。allowlist 用 chat_allowlist_snapshot 取生效值
(运行时业务配置优先、否则启动值),/debug/notify 显式 chat_id 不在 allowlist 则 403。

ARCH-LB-WORKER 白名单不含 /debug/*,该面仅供直连 origin 联调,公网入口不可达。

AGENTS.md 登记 4 个稳定 ID:MOD-DEBUG / SAF-DEBUG-GATE / SAF-DEBUG-AUTH /
REQ-DEBUG-ENDPOINTS(§2 第 19 条硬边界、§5 测试条、§7 索引 4 行)。

调试路由测试(4 项,notify.rs + debug.rs 内):Bearer 放行 200、缺失/错误令牌 401、
未提供 DEBUG_TOKEN 因子时 /debug/* 完全不注册而落 404、/debug/notify POST 未认证 401。

门禁四段全绿(rust:1-slim-bookworm):cargo fmt --all -- --check / cargo check --locked
/ cargo clippy --locked --all-targets -- -D warnings / cargo test --locked =
49 passed / 0 failed / 1 ignored。
This commit is contained in:
anonsaber 2026-09-28 15:00:49 +08:00
parent 800d6e0d00
commit afa6cec8ef
4 changed files with 453 additions and 29 deletions

View File

@ -50,6 +50,7 @@
- **日志与 Redis 写入内容约束**(`SAF-LOG-PURITY`):仅限结构化事件、计数、时间戳、脱敏后的请求摘要;**禁止**写入密钥原文、JMAP 邮件正文、AI 请求/响应内容、附件内容。 - **日志与 Redis 写入内容约束**(`SAF-LOG-PURITY`):仅限结构化事件、计数、时间戳、脱敏后的请求摘要;**禁止**写入密钥原文、JMAP 邮件正文、AI 请求/响应内容、附件内容。
- **禁止依赖进程内状态做生产恢复**(`C-NO-STATEFUL-RECOVERY`):任何"重启续跑"(去重、sinceState、Streams 断点、熔断计数、会话)必须由外部 Redis + JMAP 对账(`C-REDIS-ONLY-STATE` / `FLOW-RECONCILE`)实现;进程内缓存仅为性能优化,**丢失必须安全可重入**。 - **禁止依赖进程内状态做生产恢复**(`C-NO-STATEFUL-RECOVERY`):任何"重启续跑"(去重、sinceState、Streams 断点、熔断计数、会话)必须由外部 Redis + JMAP 对账(`C-REDIS-ONLY-STATE` / `FLOW-RECONCILE`)实现;进程内缓存仅为性能优化,**丢失必须安全可重入**。
18. **无数据库/无本地写入/标准输出日志 = 发布门禁**:发布前必做 deployment.md §8.2 的 6 项红线自检(镜像无 DB 引擎、无本地可写挂载、日志仅 stdout/stderr、日志/Redis 无敏感数据、重启恢复不依赖进程内状态、Redis 由外部提供);任一失败禁止发布。 18. **无数据库/无本地写入/标准输出日志 = 发布门禁**:发布前必做 deployment.md §8.2 的 6 项红线自检(镜像无 DB 引擎、无本地可写挂载、日志仅 stdout/stderr、日志/Redis 无敏感数据、重启恢复不依赖进程内状态、Redis 由外部提供);任一失败禁止发布。
19. **远程 debug 双因子门禁(`MOD-DEBUG`/`SAF-DEBUG-GATE`/`SAF-DEBUG-AUTH`/`REQ-DEBUG-ENDPOINTS`)**:`/debug/*` 只在**两个条件同时满足**时才挂载——启动命令带 `--debug` 且 Secret 环境变量 `DEBUG_TOKEN` 非空;缺任一即完全不挂载该组路由,请求落到通用 `404`(**正常情况下绝对关闭**,禁止任何"未配置即放行"降级)。已挂载后每个 `/debug/*` 仍须 `Authorization: Bearer DEBUG_TOKEN` 并与 secret 做**常数时间**比较(`SAF-DEBUG-AUTH`),失败 `401` 且**在鉴权通过前不得产生任何副作用**。Worker 白名单(`ARCH-LB-WORKER`)不含 `/debug/*`,故公网唯一入口**永远不可达**该组路由;debug 仅供直连后端 origin 的远程联调。所有 `/debug/*` 响应体**不含任何 secret 原文**(`SAF-NO-SECRET-ECHO`),只回显"是否已设置"与探针结论;`DEBUG_TOKEN` 不回显、不入日志(`SAF-LOG-PURITY`)。
### 2.1 代码规模(软性指导,非硬限制) ### 2.1 代码规模(软性指导,非硬限制)
- 单个 `.rs` 文件原则上不超过 **500 行**;这是**软性指导**而非硬性门禁。 - 单个 `.rs` 文件原则上不超过 **500 行**;这是**软性指导**而非硬性门禁。
@ -132,6 +133,7 @@
- 正文转义、sinceState 存 Redis + 模拟 Redis 删除后由对账 `FLOW-RECONCILE` 恢复、通知去重。 - 正文转义、sinceState 存 Redis + 模拟 Redis 删除后由对账 `FLOW-RECONCILE` 恢复、通知去重。
- **入口鉴权(`SAF-AUTH-*`)**:`/reconcile`/`/webhook/tg`/`/push/jmap` 在缺失或错误凭证下返回 `401` 且**无副作用**(无 Redis 写入、无 JMAP/AI 调用);正确凭证放行。 - **入口鉴权(`SAF-AUTH-*`)**:`/reconcile`/`/webhook/tg`/`/push/jmap` 在缺失或错误凭证下返回 `401` 且**无副作用**(无 Redis 写入、无 JMAP/AI 调用);正确凭证放行。
- **健康探针(`SAF-PROBE-PUBLIC`)**:`/healthz` 返回 `200`;`/ready` 做端到端探测(`ARCH-READY-BASELINE`:配置完整性 + Redis 可达性 + 出站只读探测 JMAP session `GET` / TG `getMe`,各 3000ms,任一失败返 `503`)——测试**可以**断言 `/ready` 在配置 / Redis / 上游探针失败时返回 `503`;两个探针(`probe_jmap_session` / `probe_telegram_get_me`)是可复用纯函数,④ 远程 debug 直接调用,**不要另写一套**;两者响应体均不含敏感信息。 - **健康探针(`SAF-PROBE-PUBLIC`)**:`/healthz` 返回 `200`;`/ready` 做端到端探测(`ARCH-READY-BASELINE`:配置完整性 + Redis 可达性 + 出站只读探测 JMAP session `GET` / TG `getMe`,各 3000ms,任一失败返 `503`)——测试**可以**断言 `/ready` 在配置 / Redis / 上游探针失败时返回 `503`;两个探针(`probe_jmap_session` / `probe_telegram_get_me`)是可复用纯函数,④ 远程 debug 直接调用,**不要另写一套**;两者响应体均不含敏感信息。
- **远程 debug(`MOD-DEBUG`/`SAF-DEBUG-GATE`/`SAF-DEBUG-AUTH`/`REQ-DEBUG-ENDPOINTS`)**:缺 `--debug` 或 `DEBUG_TOKEN` 缺失/为空时,`/debug/*` **一律 `404`**(路由根本没挂载);双因子齐备时挂载,缺 `Bearer` 或 token 不符返回 `401`,正确 token 放行;`GET /debug/config` 响应体**不得出现任何 secret 原文**(`SAF-NO-SECRET-ECHO`,测试可断言只含布尔/计数/结论字段);出站只读探针直接复用 `probe_jmap_session` / `probe_telegram_get_me`(`ARCH-READY-BASELINE`),不另写实现;`POST /debug/notify` 复用真实出站路径(`TelegramChannel::send_message`),不得另建第二套 Telegram 客户端。
- **JMAP 只读 adapter(`MOD-JMAP-CLIENT`/`GATE-G1-JMAP-READONLY`)**:mock 测试覆盖 session/account 选择(`ACCOUNT_ID` 空→主账户、显式值校验)、**URL 归一化**(`JMAP_SESSION_URL` 基地址与完整 `…/.well-known/jmap` 两种输入结果一致、无重复路径、`http://` 拒绝、内嵌凭据拒绝,`REQ-JMAP-SESSION-URL`/`SAF-JMAP-URL`)、`list_folders`/`list_emails`(`limit` 边界)/`read_email`(多 part 拼接与"无可用部分"明确错误,`REQ-JMAP-RAW-MULTIPART`)、`received_at` 解析;真机测试用 `#[ignore]` 标记、经环境变量驱动(运行:`cargo test -- --ignored jmap::`;仅编译:`cargo test --no-run`),**缺环境时清晰跳过且不泄密**,CI 默认不跑真机用例。**G1/D-G1-1 代码已实现,待真实 `cargo test -- --ignored jmap::` 验证;未实际运行 `--ignored` 前不得声称"真机通过"。** - **JMAP 只读 adapter(`MOD-JMAP-CLIENT`/`GATE-G1-JMAP-READONLY`)**:mock 测试覆盖 session/account 选择(`ACCOUNT_ID` 空→主账户、显式值校验)、**URL 归一化**(`JMAP_SESSION_URL` 基地址与完整 `…/.well-known/jmap` 两种输入结果一致、无重复路径、`http://` 拒绝、内嵌凭据拒绝,`REQ-JMAP-SESSION-URL`/`SAF-JMAP-URL`)、`list_folders`/`list_emails`(`limit` 边界)/`read_email`(多 part 拼接与"无可用部分"明确错误,`REQ-JMAP-RAW-MULTIPART`)、`received_at` 解析;真机测试用 `#[ignore]` 标记、经环境变量驱动(运行:`cargo test -- --ignored jmap::`;仅编译:`cargo test --no-run`),**缺环境时清晰跳过且不泄密**,CI 默认不跑真机用例。**G1/D-G1-1 代码已实现,待真实 `cargo test -- --ignored jmap::` 验证;未实际运行 `--ignored` 前不得声称"真机通过"。**
- 领域/渠道解耦测试:领域模块(`src/domain.rs` / `src/ai.rs` / `src/worker.rs`)的公开接口不出现任何渠道 SDK 类型(编译期检查);`JmapBackend` 的 `MockBackend` 可驱动全部 JMAP 领域流程(不联网)。 - 领域/渠道解耦测试:领域模块(`src/domain.rs` / `src/ai.rs` / `src/worker.rs`)的公开接口不出现任何渠道 SDK 类型(编译期检查);`JmapBackend` 的 `MockBackend` 可驱动全部 JMAP 领域流程(不联网)。
- 文件行数抽查:>500 行的 `.rs` 文件在 PR 说明中可见(软性,不自动 fail)。 - 文件行数抽查:>500 行的 `.rs` 文件在 PR 说明中可见(软性,不自动 fail)。
@ -247,6 +249,10 @@
| `SAF-RECONCILE-LOCK` | docs/deployment.md §10.5 | `/reconcile` 不扇出,Redis 锁保证单实例执行,避免重复对账 | 安全 | | `SAF-RECONCILE-LOCK` | docs/deployment.md §10.5 | `/reconcile` 不扇出,Redis 锁保证单实例执行,避免重复对账 | 安全 |
| `MOD-STREAMS-GROUP` | docs/deployment.md §10.5 | 多实例用同一 Streams 消费组名,Redis 自动分摊(at-least-once 不重复处理) | 组件 | | `MOD-STREAMS-GROUP` | docs/deployment.md §10.5 | 多实例用同一 Streams 消费组名,Redis 自动分摊(at-least-once 不重复处理) | 组件 |
| `MOD-HEALTH-AGG` | docs/deployment.md §10.6 | Worker 聚合健康视图,报告各后端存活供外部监控 | 组件 | | `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 原文 | 需求 |
| `NFR-HA-MULTI-INSTANCE` | docs/deployment.md §10.7 / §9.1 | 多实例高可用语义;双活或主备均可;Redis 单点故障不在方案范围(用户外部解决) | 非功能 | | `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-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 | 禁止本地文件/目录写入(日志/数据/临时缓存/本地卷) | 约束 | | `C-NO-LOCAL-WRITE` | docs/deployment.md §0 / §9.1 / AGENTS.md §2 | 禁止本地文件/目录写入(日志/数据/临时缓存/本地卷) | 约束 |

364
src/debug.rs Normal file
View File

@ -0,0 +1,364 @@
//! `MOD-DEBUG` — optional remote debug / 联调 surface.
//!
//! Mounted only when **both** factors of `SAF-DEBUG-GATE` are present: the process was launched
//! with the `--debug` flag **and** the `DEBUG_TOKEN` Secret is non-empty. If either factor is
//! absent the router builder returns an empty router and no `/debug/*` route exists at all, so a
//! request falls through to the generic `404`. There is deliberately no "unconfigured = open"
//! fallback: the default state is absolutely closed.
//!
//! Even once mounted, every handler authenticates `Authorization: Bearer DEBUG_TOKEN` in constant
//! time (`SAF-DEBUG-AUTH`) and performs no side effect before that check passes; failure is `401`.
//! `REQ-DEBUG-ENDPOINTS`: the `GET` handlers are read-only, and `POST /debug/notify` reuses the
//! production outbound path (`TelegramClient::send_text`) for one test message.
//!
//! No response body ever contains a secret (`SAF-NO-SECRET-ECHO`) — only presence flags, counts
//! and probe verdicts — and `DEBUG_TOKEN` is never echoed or logged (`SAF-LOG-PURITY`). The
//! Worker safelist (`ARCH-LB-WORKER`) does not include `/debug/*`, so the public ingress cannot
//! reach this module; debug is for direct-origin remote 联调 only.
use axum::{
extract::{Query, State},
http::{HeaderMap, StatusCode},
response::{IntoResponse, Response},
routing::{get, post},
Json, Router,
};
use secrecy::ExposeSecret;
use crate::notify::{
business_client, chat_allowlist_snapshot, error_response, probe_jmap_session,
probe_telegram_get_me, refresh_business_config, worker_authorized, AppState,
};
const DEFAULT_DEBUG_MESSAGE: &str = "messageweave debug: outbound test OK";
const MAX_DEBUG_MESSAGE_CHARS: usize = 1024;
/// `POST /debug/notify` parameters, taken from the query string so the handler authenticates
/// before any payload is interpreted (`SAF-DEBUG-AUTH`).
#[derive(serde::Deserialize)]
struct DebugNotifyQuery {
chat_id: Option<i64>,
text: Option<String>,
}
/// `SAF-DEBUG-AUTH`: Bearer `DEBUG_TOKEN`, constant-time, checked before any side effect.
fn debug_authorized(app: &AppState, headers: &HeaderMap) -> bool {
match app.debug_token.as_ref() {
Some(token) => worker_authorized(headers, token.expose_secret()),
None => false,
}
}
fn unauthorized() -> Response {
error_response(StatusCode::UNAUTHORIZED, "unauthorized", false)
}
fn unavailable() -> Response {
error_response(StatusCode::SERVICE_UNAVAILABLE, "service_unavailable", true)
}
fn chat_not_allowed() -> Response {
error_response(StatusCode::FORBIDDEN, "chat_not_allowed", false)
}
fn send_failed() -> Response {
error_response(StatusCode::BAD_GATEWAY, "telegram_send_failed", false)
}
/// `SAF-DEBUG-GATE` (second layer): an empty router, so callers that never merge this
/// builder simply never expose the surface. The caller decides the merge, so the default is
/// "absolutely closed": no `/debug/*` route exists unless the dual factor fired. This is
/// belt-and-suspenders on top of `main.rs` only merging under `--debug` + `DEBUG_TOKEN`.
pub(crate) fn debug_router() -> Router<AppState> {
Router::new()
.route("/debug/ping", get(debug_ping))
.route("/debug/config", get(debug_config))
.route("/debug/redis", get(debug_redis))
.route("/debug/jmap", get(debug_jmap))
.route("/debug/telegram", get(debug_telegram))
.route("/debug/worker", get(debug_worker))
.route("/debug/notify", post(debug_notify))
}
/// Liveness of the debug surface itself. No dependency is touched.
async fn debug_ping(State(app): State<AppState>, headers: HeaderMap) -> Response {
if !debug_authorized(&app, &headers) {
return unauthorized();
}
Json(serde_json::json!({ "ok": true })).into_response()
}
/// Redacted business-config snapshot: only presence flags, counts and non-secret values.
/// `SAF-NO-SECRET-ECHO`: bot token, JMAP password, worker/webhook/reconcile tokens and the LLM
/// key are reported as `*_configured` booleans only — never their values.
async fn debug_config(State(app): State<AppState>, headers: HeaderMap) -> Response {
if !debug_authorized(&app, &headers) {
return unauthorized();
}
refresh_business_config(&app).await;
let revision = app.state.business_config_revision().await.unwrap_or(0);
let setup_missing = app.setup_missing.as_ref().clone();
let allowlist_size = chat_allowlist_snapshot(&app).len();
let outbound = app.state.get_outbound_config().await.ok();
let config = app
.business_runtime
.read()
.ok()
.and_then(|guard| guard.clone());
let body = match config {
Some(config) => serde_json::json!({
"revision": revision,
"setup_missing": setup_missing,
"business_configured": true,
"allowlist_size": allowlist_size,
"jmap": {
"session_url": config.jmap_session_url,
"username": config.jmap_username,
"account_id": config.account_id,
},
"telegram": {
"chat_id": config.telegram_chat_id,
"webhook_secret_configured": !config.telegram_webhook_secret.expose_secret().is_empty(),
},
"worker": {
"worker_token_configured": !config.worker_token.expose_secret().is_empty(),
"reconcile_token_configured": !config.reconcile_token.expose_secret().is_empty(),
},
"llm": {
"enabled": config.llm_enabled,
"allow_net": config.llm_allow_net,
"api_key_configured": config.llm_api_key.is_some(),
"base_url": config.llm_base_url,
"model": config.llm_model,
},
"outbound": outbound.map(|o| serde_json::json!({
"jmap_timeout_ms": o.jmap_timeout_ms,
"telegram_timeout_ms": o.telegram_timeout_ms,
"llm_timeout_ms": o.llm_timeout_ms,
"max_retries": o.max_retries,
})),
}),
None => serde_json::json!({
"revision": revision,
"setup_missing": setup_missing,
"business_configured": false,
"allowlist_size": allowlist_size,
}),
};
Json(body).into_response()
}
/// Redis reachability plus the persisted global enable flag.
async fn debug_redis(State(app): State<AppState>, headers: HeaderMap) -> Response {
if !debug_authorized(&app, &headers) {
return unauthorized();
}
let body = match app.state.is_enabled().await {
Ok(enabled) => serde_json::json!({ "reachable": true, "global_enabled": enabled }),
Err(_) => serde_json::json!({ "reachable": false, "detail": "redis_probe_failed" }),
};
Json(body).into_response()
}
/// JMAP reachability. Reuses `probe_jmap_session` so `/ready` and debug can never drift.
async fn debug_jmap(State(app): State<AppState>, headers: HeaderMap) -> Response {
if !debug_authorized(&app, &headers) {
return unauthorized();
}
refresh_business_config(&app).await;
let body = match probe_jmap_session(&app).await {
Ok(()) => serde_json::json!({ "ok": true }),
Err(reason) => serde_json::json!({ "ok": false, "detail": reason }),
};
Json(body).into_response()
}
/// Telegram reachability. Reuses `probe_telegram_get_me` (token only ever rides in the URL).
async fn debug_telegram(State(app): State<AppState>, headers: HeaderMap) -> Response {
if !debug_authorized(&app, &headers) {
return unauthorized();
}
refresh_business_config(&app).await;
let body = match probe_telegram_get_me(&app).await {
Ok(()) => serde_json::json!({ "ok": true }),
Err(reason) => serde_json::json!({ "ok": false, "detail": reason }),
};
Json(body).into_response()
}
/// Worker position: persisted JMAP changes cursor plus the live outbound runtime parameters.
async fn debug_worker(State(app): State<AppState>, headers: HeaderMap) -> Response {
if !debug_authorized(&app, &headers) {
return unauthorized();
}
let revision = app.state.business_config_revision().await.unwrap_or(0);
let cursor = app.state.get_reconcile_state().await.ok().flatten();
let outbound = app.state.get_outbound_config().await.ok();
let body = serde_json::json!({
"revision": revision,
"reconcile_cursor": cursor,
"outbound": outbound.map(|o| serde_json::json!({
"jmap_timeout_ms": o.jmap_timeout_ms,
"telegram_timeout_ms": o.telegram_timeout_ms,
"llm_timeout_ms": o.llm_timeout_ms,
"max_retries": o.max_retries,
})),
});
Json(body).into_response()
}
/// Send one test message through the real outbound path. The chat defaults to the configured
/// business chat; an explicit `chat_id` must be inside the effective allowlist when one is set.
async fn debug_notify(
State(app): State<AppState>,
headers: HeaderMap,
Query(query): Query<DebugNotifyQuery>,
) -> Response {
if !debug_authorized(&app, &headers) {
return unauthorized();
}
let Some(config) = app
.business_runtime
.read()
.ok()
.and_then(|guard| guard.clone())
else {
return unavailable();
};
let chat_id = query.chat_id.unwrap_or(config.telegram_chat_id);
let allowlist = chat_allowlist_snapshot(&app);
if !allowlist.is_empty() && !allowlist.contains(&chat_id) {
return chat_not_allowed();
}
let Some(client) = business_client(&app) else {
return unavailable();
};
let text: String = query
.text
.unwrap_or_else(|| DEFAULT_DEBUG_MESSAGE.to_string())
.chars()
.take(MAX_DEBUG_MESSAGE_CHARS)
.collect();
match client.send_text(chat_id, &text).await {
Ok(()) => Json(serde_json::json!({
"ok": true,
"chat_id": chat_id,
"chars": text.chars().count(),
}))
.into_response(),
Err(_) => send_failed(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::AuthSecrets;
use crate::notify::router_with_worker_state_runtime_bootstrap;
use crate::state::{runtime_provider, MemoryState, OutboundConfig};
use crate::worker::NoopWorker;
use axum::{
body::Body,
http::{Method, Request},
};
use secrecy::SecretString;
use std::collections::HashSet;
use std::sync::Arc;
use tower::ServiceExt;
const DEBUG_TOKEN: &str = "debug-token-for-tests";
/// Router built through the real dual-factor wiring: debug routes are merged only when the
/// caller passes a `debug_token` (SAF-DEBUG-GATE).
fn bootstrap_router(debug_token: Option<SecretString>) -> Router {
router_with_worker_state_runtime_bootstrap(
AuthSecrets {
reconcile_token: SecretString::new("reconcile-secret".into()),
telegram_webhook_secret: SecretString::new("telegram-secret".into()),
},
SecretString::new("worker-secret".into()),
MemoryState::enabled_for_tests(),
HashSet::new(),
Arc::new(NoopWorker),
runtime_provider(OutboundConfig::default()),
SecretString::new(String::new()),
debug_token,
)
}
async fn get(uri: &str, authorization: Option<&str>) -> (StatusCode, String) {
let router = bootstrap_router(Some(SecretString::new(DEBUG_TOKEN.to_string())));
let mut builder = Request::builder().uri(uri);
if let Some(token) = authorization {
builder = builder.header("authorization", format!("Bearer {token}"));
}
let response = router
.oneshot(builder.body(Body::empty()).unwrap())
.await
.unwrap();
let status = response.status();
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
(status, String::from_utf8_lossy(&body).to_string())
}
/// POST variant: the notification route is `POST`-only, so a GET would be a 405 rather than
/// ever reaching the authentication check.
async fn post(uri: &str, authorization: Option<&str>) -> StatusCode {
let router = bootstrap_router(Some(SecretString::new(DEBUG_TOKEN.to_string())));
let mut builder = Request::builder().method(Method::POST).uri(uri);
if let Some(token) = authorization {
builder = builder.header("authorization", format!("Bearer {token}"));
}
router
.oneshot(builder.body(Body::empty()).unwrap())
.await
.unwrap()
.status()
}
#[tokio::test]
async fn debug_ping_accepts_debug_bearer() {
let (status, body) = get("/debug/ping", Some(DEBUG_TOKEN)).await;
assert_eq!(status, StatusCode::OK);
assert!(body.contains(r#""ok":true"#));
}
#[tokio::test]
async fn debug_ping_rejects_missing_or_wrong_bearer() {
assert_eq!(get("/debug/ping", None).await.0, StatusCode::UNAUTHORIZED);
assert_eq!(
get("/debug/ping", Some("wrong")).await.0,
StatusCode::UNAUTHORIZED
);
}
#[tokio::test]
async fn debug_notify_requires_authentication() {
assert_eq!(post("/debug/notify", None).await, StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn debug_surface_absent_when_debug_token_absent() {
// SAF-DEBUG-GATE: without the DEBUG_TOKEN factor no /debug/* route is registered at all,
// so the request lands on the generic 404 instead of ever reaching a debug handler.
let request = Request::builder()
.uri("/debug/ping")
.body(Body::empty())
.unwrap();
assert_eq!(
bootstrap_router(None)
.oneshot(request)
.await
.unwrap()
.status(),
StatusCode::NOT_FOUND
);
}
}

View File

@ -1,6 +1,7 @@
mod ai; mod ai;
mod channel; mod channel;
mod config; mod config;
pub(crate) mod debug;
pub mod domain; pub mod domain;
mod error; mod error;
mod notify; mod notify;
@ -92,6 +93,21 @@ async fn main() -> Result<(), error::BotError> {
// password only gates the database connection, and passwordless managed // password only gates the database connection, and passwordless managed
// Redis would leave the admin UI permanently locked out. // Redis would leave the admin UI permanently locked out.
let admin_token = secrecy::SecretString::new(encryption_key_raw); 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") {
std::env::var("DEBUG_TOKEN")
.ok()
.filter(|value| !value.is_empty())
.map(secrecy::SecretString::new)
} else {
None
};
if debug_token.is_some() {
// SAF-LOG-PURITY: record that the surface is on, never the token value.
tracing::warn!("debug endpoints enabled for remote 联调 (SAF-DEBUG-GATE)");
}
let worker_state: std::sync::Arc<dyn state::ReliableState> = std::sync::Arc::new( let worker_state: std::sync::Arc<dyn state::ReliableState> = std::sync::Arc::new(
RedisState::connect_with_encryption(redis_url.expose_secret(), Some(encryption_key)) RedisState::connect_with_encryption(redis_url.expose_secret(), Some(encryption_key))
.await?, .await?,
@ -176,6 +192,7 @@ async fn main() -> Result<(), error::BotError> {
Arc::new(WorkerHandle::new(worker)), Arc::new(WorkerHandle::new(worker)),
runtime, runtime,
admin_token, admin_token,
debug_token,
) )
.merge(web::router()); .merge(web::router());
axum::serve(listener, app).await?; axum::serve(listener, app).await?;

View File

@ -36,19 +36,23 @@ struct AuthState {
} }
#[derive(Clone)] #[derive(Clone)]
struct AppState { pub(crate) struct AppState {
auth: AuthState, auth: AuthState,
worker_token: SecretString, worker_token: SecretString,
state: Arc<dyn ReliableState>, pub(crate) state: Arc<dyn ReliableState>,
allowlist: Arc<HashSet<i64>>, pub(crate) allowlist: Arc<HashSet<i64>>,
worker: Arc<dyn WorkerHandler>, worker: Arc<dyn WorkerHandler>,
runtime: RuntimeConfigProvider, runtime: RuntimeConfigProvider,
admin_token: SecretString, admin_token: SecretString,
business_config: Arc<RwLock<Option<serde_json::Value>>>, pub(crate) business_config: Arc<RwLock<Option<serde_json::Value>>>,
business_runtime: Arc<RwLock<Option<BusinessConfig>>>, pub(crate) business_runtime: Arc<RwLock<Option<BusinessConfig>>>,
business_revision: Arc<RwLock<u64>>, pub(crate) business_revision: Arc<RwLock<u64>>,
setup_missing: Arc<Vec<String>>, pub(crate) setup_missing: Arc<Vec<String>>,
reload: Arc<ReloadCoordinator>, reload: Arc<ReloadCoordinator>,
/// Dual-factor debug gate (`SAF-DEBUG-GATE`): `Some` only when the process was launched with
/// `--debug` AND `DEBUG_TOKEN` was set. `debug_router` is mounted iff this is `Some`, so the
/// `/debug/*` surface is absent by default. `SAF-DEBUG-AUTH`: compared in constant time.
pub(crate) debug_token: Option<SecretString>,
} }
impl From<AuthSecrets> for AuthState { impl From<AuthSecrets> for AuthState {
@ -93,7 +97,7 @@ fn cached_bot_token(app: &AppState) -> Option<secrecy::SecretString> {
/// auth): probing the raw URL unauthenticated would report not-ready forever and make ingress /// auth): probing the raw URL unauthenticated would report not-ready forever and make ingress
/// stop routing. Reusable by the remote debug surface so readiness and debugging share one /// stop routing. Reusable by the remote debug surface so readiness and debugging share one
/// implementation. /// implementation.
async fn probe_jmap_session(app: &AppState) -> Result<(), &'static str> { pub(crate) async fn probe_jmap_session(app: &AppState) -> Result<(), &'static str> {
let Some((session_url, username, password)) = cached_jmap_session(app) else { let Some((session_url, username, password)) = cached_jmap_session(app) else {
return Err("jmap session url not configured"); return Err("jmap session url not configured");
}; };
@ -115,7 +119,7 @@ async fn probe_jmap_session(app: &AppState) -> Result<(), &'static str> {
/// End-to-end Telegram probe: `getMe` must answer with a 2xx. The bot token is used to build the /// End-to-end Telegram probe: `getMe` must answer with a 2xx. The bot token is used to build the
/// request URL only; it is never logged, echoed, or returned (SAF-NO-SECRET-ECHO). /// request URL only; it is never logged, echoed, or returned (SAF-NO-SECRET-ECHO).
async fn probe_telegram_get_me(app: &AppState) -> Result<(), &'static str> { pub(crate) async fn probe_telegram_get_me(app: &AppState) -> Result<(), &'static str> {
let Some(token) = cached_bot_token(app) else { let Some(token) = cached_bot_token(app) else {
return Err("telegram bot token not configured"); return Err("telegram bot token not configured");
}; };
@ -313,7 +317,7 @@ fn reconcile_lock_owner() -> String {
bytes.iter().map(|byte| format!("{byte:02x}")).collect() bytes.iter().map(|byte| format!("{byte:02x}")).collect()
} }
fn error_response(status: StatusCode, code: &'static str, retry: bool) -> Response { pub(crate) fn error_response(status: StatusCode, code: &'static str, retry: bool) -> Response {
let request_id = reconcile_lock_owner(); let request_id = reconcile_lock_owner();
let body = axum::Json(serde_json::json!({"error": code, "request_id": request_id})); let body = axum::Json(serde_json::json!({"error": code, "request_id": request_id}));
if retry { if retry {
@ -438,7 +442,7 @@ async fn worker(State(app): State<AppState>, headers: HeaderMap, body: Bytes) ->
} }
} }
fn worker_authorized(headers: &HeaderMap, token: &str) -> bool { pub(crate) fn worker_authorized(headers: &HeaderMap, token: &str) -> bool {
headers headers
.get(header::AUTHORIZATION) .get(header::AUTHORIZATION)
.and_then(|value| value.to_str().ok()) .and_then(|value| value.to_str().ok())
@ -699,7 +703,18 @@ async fn build_worker(
/// Request-boundary refresh for multi-instance deployments. A failed remote rebuild advances the /// Request-boundary refresh for multi-instance deployments. A failed remote rebuild advances the
/// observed revision to avoid a hot retry loop while retaining the active worker/config. /// observed revision to avoid a hot retry loop while retaining the active worker/config.
async fn refresh_business_config(app: &AppState) { /// `REQ-DEBUG-ENDPOINTS`: the single place a business-configured Telegram client is built from the
/// live `business_runtime` snapshot. `refresh_business_config` and `/debug/notify` both go through
/// it, so there is never a second Telegram client implementation to drift apart.
pub(crate) fn business_client(app: &AppState) -> Option<TelegramClient> {
app.business_runtime
.read()
.ok()
.and_then(|guard| guard.clone())
.map(|config| TelegramClient::with_runtime(config.bot_token, app.runtime.clone()))
}
pub(crate) async fn refresh_business_config(app: &AppState) {
let Ok(remote_revision) = app.state.business_config_revision().await else { let Ok(remote_revision) = app.state.business_config_revision().await else {
return; return;
}; };
@ -1117,10 +1132,16 @@ fn header_value_matches(headers: &HeaderMap, name: &str, expected: &str) -> bool
.unwrap_or(false) .unwrap_or(false)
} }
fn constant_time_eq(actual: &[u8], expected: &[u8]) -> bool { pub(crate) fn constant_time_eq(actual: &[u8], expected: &[u8]) -> bool {
!expected.is_empty() && actual.len() == expected.len() && actual.ct_eq(expected).into() !expected.is_empty() && actual.len() == expected.len() && actual.ct_eq(expected).into()
} }
/// Effective chat allowlist (runtime business config if present, else the static startup
/// value). Exposed to `MOD-DEBUG` without leaking `AuthState` itself.
pub(crate) fn chat_allowlist_snapshot(app: &AppState) -> HashSet<i64> {
auth_snapshot(app).2
}
fn auth_snapshot(app: &AppState) -> (AuthState, SecretString, HashSet<i64>) { fn auth_snapshot(app: &AppState) -> (AuthState, SecretString, HashSet<i64>) {
if let Ok(snapshot) = app.business_runtime.read() { if let Ok(snapshot) = app.business_runtime.read() {
if let Some(config) = snapshot.as_ref() { if let Some(config) = snapshot.as_ref() {
@ -1178,6 +1199,7 @@ pub fn router_configuration_setup(missing: Vec<String>) -> Router {
Arc::new(NoopWorker), Arc::new(NoopWorker),
runtime_provider(OutboundConfig::default()), runtime_provider(OutboundConfig::default()),
SecretString::new(String::new()), SecretString::new(String::new()),
None,
missing, missing,
) )
} }
@ -1215,9 +1237,14 @@ pub fn router_with_worker_state_and_runtime<S: ReliableState + 'static>(
worker_handler, worker_handler,
runtime, runtime,
SecretString::new(String::new()), SecretString::new(String::new()),
None,
) )
} }
#[expect(
clippy::too_many_arguments,
reason = "router wiring keeps state dependencies explicit"
)]
pub fn router_with_worker_state_runtime_bootstrap<S: ReliableState + 'static>( pub fn router_with_worker_state_runtime_bootstrap<S: ReliableState + 'static>(
secrets: AuthSecrets, secrets: AuthSecrets,
worker_token: SecretString, worker_token: SecretString,
@ -1226,6 +1253,7 @@ pub fn router_with_worker_state_runtime_bootstrap<S: ReliableState + 'static>(
worker_handler: Arc<dyn WorkerHandler>, worker_handler: Arc<dyn WorkerHandler>,
runtime: RuntimeConfigProvider, runtime: RuntimeConfigProvider,
admin_token: SecretString, admin_token: SecretString,
debug_token: Option<SecretString>,
) -> Router { ) -> Router {
router_with_worker_state_runtime_bootstrap_config( router_with_worker_state_runtime_bootstrap_config(
secrets, secrets,
@ -1235,6 +1263,7 @@ pub fn router_with_worker_state_runtime_bootstrap<S: ReliableState + 'static>(
worker_handler, worker_handler,
runtime, runtime,
admin_token, admin_token,
debug_token,
Vec::new(), Vec::new(),
) )
} }
@ -1251,11 +1280,27 @@ fn router_with_worker_state_runtime_bootstrap_config<S: ReliableState + 'static>
worker_handler: Arc<dyn WorkerHandler>, worker_handler: Arc<dyn WorkerHandler>,
runtime: RuntimeConfigProvider, runtime: RuntimeConfigProvider,
admin_token: SecretString, admin_token: SecretString,
debug_token: Option<SecretString>,
setup_missing: Vec<String>, setup_missing: Vec<String>,
) -> Router { ) -> Router {
let worker_handle = Arc::new(WorkerHandle::new(worker_handler)); let worker_handle = Arc::new(WorkerHandle::new(worker_handler));
let reload = Arc::new(ReloadCoordinator::new(worker_handle.clone(), None)); let reload = Arc::new(ReloadCoordinator::new(worker_handle.clone(), None));
Router::new() let app_state = AppState {
auth: AuthState::from(secrets),
worker_token,
state: Arc::new(state),
allowlist: Arc::new(allowlist),
worker: worker_handle,
runtime,
admin_token,
debug_token,
business_config: Arc::new(RwLock::new(None)),
business_runtime: Arc::new(RwLock::new(None)),
business_revision: Arc::new(RwLock::new(0)),
setup_missing: Arc::new(setup_missing),
reload,
};
let mut router = Router::new()
.route("/webhook/tg", post(telegram_webhook)) .route("/webhook/tg", post(telegram_webhook))
.route("/push/jmap", post(jmap_push)) .route("/push/jmap", post(jmap_push))
.route("/api/push/register", post(register_push)) .route("/api/push/register", post(register_push))
@ -1270,21 +1315,12 @@ fn router_with_worker_state_runtime_bootstrap_config<S: ReliableState + 'static>
.route("/api/admin/session", post(create_admin_session)) .route("/api/admin/session", post(create_admin_session))
.route("/healthz", get(healthz)) .route("/healthz", get(healthz))
.route("/ready", get(ready)) .route("/ready", get(ready))
.route("/api/status", get(setup_status)) .route("/api/status", get(setup_status));
.with_state(AppState { // SAF-DEBUG-GATE: /debug/* only exists when main.rs passed a real DEBUG_TOKEN under --debug.
auth: AuthState::from(secrets), if app_state.debug_token.is_some() {
worker_token, router = router.merge(crate::debug::debug_router());
state: Arc::new(state), }
allowlist: Arc::new(allowlist), router.with_state(app_state)
worker: worker_handle,
runtime,
admin_token,
business_config: Arc::new(RwLock::new(None)),
business_runtime: Arc::new(RwLock::new(None)),
business_revision: Arc::new(RwLock::new(0)),
setup_missing: Arc::new(setup_missing),
reload,
})
} }
#[cfg(test)] #[cfg(test)]
@ -1434,6 +1470,7 @@ mod tests {
Arc::new(NoopWorker), Arc::new(NoopWorker),
runtime_provider(OutboundConfig::default()), runtime_provider(OutboundConfig::default()),
SecretString::new("acl-root".into()), SecretString::new("acl-root".into()),
None,
); );
// 本用例验证的是"失败的 bootstrap 不消耗一次性初始化槽位",失败原因应是 // 本用例验证的是"失败的 bootstrap 不消耗一次性初始化槽位",失败原因应是
// JMAP 不可达(127.0.0.1:1 → 503),因此 allowlist 必须合法,避免被 // JMAP 不可达(127.0.0.1:1 → 503),因此 allowlist 必须合法,避免被