//! `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 ); } }