feat: provide configuration setup mode

This commit is contained in:
anonsaber 2026-09-25 09:30:45 +08:00
parent fc43c6bf93
commit e5ec3aef4c
5 changed files with 109 additions and 13 deletions

View File

@ -49,7 +49,7 @@
AI 摘要默认关闭。Telegram 用户可发送 `/ai on`(1小时)、`临时一次`、`1小时`、`今天`、`7天` 或 `直到我撤销` 明确授权,再发送 `/summary <email_id>`;授权到期不会自动续期,`/ai off` 立即撤销。未授权时仅返回邮件元数据,LLM 失败则回退为本地截断摘要。 AI 摘要默认关闭。Telegram 用户可发送 `/ai on`(1小时)、`临时一次`、`1小时`、`今天`、`7天` 或 `直到我撤销` 明确授权,再发送 `/summary <email_id>`;授权到期不会自动续期,`/ai off` 立即撤销。未授权时仅返回邮件元数据,LLM 失败则回退为本地截断摘要。
> Redis 未初始化时服务进入 configuration-only 模式,仅提供 SPA、bootstrap 和探针。 > 缺少 `REDIS_URL` 或 `CONFIG_ENCRYPTION_KEY` 时服务进入 configuration-setup 模式,仍提供 SPA、`/api/status` 和探针;状态页会提示缺少的环境变量,不回显任何密钥,也不会伪造 Redis 持久化成功。
--- ---

View File

@ -105,7 +105,7 @@
AI 授权期限由用户选择(临时一次、1小时、今天、7天或直到撤销),Redis 仅保存 chat id、授权状态和带 TTL 的到期时间;不会保存正文或摘要。到期后摘要请求回到元数据模式并提示重新授权。 AI 授权期限由用户选择(临时一次、1小时、今天、7天或直到撤销),Redis 仅保存 chat id、授权状态和带 TTL 的到期时间;不会保存正文或摘要。到期后摘要请求回到元数据模式并提示重新授权。
| `ACCOUNT_ID` | 默认空 | **单账户**(`REQ-SINGLE-ACCOUNT`):留空则取 session 主账户;多账户 = 部署多个 bot 实例(各自独立 token/配置),不做多账户单实例 | | `ACCOUNT_ID` | 默认空 | **单账户**(`REQ-SINGLE-ACCOUNT`):留空则取 session 主账户;多账户 = 部署多个 bot 实例(各自独立 token/配置),不做多账户单实例 |
> **三个入口鉴权变量均为必填(fail-closed,`SAF-AUTH-*`)**:`RECONCILE_TOKEN` / `TG_WEBHOOK_SECRET` / `JMAP_PUSH_VERIFICATION` 缺失即**启动失败**(`Config::from_env()` 报缺失),不存在"未配置则放行"的降级路径。三个入口分别校验:`/reconcile`(Bearer 头)、`/webhook/tg`(secret 头)、`/push/jmap`(Body `verificationCode`);校验失败一律返回 `401`,且在鉴权通过前**不产生任何副作用**。`/healthz` 与 `/ready` 为公开探针,不含敏感信息。 > 业务鉴权变量由 Redis `config:business` 管理,不再要求作为启动环境变量。缺少 `REDIS_URL` 或非法/缺失 `CONFIG_ENCRYPTION_KEY` 时服务仍监听并提供 SPA、`/api/status` 与探针,状态为 `configuration-setup`,不会伪造持久化成功;配置恢复后再启用业务入口鉴权。
> 示例占位见仓库根目录 [`.env.example`](../.env.example)(仅占位符,**严禁**放入真实密钥)。 > 示例占位见仓库根目录 [`.env.example`](../.env.example)(仅占位符,**严禁**放入真实密钥)。
> **配置管理页面**:服务根路径 `/` 提供嵌入 Rust 二进制的 SPA。输入 Redis URL 的 ACL 密码后,`POST /api/admin/session` 签发 900 秒 admin session;页面只在内存中保存 opaque session。运行参数通过 `/api/config` 读取和保存;完整业务配置通过 `PUT /api/business-config` 替换并热加载。业务配置 API 不提供 GET,密钥不会回显;每次完整替换都需重新输入必填密钥。配置保存在外部 Redis(`C-REDIS-ONLY-STATE`),静态资源编译时随二进制打包,无运行期本地文件。 > **配置管理页面**:服务根路径 `/` 提供嵌入 Rust 二进制的 SPA。输入 Redis URL 的 ACL 密码后,`POST /api/admin/session` 签发 900 秒 admin session;页面只在内存中保存 opaque session。运行参数通过 `/api/config` 读取和保存;完整业务配置通过 `PUT /api/business-config` 替换并热加载。业务配置 API 不提供 GET,密钥不会回显;每次完整替换都需重新输入必填密钥。配置保存在外部 Redis(`C-REDIS-ONLY-STATE`),静态资源编译时随二进制打包,无运行期本地文件。

