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:
parent
800d6e0d00
commit
afa6cec8ef
|
|
@ -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 | 禁止本地文件/目录写入(日志/数据/临时缓存/本地卷) | 约束 |
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
17
src/main.rs
17
src/main.rs
|
|
@ -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?;
|
||||||
|
|
|
||||||
|
|
@ -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 必须合法,避免被
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue