diff --git a/AGENTS.md b/AGENTS.md index 70a3a2c..eca380c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -112,7 +112,7 @@ oauth_accounts ( - 错误:业务层 `anyhow::Result`,避免生产路径 `unwrap()`。 - 异步:`tokio` + `sqlx` async。 - SQL:`sqlx::query` / `query_as` 参数绑定;动态 WHERE 仍须用占位符绑定。 -- 日志:运维用 `tracing`;面向用户的 Web 响应走 axum handler。 +- 日志:运维用 `tracing`;面向用户的 Web 响应走 axum handler。tracing 字段风格:变量名即字段名时用简写(`%var`、`?var`、`var`),否则用显式形式(`field = %expr`)。 - 审计:写操作成功后尽量 `audit::log_tx`;失败可 `warn`,不掩盖主错误。 - 加密:密钥由用户密码短语通过 **PBKDF2-SHA256(600k 次)** 在客户端派生,服务端只存 `key_salt`/`key_check`/`key_params`,不持有原始密钥。Web 客户端在浏览器本地完成加解密;MCP 客户端通过 `X-Encryption-Key` 请求头传递密钥,服务端临时解密后返回明文。 - MCP:tools 参数与 JSON Schema(`schemars`)保持同步,鉴权以请求扩展中的用户上下文为准。 @@ -154,7 +154,7 @@ git tag -l 'secrets-mcp-*' |------|------| | `SECRETS_DATABASE_URL` | **必填**。PostgreSQL URL。 | | `BASE_URL` | 对外基址;OAuth 回调 `${BASE_URL}/auth/google/callback`。 | -| `SECRETS_MCP_BIND` | 监听地址,默认 `0.0.0.0:9315`。 | +| `SECRETS_MCP_BIND` | 监听地址,默认 `127.0.0.1:9315`(容器/远程直接暴露时需改为 `0.0.0.0:9315`)。 | | `GOOGLE_CLIENT_ID` / `GOOGLE_CLIENT_SECRET` | 可选;仅运行时配置。 | | `RUST_LOG` | 如 `secrets_mcp=debug`。 | diff --git a/Cargo.lock b/Cargo.lock index 8eadcc3..a201b32 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1809,6 +1809,25 @@ dependencies = [ "syn", ] +[[package]] +name = "rmp" +version = "0.8.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ba8be72d372b2c9b35542551678538b562e7cf86c3315773cae48dfbfe7790c" +dependencies = [ + "num-traits", +] + +[[package]] +name = "rmp-serde" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f81bee8c8ef9b577d1681a70ebbc962c232461e397b22c208c43c04b67a155" +dependencies = [ + "rmp", + "serde", +] + [[package]] name = "rsa" version = "0.9.10" @@ -1949,7 +1968,7 @@ dependencies = [ [[package]] name = "secrets-mcp" -version = "0.1.11" +version = "0.2.0" dependencies = [ "anyhow", "askama", @@ -1967,10 +1986,12 @@ dependencies = [ "serde_json", "sha2", "sqlx", + "time", "tokio", "tower", "tower-http", "tower-sessions", + "tower-sessions-sqlx-store-chrono", "tracing", "tracing-subscriber", "urlencoding", @@ -2766,6 +2787,22 @@ dependencies = [ "tower-sessions-core", ] +[[package]] +name = "tower-sessions-sqlx-store-chrono" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b295c8fc08db03246e92773c5e10119b72db6bc4240112135bebb0e49670804f" +dependencies = [ + "async-trait", + "axum", + "chrono", + "rmp-serde", + "sqlx", + "thiserror", + "time", + "tower-sessions-core", +] + [[package]] name = "tracing" version = "0.1.44" diff --git a/README.md b/README.md index 254a897..3c9b27c 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ cargo build --release -p secrets-mcp |------|------| | `SECRETS_DATABASE_URL` | **必填**。PostgreSQL 连接串(建议专用库,如 `secrets-mcp`)。 | | `BASE_URL` | 对外访问基址;OAuth 回调为 `{BASE_URL}/auth/google/callback`。默认 `http://localhost:9315`。 | -| `SECRETS_MCP_BIND` | 监听地址,默认 `0.0.0.0:9315`。反代时常为 `127.0.0.1:9315`。 | +| `SECRETS_MCP_BIND` | 监听地址,默认 `127.0.0.1:9315`。容器内或直接对外暴露端口时请改为 `0.0.0.0:9315`;反代时常为 `127.0.0.1:9315`。 | | `GOOGLE_CLIENT_ID` / `GOOGLE_CLIENT_SECRET` | 可选;不配置则无 Google 登录入口。运行时从环境读取,勿写入 CI、勿打入二进制。 | ```bash diff --git a/crates/secrets-mcp/Cargo.toml b/crates/secrets-mcp/Cargo.toml index 0fec568..b9ef236 100644 --- a/crates/secrets-mcp/Cargo.toml +++ b/crates/secrets-mcp/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "secrets-mcp" -version = "0.1.11" +version = "0.2.0" edition.workspace = true [[bin]] @@ -19,6 +19,8 @@ axum-extra = { version = "0.10", features = ["typed-header"] } tower = "0.5" tower-http = { version = "0.6", features = ["cors", "trace"] } tower-sessions = "0.14" +tower-sessions-sqlx-store-chrono = { version = "0.14", features = ["postgres"] } +time = "0.3" # OAuth (manual token exchange via reqwest) reqwest.workspace = true diff --git a/crates/secrets-mcp/src/main.rs b/crates/secrets-mcp/src/main.rs index 6fc3475..9c520b2 100644 --- a/crates/secrets-mcp/src/main.rs +++ b/crates/secrets-mcp/src/main.rs @@ -15,8 +15,11 @@ use rmcp::transport::streamable_http_server::{ use sqlx::PgPool; use tower_http::cors::{Any, CorsLayer}; use tower_sessions::cookie::SameSite; -use tower_sessions::{MemoryStore, SessionManagerLayer}; +use tower_sessions::session_store::ExpiredDeletion; +use tower_sessions::{Expiry, SessionManagerLayer}; +use tower_sessions_sqlx_store_chrono::PostgresStore; use tracing_subscriber::EnvFilter; +use tracing_subscriber::fmt::time::FormatTime; use secrets_core::config::resolve_db_url; use secrets_core::db::{create_pool, migrate}; @@ -47,12 +50,27 @@ fn load_oauth_config(prefix: &str, base_url: &str, path: &str) -> Option) -> std::fmt::Result { + write!( + w, + "{}", + chrono::Local::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, false) + ) + } +} + #[tokio::main] async fn main() -> Result<()> { // Load .env if present let _ = dotenvy::dotenv(); tracing_subscriber::fmt() + .with_timer(LocalRfc3339Time) .with_env_filter( EnvFilter::try_from_default_env() .unwrap_or_else(|_| "secrets_mcp=info,tower_http=info".into()), @@ -72,7 +90,8 @@ async fn main() -> Result<()> { // ── Configuration ───────────────────────────────────────────────────────── let base_url = load_env_var("BASE_URL").unwrap_or_else(|| "http://localhost:9315".to_string()); - let bind_addr = load_env_var("SECRETS_MCP_BIND").unwrap_or_else(|| "0.0.0.0:9315".to_string()); + let bind_addr = + load_env_var("SECRETS_MCP_BIND").unwrap_or_else(|| "127.0.0.1:9315".to_string()); // ── OAuth providers ─────────────────────────────────────────────────────── let google_config = load_oauth_config("GOOGLE", &base_url, "/auth/google/callback"); @@ -83,12 +102,23 @@ async fn main() -> Result<()> { ); } - // ── Session store ───────────────────────────────────────────────────────── - let session_store = MemoryStore::default(); + // ── Session store (PostgreSQL-backed) ───────────────────────────────────── + let session_store = PostgresStore::new(pool.clone()); + session_store + .migrate() + .await + .context("failed to run session table migration")?; + // Prune expired rows every hour; task is aborted when the server shuts down. + let session_cleanup = tokio::spawn( + session_store + .clone() + .continuously_delete_expired(tokio::time::Duration::from_secs(3600)), + ); // Strict would drop the session cookie on redirect from Google → our origin (cross-site nav). let session_layer = SessionManagerLayer::new(session_store) .with_secure(base_url.starts_with("https://")) - .with_same_site(SameSite::Lax); + .with_same_site(SameSite::Lax) + .with_expiry(Expiry::OnInactivity(time::Duration::days(14))); // ── App state ───────────────────────────────────────────────────────────── let app_state = AppState { @@ -149,6 +179,7 @@ async fn main() -> Result<()> { .await .context("server error")?; + session_cleanup.abort(); Ok(()) } diff --git a/crates/secrets-mcp/src/tools.rs b/crates/secrets-mcp/src/tools.rs index 856a62b..767b9dc 100644 --- a/crates/secrets-mcp/src/tools.rs +++ b/crates/secrets-mcp/src/tools.rs @@ -17,6 +17,7 @@ use serde::Deserialize; use sqlx::PgPool; use uuid::Uuid; +use secrets_core::models::ExportFormat; use secrets_core::service::{ add::{AddParams, run as svc_add}, delete::{DeleteParams, run as svc_delete}, @@ -30,6 +31,32 @@ use secrets_core::service::{ use crate::auth::AuthUser; +// ── MCP client-facing errors (no internal details) ─────────────────────────── + +fn mcp_err_missing_http_parts() -> rmcp::ErrorData { + rmcp::ErrorData::internal_error("Invalid MCP request context.", None) +} + +fn mcp_err_internal_logged( + tool: &'static str, + user_id: Option, + err: impl std::fmt::Display, +) -> rmcp::ErrorData { + tracing::warn!(tool, ?user_id, error = %err, "tool call failed"); + rmcp::ErrorData::internal_error( + "Request failed due to a server error. Check service logs if you need details.", + None, + ) +} + +fn mcp_err_invalid_encryption_key_logged(err: impl std::fmt::Display) -> rmcp::ErrorData { + tracing::warn!(error = %err, "invalid X-Encryption-Key"); + rmcp::ErrorData::invalid_request( + "Invalid X-Encryption-Key: must be exactly 64 hexadecimal characters (32-byte key).", + None, + ) +} + // ── Shared state ────────────────────────────────────────────────────────────── #[derive(Clone)] @@ -51,7 +78,7 @@ impl SecretsService { let parts = ctx .extensions .get::() - .ok_or_else(|| rmcp::ErrorData::internal_error("Missing HTTP parts", None))?; + .ok_or_else(mcp_err_missing_http_parts)?; Ok(parts.extensions.get::().map(|a| a.user_id)) } @@ -60,7 +87,7 @@ impl SecretsService { let parts = ctx .extensions .get::() - .ok_or_else(|| rmcp::ErrorData::internal_error("Missing HTTP parts", None))?; + .ok_or_else(mcp_err_missing_http_parts)?; parts .extensions .get::() @@ -74,7 +101,7 @@ impl SecretsService { let parts = ctx .extensions .get::() - .ok_or_else(|| rmcp::ErrorData::internal_error("Missing HTTP parts", None))?; + .ok_or_else(mcp_err_missing_http_parts)?; let hex_str = parts .headers .get("x-encryption-key") @@ -89,8 +116,29 @@ impl SecretsService { .map_err(|_| { rmcp::ErrorData::invalid_request("Invalid X-Encryption-Key header value", None) })?; + let trimmed = hex_str.trim(); + if trimmed.len() != 64 { + tracing::warn!( + got_len = trimmed.len(), + "X-Encryption-Key has wrong length after trim" + ); + return Err(rmcp::ErrorData::invalid_request( + format!( + "X-Encryption-Key must be exactly 64 hex characters (32-byte key), got {} characters.", + trimmed.len() + ), + None, + )); + } + if !trimmed.chars().all(|c| c.is_ascii_hexdigit()) { + tracing::warn!("X-Encryption-Key contains non-hexadecimal characters"); + return Err(rmcp::ErrorData::invalid_request( + "X-Encryption-Key contains non-hexadecimal characters.", + None, + )); + } secrets_core::crypto::extract_key_from_hex(hex_str) - .map_err(|e| rmcp::ErrorData::invalid_request(e.to_string(), None)) + .map_err(mcp_err_invalid_encryption_key_logged) } /// Require both user_id and encryption key. @@ -251,7 +299,12 @@ struct EnvMapInput { impl SecretsService { #[tool( description = "Search entries in the secrets store. Returns entries with metadata and \ - secret field names (not values). Use secrets_get to decrypt secret values." + secret field names (not values). Use secrets_get to decrypt secret values.", + annotations( + title = "Search Secrets", + read_only_hint = true, + idempotent_hint = true + ) )] async fn secrets_search( &self, @@ -285,10 +338,7 @@ impl SecretsService { }, ) .await - .map_err(|e| { - tracing::warn!(tool = "secrets_search", ?user_id, error = %e, "tool call failed"); - rmcp::ErrorData::internal_error(e.to_string(), None) - })?; + .map_err(|e| mcp_err_internal_logged("secrets_search", user_id, e))?; let summary = input.summary.unwrap_or(false); let entries: Vec = result @@ -341,7 +391,12 @@ impl SecretsService { #[tool( description = "Get decrypted secret field values for an entry. Requires your \ encryption key via X-Encryption-Key header (64 hex chars, PBKDF2-derived). \ - Returns all fields, or a specific field if 'field' is provided." + Returns all fields, or a specific field if 'field' is provided.", + annotations( + title = "Get Secret Values", + read_only_hint = true, + idempotent_hint = true + ) )] async fn secrets_get( &self, @@ -371,10 +426,7 @@ impl SecretsService { Some(user_id), ) .await - .map_err(|e| { - tracing::warn!(tool = "secrets_get", ?user_id, error = %e, "tool call failed"); - rmcp::ErrorData::internal_error(e.to_string(), None) - })?; + .map_err(|e| mcp_err_internal_logged("secrets_get", Some(user_id), e))?; tracing::info!( tool = "secrets_get", @@ -395,10 +447,7 @@ impl SecretsService { Some(user_id), ) .await - .map_err(|e| { - tracing::warn!(tool = "secrets_get", ?user_id, error = %e, "tool call failed"); - rmcp::ErrorData::internal_error(e.to_string(), None) - })?; + .map_err(|e| mcp_err_internal_logged("secrets_get", Some(user_id), e))?; let count = secrets.len(); tracing::info!( @@ -416,7 +465,8 @@ impl SecretsService { #[tool( description = "Add or upsert an entry with metadata and encrypted secret fields. \ Requires X-Encryption-Key header. \ - Meta and secret values use 'key=value', 'key=@file', or 'key:=' format." + Meta and secret values use 'key=value', 'key=@file', or 'key:=' format.", + annotations(title = "Add Secret Entry") )] async fn secrets_add( &self, @@ -452,10 +502,7 @@ impl SecretsService { &user_key, ) .await - .map_err(|e| { - tracing::warn!(tool = "secrets_add", ?user_id, error = %e, "tool call failed"); - rmcp::ErrorData::internal_error(e.to_string(), None) - })?; + .map_err(|e| mcp_err_internal_logged("secrets_add", Some(user_id), e))?; tracing::info!( tool = "secrets_add", @@ -472,7 +519,8 @@ impl SecretsService { #[tool( description = "Incrementally update an existing entry. Requires X-Encryption-Key header. \ - Only the fields you specify are changed; everything else is preserved." + Only the fields you specify are changed; everything else is preserved.", + annotations(title = "Update Secret Entry") )] async fn secrets_update( &self, @@ -514,10 +562,7 @@ impl SecretsService { &user_key, ) .await - .map_err(|e| { - tracing::warn!(tool = "secrets_update", ?user_id, error = %e, "tool call failed"); - rmcp::ErrorData::internal_error(e.to_string(), None) - })?; + .map_err(|e| mcp_err_internal_logged("secrets_update", Some(user_id), e))?; tracing::info!( tool = "secrets_update", @@ -534,7 +579,8 @@ impl SecretsService { #[tool( description = "Delete one entry (specify namespace+kind+name) or bulk delete all \ - entries matching namespace+kind. Use dry_run=true to preview." + entries matching namespace+kind. Use dry_run=true to preview.", + annotations(title = "Delete Secret Entry", destructive_hint = true) )] async fn secrets_delete( &self, @@ -564,10 +610,7 @@ impl SecretsService { }, ) .await - .map_err(|e| { - tracing::warn!(tool = "secrets_delete", ?user_id, error = %e, "tool call failed"); - rmcp::ErrorData::internal_error(e.to_string(), None) - })?; + .map_err(|e| mcp_err_internal_logged("secrets_delete", user_id, e))?; tracing::info!( tool = "secrets_delete", @@ -582,7 +625,12 @@ impl SecretsService { #[tool( description = "View change history for an entry. Returns a list of versions with \ - actions and timestamps." + actions and timestamps.", + annotations( + title = "View Secret History", + read_only_hint = true, + idempotent_hint = true + ) )] async fn secrets_history( &self, @@ -609,10 +657,7 @@ impl SecretsService { user_id, ) .await - .map_err(|e| { - tracing::warn!(tool = "secrets_history", ?user_id, error = %e, "tool call failed"); - rmcp::ErrorData::internal_error(e.to_string(), None) - })?; + .map_err(|e| mcp_err_internal_logged("secrets_history", user_id, e))?; tracing::info!( tool = "secrets_history", @@ -626,7 +671,8 @@ impl SecretsService { #[tool( description = "Rollback an entry to a previous version. Requires X-Encryption-Key header. \ - Omit to_version to restore the most recent snapshot." + Omit to_version to restore the most recent snapshot.", + annotations(title = "Rollback Secret Entry", destructive_hint = true) )] async fn secrets_rollback( &self, @@ -655,10 +701,7 @@ impl SecretsService { Some(user_id), ) .await - .map_err(|e| { - tracing::warn!(tool = "secrets_rollback", ?user_id, error = %e, "tool call failed"); - rmcp::ErrorData::internal_error(e.to_string(), None) - })?; + .map_err(|e| mcp_err_internal_logged("secrets_rollback", Some(user_id), e))?; tracing::info!( tool = "secrets_rollback", @@ -672,7 +715,12 @@ impl SecretsService { #[tool( description = "Export matching entries with decrypted secrets as JSON/TOML/YAML string. \ - Requires X-Encryption-Key header. Useful for backup or data migration." + Requires X-Encryption-Key header. Useful for backup or data migration.", + annotations( + title = "Export Secrets", + read_only_hint = true, + idempotent_hint = true + ) )] async fn secrets_export( &self, @@ -706,15 +754,23 @@ impl SecretsService { Some(&user_key), ) .await - .map_err(|e| { - tracing::warn!(tool = "secrets_export", ?user_id, error = %e, "tool call failed"); - rmcp::ErrorData::internal_error(e.to_string(), None) - })?; + .map_err(|e| mcp_err_internal_logged("secrets_export", Some(user_id), e))?; - let serialized = format - .parse::() - .and_then(|fmt| fmt.serialize(&data)) - .map_err(|e| rmcp::ErrorData::internal_error(e.to_string(), None))?; + let fmt = format.parse::().map_err(|e| { + tracing::warn!( + tool = "secrets_export", + ?user_id, + error = %e, + "invalid export format" + ); + rmcp::ErrorData::invalid_request( + "Invalid export format. Use json, toml, or yaml.", + None, + ) + })?; + let serialized = fmt + .serialize(&data) + .map_err(|e| mcp_err_internal_logged("secrets_export", Some(user_id), e))?; tracing::info!( tool = "secrets_export", @@ -729,7 +785,8 @@ impl SecretsService { #[tool( description = "Build the environment variable map from entry secrets with decrypted \ plaintext values. Requires X-Encryption-Key header. \ - Returns a JSON object of VAR_NAME -> plaintext_value ready for injection." + Returns a JSON object of VAR_NAME -> plaintext_value ready for injection.", + annotations(title = "Build Env Map", read_only_hint = true, idempotent_hint = true) )] async fn secrets_env_map( &self, @@ -761,10 +818,7 @@ impl SecretsService { Some(user_id), ) .await - .map_err(|e| { - tracing::warn!(tool = "secrets_env_map", ?user_id, error = %e, "tool call failed"); - rmcp::ErrorData::internal_error(e.to_string(), None) - })?; + .map_err(|e| mcp_err_internal_logged("secrets_env_map", Some(user_id), e))?; let entry_count = env_map.len(); tracing::info!( @@ -785,8 +839,12 @@ impl SecretsService { impl ServerHandler for SecretsService { fn get_info(&self) -> InitializeResult { let mut info = InitializeResult::new(ServerCapabilities::builder().enable_tools().build()); - info.server_info = Implementation::new("secrets-mcp", env!("CARGO_PKG_VERSION")); - info.protocol_version = ProtocolVersion::V_2025_03_26; + info.server_info = Implementation::new("secrets-mcp", env!("CARGO_PKG_VERSION")) + .with_title("Secrets MCP") + .with_description( + "Secure cross-device secrets and configuration management with encrypted secret fields.", + ); + info.protocol_version = ProtocolVersion::V_2025_06_18; info.instructions = Some( "Manage cross-device secrets and configuration securely. \ Data is encrypted with your passphrase-derived key. \ diff --git a/crates/secrets-mcp/src/web.rs b/crates/secrets-mcp/src/web.rs index fcf128d..44b36d8 100644 --- a/crates/secrets-mcp/src/web.rs +++ b/crates/secrets-mcp/src/web.rs @@ -76,12 +76,22 @@ fn google_cfg(state: &AppState) -> Option<&OAuthConfig> { } async fn current_user_id(session: &Session) -> Option { - session - .get::(SESSION_USER_ID) - .await - .ok() - .flatten() - .and_then(|s| Uuid::parse_str(&s).ok()) + match session.get::(SESSION_USER_ID).await { + Ok(opt) => match opt { + Some(s) => match Uuid::parse_str(&s) { + Ok(id) => Some(id), + Err(e) => { + tracing::warn!(error = %e, user_id_str = %s, "invalid user_id UUID in session"); + None + } + }, + None => None, + }, + Err(e) => { + tracing::warn!(error = %e, "failed to read user_id from session"); + None + } + } } fn request_client_ip(headers: &HeaderMap, connect_info: ConnectInfo) -> Option { @@ -112,6 +122,9 @@ fn request_user_agent(headers: &HeaderMap) -> Option { pub fn web_router() -> Router { Router::new() + .route("/robots.txt", get(robots_txt)) + .route("/llms.txt", get(llms_txt)) + .route("/ai.txt", get(ai_txt)) .route("/favicon.svg", get(favicon_svg)) .route( "/favicon.ico", @@ -139,6 +152,33 @@ pub fn web_router() -> Router { .route("/api/apikey/regenerate", post(api_apikey_regenerate)) } +fn text_asset_response(content: &'static str, content_type: &'static str) -> Response { + Response::builder() + .status(StatusCode::OK) + .header(header::CONTENT_TYPE, content_type) + .header(header::CACHE_CONTROL, "public, max-age=86400") + .body(Body::from(content)) + .expect("text asset response") +} + +async fn robots_txt() -> Response { + text_asset_response( + include_str!("../static/robots.txt"), + "text/plain; charset=utf-8", + ) +} + +async fn llms_txt() -> Response { + text_asset_response( + include_str!("../static/llms.txt"), + "text/markdown; charset=utf-8", + ) +} + +async fn ai_txt() -> Response { + llms_txt().await +} + async fn favicon_svg() -> Response { Response::builder() .status(StatusCode::OK) @@ -177,7 +217,10 @@ async fn auth_google( session .insert(SESSION_OAUTH_STATE, &oauth_state) .await - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + .map_err(|e| { + tracing::error!(error = %e, "failed to insert oauth_state into session"); + StatusCode::INTERNAL_SERVER_ERROR + })?; let url = google_auth_url(config, &oauth_state); Ok(Redirect::to(&url).into_response()) @@ -251,10 +294,10 @@ where return Ok(Redirect::to("/?error=oauth_missing_state").into_response()); }; - let expected_state: Option = session - .get(SESSION_OAUTH_STATE) - .await - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + let expected_state: Option = session.get(SESSION_OAUTH_STATE).await.map_err(|e| { + tracing::error!(provider, error = %e, "failed to read oauth_state from session"); + StatusCode::INTERNAL_SERVER_ERROR + })?; if expected_state.as_deref() != Some(returned_state) { tracing::warn!( provider, @@ -263,7 +306,9 @@ where ); return Ok(Redirect::to("/?error=oauth_state").into_response()); } - session.remove::(SESSION_OAUTH_STATE).await.ok(); + if let Err(e) = session.remove::(SESSION_OAUTH_STATE).await { + tracing::warn!(provider, error = %e, "failed to remove oauth_state from session"); + } let config = match provider { "google" => state @@ -280,17 +325,25 @@ where StatusCode::INTERNAL_SERVER_ERROR })?; - let bind_mode: bool = session - .get(SESSION_OAUTH_BIND_MODE) - .await - .unwrap_or(None) - .unwrap_or(false); + let bind_mode: bool = match session.get::(SESSION_OAUTH_BIND_MODE).await { + Ok(v) => v.unwrap_or(false), + Err(e) => { + tracing::error!( + provider, + error = %e, + "failed to read oauth_bind_mode from session" + ); + return Err(StatusCode::INTERNAL_SERVER_ERROR); + } + }; if bind_mode { let user_id = current_user_id(session) .await .ok_or(StatusCode::UNAUTHORIZED)?; - session.remove::(SESSION_OAUTH_BIND_MODE).await.ok(); + if let Err(e) = session.remove::(SESSION_OAUTH_BIND_MODE).await { + tracing::warn!(provider, error = %e, "failed to remove oauth_bind_mode from session after bind"); + } let profile = OAuthProfile { provider: user_info.provider, @@ -328,11 +381,25 @@ where session .insert(SESSION_USER_ID, user.id.to_string()) .await - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + .map_err(|e| { + tracing::error!( + error = %e, + user_id = %user.id, + "failed to insert user_id into session after OAuth" + ); + StatusCode::INTERNAL_SERVER_ERROR + })?; session .insert(SESSION_LOGIN_PROVIDER, &provider) .await - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + .map_err(|e| { + tracing::error!( + provider, + error = %e, + "failed to insert login_provider into session after OAuth" + ); + StatusCode::INTERNAL_SERVER_ERROR + })?; log_login( &state.pool, @@ -350,7 +417,9 @@ where // ── Logout ──────────────────────────────────────────────────────────────────── async fn auth_logout(session: Session) -> impl IntoResponse { - session.flush().await.ok(); + if let Err(e) = session.flush().await { + tracing::warn!(error = %e, "failed to flush session on logout"); + } Redirect::to("/") } @@ -364,10 +433,10 @@ async fn dashboard( return Ok(Redirect::to("/").into_response()); }; - let user = match get_user_by_id(&state.pool, user_id) - .await - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? - { + let user = match get_user_by_id(&state.pool, user_id).await.map_err(|e| { + tracing::error!(error = %e, %user_id, "failed to load user for dashboard"); + StatusCode::INTERNAL_SERVER_ERROR + })? { Some(u) => u, None => return Ok(Redirect::to("/").into_response()), }; @@ -391,10 +460,10 @@ async fn audit_page( return Ok(Redirect::to("/").into_response()); }; - let user = match get_user_by_id(&state.pool, user_id) - .await - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? - { + let user = match get_user_by_id(&state.pool, user_id).await.map_err(|e| { + tracing::error!(error = %e, %user_id, "failed to load user for audit page"); + StatusCode::INTERNAL_SERVER_ERROR + })? { Some(u) => u, None => return Ok(Redirect::to("/").into_response()), }; @@ -439,7 +508,10 @@ async fn account_bind_google( session .insert(SESSION_OAUTH_BIND_MODE, true) .await - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + .map_err(|e| { + tracing::error!(error = %e, "failed to insert oauth_bind_mode into session"); + StatusCode::INTERNAL_SERVER_ERROR + })?; let redirect_uri = format!("{}/account/bind/google/callback", state.base_url); let mut cfg = state @@ -448,7 +520,13 @@ async fn account_bind_google( .ok_or(StatusCode::SERVICE_UNAVAILABLE)?; cfg.redirect_uri = redirect_uri; let st = random_state(); - session.insert(SESSION_OAUTH_STATE, &st).await.ok(); + if let Err(e) = session.insert(SESSION_OAUTH_STATE, &st).await { + tracing::error!(error = %e, "failed to insert oauth_state for account bind flow"); + if let Err(rm) = session.remove::(SESSION_OAUTH_BIND_MODE).await { + tracing::warn!(error = %rm, "failed to roll back oauth_bind_mode after oauth_state insert failure"); + } + return Err(StatusCode::INTERNAL_SERVER_ERROR); + } Ok(Redirect::to(&google_auth_url(&cfg, &st)).into_response()) } @@ -492,7 +570,10 @@ async fn account_unbind( let current_login_provider = session .get::(SESSION_LOGIN_PROVIDER) .await - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + .map_err(|e| { + tracing::error!(error = %e, "failed to read login_provider from session"); + StatusCode::INTERNAL_SERVER_ERROR + })?; unbind_oauth_account( &state.pool, @@ -532,7 +613,10 @@ async fn api_key_salt( let user = get_user_by_id(&state.pool, user_id) .await - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? + .map_err(|e| { + tracing::error!(error = %e, %user_id, "failed to load user for key-salt API"); + StatusCode::INTERNAL_SERVER_ERROR + })? .ok_or(StatusCode::UNAUTHORIZED)?; if user.key_salt.is_none() { @@ -576,10 +660,17 @@ async fn api_key_setup( .await .ok_or(StatusCode::UNAUTHORIZED)?; - let salt = hex::decode_hex(&body.salt).map_err(|_| StatusCode::BAD_REQUEST)?; - let key_check = hex::decode_hex(&body.key_check).map_err(|_| StatusCode::BAD_REQUEST)?; + let salt = hex::decode_hex(&body.salt).map_err(|e| { + tracing::warn!(error = %e, "invalid hex in key-setup salt"); + StatusCode::BAD_REQUEST + })?; + let key_check = hex::decode_hex(&body.key_check).map_err(|e| { + tracing::warn!(error = %e, "invalid hex in key-setup key_check"); + StatusCode::BAD_REQUEST + })?; if salt.len() != 32 { + tracing::warn!(salt_len = salt.len(), "key-setup salt must be 32 bytes"); return Err(StatusCode::BAD_REQUEST); } @@ -608,9 +699,10 @@ async fn api_apikey_get( .await .ok_or(StatusCode::UNAUTHORIZED)?; - let api_key = ensure_api_key(&state.pool, user_id) - .await - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + let api_key = ensure_api_key(&state.pool, user_id).await.map_err(|e| { + tracing::error!(error = %e, %user_id, "ensure_api_key failed"); + StatusCode::INTERNAL_SERVER_ERROR + })?; Ok(Json(ApiKeyResponse { api_key })) } @@ -625,7 +717,10 @@ async fn api_apikey_regenerate( let api_key = regenerate_api_key(&state.pool, user_id) .await - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + .map_err(|e| { + tracing::error!(error = %e, %user_id, "regenerate_api_key failed"); + StatusCode::INTERNAL_SERVER_ERROR + })?; Ok(Json(ApiKeyResponse { api_key })) } diff --git a/crates/secrets-mcp/static/llms.txt b/crates/secrets-mcp/static/llms.txt new file mode 100644 index 0000000..e8fa819 --- /dev/null +++ b/crates/secrets-mcp/static/llms.txt @@ -0,0 +1,23 @@ +# Secrets MCP + +> 给 AI 与自动化工具的简要说明。本站是 **secrets-mcp**:Streamable HTTP **MCP**(Model Context Protocol)与 **Web 控制台** 的组合,用于在多租户场景下存储条目元数据与加密后的秘密字段;持久化在 PostgreSQL。用户通过 OAuth(如已配置)登录 Web;MCP 调用使用 API Key 与加密相关请求头。 + +## 不应抓取或索引的内容 + +- **`/mcp`**:MCP 流式 HTTP 端点(JSON-RPC 等),**不是** HTML 文档,也不适合作为公开知识库来源。 +- **`/api/*`**:会话或 API Key 相关的 HTTP API。 +- **`/dashboard`、`/audit`、`/auth/*`、`/account/*`**:需浏览器会话,属于用户私有界面与 OAuth 流程。 + +## 给 AI 助手的实用提示 + +- 向用户说明连接方式时:MCP 基址为 `{BASE_URL}/mcp`(`BASE_URL` 由部署方设置),通常需要 `Authorization: Bearer `;读写加密秘密时还需按部署文档传递 `X-Encryption-Key` 等头(与客户端模式有关)。 +- **不要编造**本实例的数据库 URL、OAuth 密钥、回调地址或任何凭据;一律以用户环境变量与运维文档为准。 +- Web 端在浏览器内用密码短语派生密钥完成端到端加密;MCP 路径下服务端可能在请求周期内临时使用客户端提供的密钥处理密文(架构细节见项目 README「加密架构」)。 + +## 延伸阅读 + +- 开源仓库中的 `README.md`、`AGENTS.md`(若可访问)包含环境变量、表结构与运维约定。 + +## 关于本文件 + +- 遵循常见的 **`/llms.txt`** 约定,便于人类与 LLM 快速了解站点性质与抓取边界;同文可在 **`/ai.txt`** 获取。 diff --git a/crates/secrets-mcp/static/robots.txt b/crates/secrets-mcp/static/robots.txt new file mode 100644 index 0000000..51bcb18 --- /dev/null +++ b/crates/secrets-mcp/static/robots.txt @@ -0,0 +1,27 @@ +# Secrets MCP — robots.txt +# 本站为需登录的私密控制台与 MCP API;以下路径请勿抓取,以免浪费配额并避免误索引敏感端点。 +# This host serves an authenticated dashboard and machine APIs; please skip crawling the paths below. + +User-agent: * +Disallow: /mcp +Disallow: /api/ +Disallow: /dashboard +Disallow: /audit +Disallow: /auth/ +Disallow: /account/ + +# 面向 AI / LLM 的机器可读站点说明(Markdown):/llms.txt +# Human & AI-readable site summary: /llms.txt (also /ai.txt) + +User-agent: GPTBot +User-agent: Google-Extended +User-agent: anthropic-ai +User-agent: Claude-Web +User-agent: PerplexityBot +User-agent: Bytespider +Disallow: /mcp +Disallow: /api/ +Disallow: /dashboard +Disallow: /audit +Disallow: /auth/ +Disallow: /account/ diff --git a/deploy/.env.example b/deploy/.env.example index e2e8197..b9608ab 100644 --- a/deploy/.env.example +++ b/deploy/.env.example @@ -2,6 +2,7 @@ # 复制此文件为 .env 并填写真实值 # ─── 数据库 ─────────────────────────────────────────────────────────── +# Web 会话(tower-sessions)与业务数据共用此库;启动时会自动 migrate 会话表,无需额外环境变量。 SECRETS_DATABASE_URL=postgres://postgres:PASSWORD@HOST:PORT/secrets-mcp # ─── 服务地址 ─────────────────────────────────────────────────────────