View File

@ -15,7 +15,8 @@ use crate::channel::telegram::TelegramClient;
use crate::domain::jmap::{client::JmapClientBackend, JmapService}; use crate::domain::jmap::{client::JmapClientBackend, JmapService};
use config::{encryption_key_from_env, Config}; use config::{encryption_key_from_env, Config};
use notify::{ use notify::{
router_with_worker_state_runtime_bootstrap, MetadataWorker, WorkerHandle, WorkerHandler, router_configuration_setup, router_with_worker_state_runtime_bootstrap, MetadataWorker,
WorkerHandle, WorkerHandler,
}; };
use state::{runtime_provider, RedisState}; use state::{runtime_provider, RedisState};
use std::sync::Arc; use std::sync::Arc;
@ -23,17 +24,41 @@ use std::sync::Arc;
#[tokio::main] #[tokio::main]
async fn main() -> Result<(), error::BotError> { async fn main() -> Result<(), error::BotError> {
tracing_subscriber::fmt().with_env_filter("info").init(); tracing_subscriber::fmt().with_env_filter("info").init();
let port = std::env::var("PORT")
.ok()
.and_then(|value| value.parse().ok())
.unwrap_or(8080_u16);
let addr: SocketAddr = format!("0.0.0.0:{port}")
.parse()
.map_err(|e| error::BotError::Config(format!("invalid PORT: {e}")))?;
let listener = tokio::net::TcpListener::bind(addr).await?;
let redis_value = match std::env::var("REDIS_URL") {
Ok(value) if !value.trim().is_empty() => value,
_ => {
tracing::warn!("REDIS_URL is not configured; serving configuration setup mode");
let app = router_configuration_setup(vec!["REDIS_URL".into()]).merge(web::router());
axum::serve(listener, app).await?;
return Ok(());
}
};
let encryption_key = match encryption_key_from_env() {
Ok(key) => key,
Err(_) => {
tracing::warn!(
"CONFIG_ENCRYPTION_KEY is not configured or invalid; serving setup mode"
);
let app = router_configuration_setup(vec!["CONFIG_ENCRYPTION_KEY".into()])
.merge(web::router());
axum::serve(listener, app).await?;
return Ok(());
}
};
// The Redis URL is the only bootstrap credential in the migration path. Until // The Redis URL is the only bootstrap credential in the migration path. Until
// the bootstrap route is wired, retain the legacy parser solely to obtain it. // the bootstrap route is wired, retain the legacy parser solely to obtain it.
let bootstrap_config = match Config::from_env() { let bootstrap_config = match Config::from_env() {
Ok(config) => config, Ok(config) => config,
Err(_) => { Err(_) => Config::redis_only(secrecy::SecretString::new(redis_value.clone())),
let redis_url = std::env::var("REDIS_URL")
.map_err(|_| error::BotError::Config("missing REDIS_URL".into()))?;
Config::redis_only(secrecy::SecretString::new(redis_url))
}
}; };
let encryption_key = encryption_key_from_env()?;
let redis_url = bootstrap_config.redis_url.clone(); let redis_url = bootstrap_config.redis_url.clone();
// RUN_MODE is intentionally explicit even while both modes share the stage-0 // RUN_MODE is intentionally explicit even while both modes share the stage-0
// no-op router; later stages attach webhook/reconcile side effects here. // no-op router; later stages attach webhook/reconcile side effects here.
@ -46,14 +71,10 @@ async fn main() -> Result<(), error::BotError> {
))) )))
} }
} }
let addr: SocketAddr = format!("0.0.0.0:{}", bootstrap_config.port)
.parse()
.map_err(|e| error::BotError::Config(format!("invalid PORT: {e}")))?;
tracing::info!( tracing::info!(
port = bootstrap_config.port, port = bootstrap_config.port,
"starting webhook HTTP entrypoint" "starting webhook HTTP entrypoint"
); );
let listener = tokio::net::TcpListener::bind(addr).await?;
let redis = let redis =
RedisState::connect_with_encryption(redis_url.expose_secret(), Some(encryption_key)) RedisState::connect_with_encryption(redis_url.expose_secret(), Some(encryption_key))
.await?; .await?;

