diff --git a/AGENTS.md b/AGENTS.md index f1fe7d6..083c7df 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -50,6 +50,7 @@ - **日志与 Redis 写入内容约束**(`SAF-LOG-PURITY`):仅限结构化事件、计数、时间戳、脱敏后的请求摘要;**禁止**写入密钥原文、JMAP 邮件正文、AI 请求/响应内容、附件内容。 - **禁止依赖进程内状态做生产恢复**(`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 由外部提供);任一失败禁止发布。 +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 代码规模(软性指导,非硬限制) - 单个 `.rs` 文件原则上不超过 **500 行**;这是**软性指导**而非硬性门禁。 @@ -132,6 +133,7 @@ - 正文转义、sinceState 存 Redis + 模拟 Redis 删除后由对账 `FLOW-RECONCILE` 恢复、通知去重。 - **入口鉴权(`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 直接调用,**不要另写一套**;两者响应体均不含敏感信息。 + - **远程 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` 前不得声称"真机通过"。** - 领域/渠道解耦测试:领域模块(`src/domain.rs` / `src/ai.rs` / `src/worker.rs`)的公开接口不出现任何渠道 SDK 类型(编译期检查);`JmapBackend` 的 `MockBackend` 可驱动全部 JMAP 领域流程(不联网)。 - 文件行数抽查:>500 行的 `.rs` 文件在 PR 说明中可见(软性,不自动 fail)。 @@ -247,6 +249,10 @@ | `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 / 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 单点故障不在方案范围(用户外部解决) | 非功能 | | `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 | 禁止本地文件/目录写入(日志/数据/临时缓存/本地卷) | 约束 | diff --git a/src/debug.rs b/src/debug.rs new file mode 100644 index 0000000..9b5b7d1 --- /dev/null +++ b/src/debug.rs @@ -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, + text: Option, +} + +/// `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 { + 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, 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, 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, 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, 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, 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, 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, + headers: HeaderMap, + Query(query): Query, +) -> 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) -> 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 + ); + } +} diff --git a/src/main.rs b/src/main.rs index 73323b9..beede81 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,6 +1,7 @@ mod ai; mod channel; mod config; +pub(crate) mod debug; pub mod domain; mod error; mod notify; @@ -92,6 +93,21 @@ async fn main() -> Result<(), error::BotError> { // password only gates the database connection, and passwordless managed // 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") { + 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 = std::sync::Arc::new( RedisState::connect_with_encryption(redis_url.expose_secret(), Some(encryption_key)) .await?, @@ -176,6 +192,7 @@ async fn main() -> Result<(), error::BotError> { Arc::new(WorkerHandle::new(worker)), runtime, admin_token, + debug_token, ) .merge(web::router()); axum::serve(listener, app).await?; diff --git a/src/notify.rs b/src/notify.rs index 56b5dea..63edb8f 100644 --- a/src/notify.rs +++ b/src/notify.rs @@ -36,19 +36,23 @@ struct AuthState { } #[derive(Clone)] -struct AppState { +pub(crate) struct AppState { auth: AuthState, worker_token: SecretString, - state: Arc, - allowlist: Arc>, + pub(crate) state: Arc, + pub(crate) allowlist: Arc>, worker: Arc, runtime: RuntimeConfigProvider, admin_token: SecretString, - business_config: Arc>>, - business_runtime: Arc>>, - business_revision: Arc>, - setup_missing: Arc>, + pub(crate) business_config: Arc>>, + pub(crate) business_runtime: Arc>>, + pub(crate) business_revision: Arc>, + pub(crate) setup_missing: Arc>, reload: Arc, + /// 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, } impl From for AuthState { @@ -93,7 +97,7 @@ fn cached_bot_token(app: &AppState) -> Option { /// 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 /// 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 { 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 /// 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 { return Err("telegram bot token not configured"); }; @@ -313,7 +317,7 @@ fn reconcile_lock_owner() -> String { 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 body = axum::Json(serde_json::json!({"error": code, "request_id": request_id})); if retry { @@ -438,7 +442,7 @@ async fn worker(State(app): State, headers: HeaderMap, body: Bytes) -> } } -fn worker_authorized(headers: &HeaderMap, token: &str) -> bool { +pub(crate) fn worker_authorized(headers: &HeaderMap, token: &str) -> bool { headers .get(header::AUTHORIZATION) .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 /// 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 { + 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 { return; }; @@ -1117,10 +1132,16 @@ fn header_value_matches(headers: &HeaderMap, name: &str, expected: &str) -> bool .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() } +/// 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 { + auth_snapshot(app).2 +} + fn auth_snapshot(app: &AppState) -> (AuthState, SecretString, HashSet) { if let Ok(snapshot) = app.business_runtime.read() { if let Some(config) = snapshot.as_ref() { @@ -1178,6 +1199,7 @@ pub fn router_configuration_setup(missing: Vec) -> Router { Arc::new(NoopWorker), runtime_provider(OutboundConfig::default()), SecretString::new(String::new()), + None, missing, ) } @@ -1215,9 +1237,14 @@ pub fn router_with_worker_state_and_runtime( worker_handler, runtime, 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( secrets: AuthSecrets, worker_token: SecretString, @@ -1226,6 +1253,7 @@ pub fn router_with_worker_state_runtime_bootstrap( worker_handler: Arc, runtime: RuntimeConfigProvider, admin_token: SecretString, + debug_token: Option, ) -> Router { router_with_worker_state_runtime_bootstrap_config( secrets, @@ -1235,6 +1263,7 @@ pub fn router_with_worker_state_runtime_bootstrap( worker_handler, runtime, admin_token, + debug_token, Vec::new(), ) } @@ -1251,11 +1280,27 @@ fn router_with_worker_state_runtime_bootstrap_config worker_handler: Arc, runtime: RuntimeConfigProvider, admin_token: SecretString, + debug_token: Option, setup_missing: Vec, ) -> Router { let worker_handle = Arc::new(WorkerHandle::new(worker_handler)); 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("/push/jmap", post(jmap_push)) .route("/api/push/register", post(register_push)) @@ -1270,21 +1315,12 @@ fn router_with_worker_state_runtime_bootstrap_config .route("/api/admin/session", post(create_admin_session)) .route("/healthz", get(healthz)) .route("/ready", get(ready)) - .route("/api/status", get(setup_status)) - .with_state(AppState { - auth: AuthState::from(secrets), - worker_token, - state: Arc::new(state), - allowlist: Arc::new(allowlist), - 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, - }) + .route("/api/status", get(setup_status)); + // SAF-DEBUG-GATE: /debug/* only exists when main.rs passed a real DEBUG_TOKEN under --debug. + if app_state.debug_token.is_some() { + router = router.merge(crate::debug::debug_router()); + } + router.with_state(app_state) } #[cfg(test)] @@ -1434,6 +1470,7 @@ mod tests { Arc::new(NoopWorker), runtime_provider(OutboundConfig::default()), SecretString::new("acl-root".into()), + None, ); // 本用例验证的是"失败的 bootstrap 不消耗一次性初始化槽位",失败原因应是 // JMAP 不可达(127.0.0.1:1 → 503),因此 allowlist 必须合法,避免被