View File

@ -20,6 +20,7 @@ use crate::config::{
session_digest, validate_business_wire, AuthSecrets, BusinessConfig, BusinessConfigWire, session_digest, validate_business_wire, AuthSecrets, BusinessConfig, BusinessConfigWire,
}; };
use crate::domain::jmap::{client::JmapClientBackend, JmapService}; use crate::domain::jmap::{client::JmapClientBackend, JmapService};
use crate::state::MemoryState;
use crate::state::{runtime_provider, OutboundConfig, ReliableState, RuntimeConfigProvider}; use crate::state::{runtime_provider, OutboundConfig, ReliableState, RuntimeConfigProvider};
pub use crate::worker::{ pub use crate::worker::{
MetadataWorker, NoopWorker, ReloadCoordinator, WorkerHandle, WorkerHandler, MetadataWorker, NoopWorker, ReloadCoordinator, WorkerHandle, WorkerHandler,
@ -44,6 +45,7 @@ struct AppState {
business_config: Arc<RwLock<Option<serde_json::Value>>>, business_config: Arc<RwLock<Option<serde_json::Value>>>,
business_runtime: Arc<RwLock<Option<BusinessConfig>>>, business_runtime: Arc<RwLock<Option<BusinessConfig>>>,
business_revision: Arc<RwLock<u64>>, business_revision: Arc<RwLock<u64>>,
setup_missing: Arc<Vec<String>>,
reload: Arc<ReloadCoordinator>, reload: Arc<ReloadCoordinator>,
} }
@ -65,6 +67,16 @@ async fn ready() -> impl IntoResponse {
(StatusCode::OK, "ready") (StatusCode::OK, "ready")
} }
async fn setup_status(State(app): State<AppState>) -> Response {
let ready = app.setup_missing.is_empty();
axum::Json(serde_json::json!({
"ready": ready,
"mode": if ready { "configured" } else { "configuration-setup" },
"missing": app.setup_missing.as_ref(),
}))
.into_response()
}
async fn telegram_webhook( async fn telegram_webhook(
State(app): State<AppState>, State(app): State<AppState>,
headers: HeaderMap, headers: HeaderMap,
@ -619,6 +631,26 @@ pub fn router_with_state<S: ReliableState + 'static>(
) )
} }
/// Configuration-only listener used when bootstrap credentials are absent. MemoryState is
/// deliberately not advertised as persistence: all business writes remain unavailable until
/// Redis is configured.
pub fn router_configuration_setup(missing: Vec<String>) -> Router {
router_with_worker_state_runtime_bootstrap_config(
AuthSecrets {
reconcile_token: SecretString::new(String::new()),
telegram_webhook_secret: SecretString::new(String::new()),
jmap_push_verification: SecretString::new(String::new()),
},
SecretString::new(String::new()),
MemoryState::default(),
HashSet::new(),
Arc::new(NoopWorker),
runtime_provider(OutboundConfig::default()),
SecretString::new(String::new()),
missing,
)
}
pub fn router_with_worker_state<S: ReliableState + 'static>( pub fn router_with_worker_state<S: ReliableState + 'static>(
secrets: AuthSecrets, secrets: AuthSecrets,
worker_token: SecretString, worker_token: SecretString,
@ -672,9 +704,14 @@ pub fn router_with_worker_state_runtime_bootstrap<S: ReliableState + 'static>(
worker_handler, worker_handler,
runtime, runtime,
bootstrap_token, bootstrap_token,
Vec::new(),
) )
} }
#[expect(
clippy::too_many_arguments,
reason = "router wiring keeps state dependencies explicit"
)]
fn router_with_worker_state_runtime_bootstrap_config<S: ReliableState + 'static>( fn router_with_worker_state_runtime_bootstrap_config<S: ReliableState + 'static>(
secrets: AuthSecrets, secrets: AuthSecrets,
worker_token: SecretString, worker_token: SecretString,
@ -683,6 +720,7 @@ 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,
bootstrap_token: SecretString, bootstrap_token: SecretString,
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));
@ -698,6 +736,7 @@ 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))
.with_state(AppState { .with_state(AppState {
auth: AuthState::from(secrets), auth: AuthState::from(secrets),
worker_token, worker_token,
@ -709,6 +748,7 @@ fn router_with_worker_state_runtime_bootstrap_config<S: ReliableState + 'static>
business_config: Arc::new(RwLock::new(None)), business_config: Arc::new(RwLock::new(None)),
business_runtime: Arc::new(RwLock::new(None)), business_runtime: Arc::new(RwLock::new(None)),
business_revision: Arc::new(RwLock::new(0)), business_revision: Arc::new(RwLock::new(0)),
setup_missing: Arc::new(setup_missing),
reload, reload,
}) })
} }
@ -804,6 +844,28 @@ mod tests {
); );
} }
#[tokio::test]
async fn configuration_setup_status_lists_only_missing_names() {
let response = router_configuration_setup(vec!["REDIS_URL".into()])
.oneshot(
Request::builder()
.method("GET")
.uri("/api/status")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let value: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(value["mode"], "configuration-setup");
assert_eq!(value["missing"][0], "REDIS_URL");
assert!(!body.windows(4).any(|part| part == b"pass"));
}
#[tokio::test] #[tokio::test]
async fn failed_bootstrap_does_not_consume_initialization_slot() { async fn failed_bootstrap_does_not_consume_initialization_slot() {
let app = router_with_worker_state_runtime_bootstrap( let app = router_with_worker_state_runtime_bootstrap(

View File

@ -121,6 +121,18 @@
return parseResponseText(responseText, response.status); return parseResponseText(responseText, response.status);
} }
async function showSetupStatus() {
try {
const status = await request("/api/status", "GET", undefined, "");
if (status && status.ready === false) {
const missing = Array.isArray(status.missing) ? status.missing.join("、") : "启动配置";
showNotice("error", `服务处于配置引导模式。请在部署环境设置:${missing};密钥不会在页面保存或回显。`);
}
} catch {
// The regular connection flow reports unavailable services without exposing details.
}
}
function showConfigView() { function showConfigView() {
authCard.hidden = true; authCard.hidden = true;
configPanel.hidden = false; configPanel.hidden = false;
@ -568,6 +580,7 @@
}); });
showRuntimePreview(); showRuntimePreview();
showSetupStatus();
updateLLMRequirements(); updateLLMRequirements();
updateButtons(); updateButtons();
})(); })();