diff --git a/Cargo.lock b/Cargo.lock index 080fc96..f4b0a86 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1858,6 +1858,7 @@ dependencies = [ "base64", "bytes", "futures-core", + "futures-util", "http", "http-body", "http-body-util", @@ -1877,12 +1878,14 @@ dependencies = [ "sync_wrapper", "tokio", "tokio-rustls", + "tokio-util", "tower", "tower-http", "tower-service", "url", "wasm-bindgen", "wasm-bindgen-futures", + "wasm-streams", "web-sys", "webpki-roots 1.0.6", ] @@ -2105,7 +2108,7 @@ dependencies = [ [[package]] name = "secrets-mcp" -version = "0.5.27" +version = "0.5.28" dependencies = [ "anyhow", "askama", @@ -2137,6 +2140,25 @@ dependencies = [ "uuid", ] +[[package]] +name = "secrets-mcp-local" +version = "0.1.0" +dependencies = [ + "anyhow", + "axum", + "dotenvy", + "futures-util", + "http", + "reqwest", + "serde", + "serde_json", + "tokio", + "tower-http", + "tracing", + "tracing-subscriber", + "url", +] + [[package]] name = "semver" version = "1.0.27" @@ -3288,6 +3310,19 @@ dependencies = [ "wasmparser", ] +[[package]] +name = "wasm-streams" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "wasmparser" version = "0.244.0" diff --git a/Cargo.toml b/Cargo.toml index b67dd33..ba80ce9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,6 +2,7 @@ members = [ "crates/secrets-core", "crates/secrets-mcp", + "crates/secrets-mcp-local", ] resolver = "2" diff --git a/README.md b/README.md index e11dc5e..78b5c74 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # secrets-mcp -Workspace:**`secrets-core`** + **`secrets-mcp`**(HTTP Streamable MCP + Web)。多租户密钥与元数据存 PostgreSQL;用户通过 **Google OAuth** 登录,**API Key** 鉴权 MCP 请求;秘密数据用**用户密码短语派生的密钥**在客户端加密,服务端不持有原始密钥。 +Workspace:**`secrets-core`** + **`secrets-mcp`**(HTTP Streamable MCP + Web)+ **`secrets-mcp-local`**(可选:本机 MCP gateway)。多租户密钥与元数据存 PostgreSQL;用户通过 **Google OAuth** 登录,**API Key** 鉴权 MCP 请求;秘密数据用**用户密码短语派生的密钥**在客户端加密,服务端不持有原始密钥。 ## 安装 @@ -9,6 +9,11 @@ cargo build --release -p secrets-mcp # 产物: target/release/secrets-mcp ``` +```bash +cargo build --release -p secrets-mcp-local +# 产物: target/release/secrets-mcp-local(本机代理远程 /mcp,见下节) +``` + 发版产物见 Gitea Release(tag:`secrets-mcp-`,Linux musl 预编译);其它平台本地 `cargo build`。 ## 环境变量与本地运行 @@ -50,6 +55,26 @@ SECRETS_ENV=production - **Web**:`BASE_URL`(登录、Dashboard、设置密码短语、创建 API Key)。**变更记录**页 **`/changelog`**:内容来自 `crates/secrets-mcp/CHANGELOG.md`(构建时嵌入并以 Markdown 渲染);首页页脚与 Dashboard(MCP)页脚均提供入口。**条目**页 `/entries` 支持 folder 标签与条件筛选(含 **`tags`** 逗号分隔、多标签同时匹配);表格列可在「显示列」中开关(名称与操作固定),**文件夹**列为可选列且默认显示。列可见性持久化见 [AGENTS.md](AGENTS.md)「Web 条目页表格列」。 - **MCP**:Streamable HTTP 基址 `{BASE_URL}/mcp`,需 `Authorization: Bearer ` + `X-Encryption-Key: ` 请求头(读密文工具须带密钥)。 +### 本地 MCP gateway(`secrets-mcp-local`) + +用于在本机启动一个 **仅监听 localhost** 的 MCP 入口:先在浏览器打开远程 **Dashboard** 登录并复制 API Key,再向本机 `POST /local/unlock` 提交一次 **64 位 hex** 加密密钥;之后 Cursor 等客户端可将 MCP URL 配为 `http://127.0.0.1:9316/mcp`,**无需**在配置里长期保存 `X-Encryption-Key`。解锁状态在进程内按 TTL 缓存,过期需重新解锁。 + +| 变量 | 说明 | +|------|------| +| `SECRETS_REMOTE_MCP_URL` | **必填**。远程 MCP 完整 URL,例如 `https://secrets.example.com/mcp`。 | +| `SECRETS_MCP_LOCAL_BIND` | 可选。监听地址,默认 `127.0.0.1:9316`。 | +| `SECRETS_LOCAL_API_KEY` | 可选。若设置,则 `/local/unlock` 可只传 `encryption_key`。 | +| `SECRETS_LOCAL_UNLOCK_TTL_SECS` | 可选。默认解锁缓存秒数(单次 `unlock` 可用 `ttl_secs` 覆盖)。 | +| `SECRETS_LOCAL_ALLOW_PLAINTEXT_TOOLS` | 可选。设为 `1`/`true` 时允许代理 `secrets_get` / `secrets_export` / `secrets_env_map`;默认 **不允许**(网关直接返回错误,避免明文进入 agent 上下文)。 | +| `SECRETS_REMOTE_DASHBOARD_URL` | 可选。首页引导链接;未设置时由 `SECRETS_REMOTE_MCP_URL` 推导为同 origin 的 `/dashboard`。 | + +```bash +SECRETS_REMOTE_MCP_URL=https://secrets.example.com/mcp cargo run -p secrets-mcp-local +# 浏览器打开首页提示的 Dashboard,解锁示例: +# curl -X POST http://127.0.0.1:9316/local/unlock -H 'Content-Type: application/json' \ +# -d '{"encryption_key":"<64 hex>","api_key":""}' +``` + ## PostgreSQL TLS 加固 - 推荐将数据库域名单独设置为 `db.refining.ltd`,服务域名保持 `secrets.refining.app`。 @@ -228,6 +253,7 @@ crates/secrets-core/ # db / crypto / models / audit / service taxonomy.rs # SECRET_TYPE_OPTIONS(secret 字段类型下拉选项) service/ # 业务逻辑(add, search, update, delete, export, env_map 等) crates/secrets-mcp/ # MCP HTTP、Web、OAuth、API Key;CHANGELOG.md 嵌入 /changelog +crates/secrets-mcp-local/ # 可选:本机 MCP gateway(代理远程 /mcp) scripts/ release-check.sh # 发版前 fmt / clippy / test setup-gitea-actions.sh diff --git a/crates/secrets-mcp-local/Cargo.toml b/crates/secrets-mcp-local/Cargo.toml new file mode 100644 index 0000000..b20e146 --- /dev/null +++ b/crates/secrets-mcp-local/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "secrets-mcp-local" +version = "0.1.0" +edition.workspace = true +description = "Local MCP gateway: caches unlock credentials and proxies to remote secrets-mcp /mcp" +license = "MIT OR Apache-2.0" + +[[bin]] +name = "secrets-mcp-local" +path = "src/main.rs" + +[dependencies] +anyhow.workspace = true +axum = "0.8" +futures-util = "0.3" +http = "1" +reqwest = { workspace = true, features = ["stream"] } +serde.workspace = true +serde_json.workspace = true +tokio.workspace = true +tower-http = { version = "0.6", features = ["cors", "limit"] } +tracing.workspace = true +tracing-subscriber = { workspace = true, features = ["env-filter"] } +dotenvy.workspace = true +url = "2" diff --git a/crates/secrets-mcp-local/src/main.rs b/crates/secrets-mcp-local/src/main.rs new file mode 100644 index 0000000..6f040ff --- /dev/null +++ b/crates/secrets-mcp-local/src/main.rs @@ -0,0 +1,450 @@ +//! Local MCP gateway: single agent-facing MCP endpoint on localhost. +//! +//! Proxies JSON-RPC to `SECRETS_REMOTE_MCP_URL` and injects `Authorization` + +//! `X-Encryption-Key` from an in-memory unlock cache (TTL). Cursor can connect +//! without embedding the encryption key in its MCP config after a one-time +//! local unlock. + +use std::convert::Infallible; +use std::net::SocketAddr; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use anyhow::{Context, Result}; +use axum::Router; +use axum::body::Body; +use axum::extract::State; +use axum::http::{HeaderMap, HeaderName, HeaderValue, Method, StatusCode}; +use axum::response::{Html, IntoResponse, Response}; +use axum::routing::{get, post}; +use futures_util::TryStreamExt; +use serde::Deserialize; +use serde_json::json; +use tokio::sync::RwLock; +use tower_http::cors::CorsLayer; +use tracing_subscriber::EnvFilter; +use url::Url; + +const DEFAULT_BIND: &str = "127.0.0.1:9316"; +const DEFAULT_TTL_SECS: u64 = 3600; + +/// Tools that return decrypted secret material; blocked when +/// `SECRETS_LOCAL_ALLOW_PLAINTEXT_TOOLS` is not `1`/`true`/`yes`. +const PLAINTEXT_TOOL_NAMES: &[&str] = &["secrets_get", "secrets_export", "secrets_env_map"]; + +#[derive(Clone)] +struct AppState { + remote_mcp_url: Url, + dashboard_hint_url: String, + http_client: reqwest::Client, + unlock: Arc>>, + default_api_key: Option, + ttl: Duration, + allow_plaintext_tools: bool, +} + +struct UnlockState { + api_key: String, + encryption_key_hex: String, + expires_at: Instant, +} + +#[derive(Debug, Deserialize)] +struct UnlockBody { + /// 64-char hex encryption key (PBKDF2-derived), same as remote `X-Encryption-Key`. + encryption_key: String, + /// Optional if `SECRETS_LOCAL_API_KEY` is set in the environment. + api_key: Option, + /// Override TTL for this unlock (seconds). + #[serde(default)] + ttl_secs: Option, +} + +fn load_env(name: &str) -> Option { + std::env::var(name).ok().filter(|s| !s.is_empty()) +} + +fn parse_bool_env(name: &str, default: bool) -> bool { + match load_env(name).map(|s| s.to_ascii_lowercase()).as_deref() { + None => default, + Some("1" | "true" | "yes" | "on") => true, + Some("0" | "false" | "no" | "off") => false, + _ => default, + } +} + +fn dashboard_url_from_remote(remote: &Url) -> String { + load_env("SECRETS_REMOTE_DASHBOARD_URL").unwrap_or_else(|| { + let mut u = remote.clone(); + u.set_path("/dashboard"); + u.set_query(None); + u.set_fragment(None); + u.to_string() + }) +} + +/// If JSON-RPC targets a blocked tool, return an error response body instead of forwarding. +fn maybe_block_plaintext_request( + allow_plaintext: bool, + method: &Method, + body: &[u8], +) -> Option> { + if allow_plaintext || *method != Method::POST || body.is_empty() { + return None; + } + let value: serde_json::Value = serde_json::from_slice(body).ok()?; + + fn tool_blocked(name: &str) -> bool { + PLAINTEXT_TOOL_NAMES.contains(&name) + } + + fn block_single(id: serde_json::Value, name: &str) -> serde_json::Value { + json!({ + "jsonrpc": "2.0", + "id": id, + "error": { + "code": -32000, + "message": format!( + "Local gateway: tool `{name}` is disabled (set SECRETS_LOCAL_ALLOW_PLAINTEXT_TOOLS=1 to allow)." + ) + } + }) + } + + match value { + serde_json::Value::Object(obj) => { + if obj.get("method").and_then(|m| m.as_str()) != Some("tools/call") { + return None; + } + let name = obj + .get("params") + .and_then(|p| p.get("name")) + .and_then(|n| n.as_str())?; + if !tool_blocked(name) { + return None; + } + let id = obj.get("id").cloned().unwrap_or(json!(null)); + Some(block_single(id, name).to_string().into_bytes()) + } + serde_json::Value::Array(arr) => { + let mut out = Vec::with_capacity(arr.len()); + let mut changed = false; + for item in arr { + if let serde_json::Value::Object(ref obj) = item + && obj.get("method").and_then(|m| m.as_str()) == Some("tools/call") + && let Some(name) = obj + .get("params") + .and_then(|p| p.get("name")) + .and_then(|n| n.as_str()) + && tool_blocked(name) + { + changed = true; + let id = obj.get("id").cloned().unwrap_or(json!(null)); + out.push(block_single(id, name)); + continue; + } + out.push(item); + } + if changed { + serde_json::to_vec(&out).ok() + } else { + None + } + } + _ => None, + } +} + +async fn index_html(State(state): State>) -> impl IntoResponse { + let remote = state.remote_mcp_url.as_str(); + let dash = &state.dashboard_hint_url; + Html(format!( + r#" + +secrets-mcp-local + +

本地 MCP Gateway

+

远程 MCP: {remote}

+

在浏览器打开 Dashboard 登录并复制 API Key:{dash}

+

然后在本机执行解锁(示例):

+
curl -sS -X POST http://127.0.0.1:9316/local/unlock \
+  -H "Content-Type: application/json" \
+  -d '{{"encryption_key":"YOUR_64_HEX","api_key":"YOUR_API_KEY"}}'
+

或将 Cursor MCP 指向 http://127.0.0.1:9316/mcp(无需在配置里写 X-Encryption-Key)。

+

/local/status

+ +"#, + remote = remote, + dash = dash + )) +} + +async fn local_status(State(state): State>) -> impl IntoResponse { + let guard = state.unlock.read().await; + let now = Instant::now(); + let body = match guard.as_ref() { + None => json!({ "unlocked": false }), + Some(u) if u.expires_at <= now => json!({ "unlocked": false, "reason": "expired" }), + Some(u) => json!({ + "unlocked": true, + "expires_in_secs": u.expires_at.duration_since(now).as_secs(), + "allow_plaintext_tools": state.allow_plaintext_tools, + }), + }; + (StatusCode::OK, axum::Json(body)) +} + +async fn local_unlock( + State(state): State>, + axum::Json(body): axum::Json, +) -> Result { + let hex = body.encryption_key.trim(); + if hex.len() != 64 || !hex.chars().all(|c| c.is_ascii_hexdigit()) { + return Err(( + StatusCode::BAD_REQUEST, + "encryption_key must be 64 hex characters".to_string(), + )); + } + let api_key = body + .api_key + .or_else(|| state.default_api_key.clone()) + .filter(|s| !s.is_empty()) + .ok_or_else(|| { + ( + StatusCode::BAD_REQUEST, + "api_key required (or set SECRETS_LOCAL_API_KEY)".to_string(), + ) + })?; + + let ttl_secs = body.ttl_secs.unwrap_or(state.ttl.as_secs()); + let ttl = Duration::from_secs(ttl_secs.clamp(60, 86400 * 7)); + + let expires_at = Instant::now() + ttl; + let mut guard = state.unlock.write().await; + *guard = Some(UnlockState { + api_key, + encryption_key_hex: hex.to_string(), + expires_at, + }); + + tracing::info!( + ttl_secs = ttl.as_secs(), + "local unlock: credentials cached until expiry" + ); + + Ok(( + StatusCode::OK, + axum::Json(json!({ + "ok": true, + "expires_in_secs": ttl.as_secs(), + })), + )) +} + +async fn local_lock(State(state): State>) -> impl IntoResponse { + let mut guard = state.unlock.write().await; + *guard = None; + tracing::info!("local lock: credentials cleared"); + (StatusCode::OK, axum::Json(json!({ "ok": true }))) +} + +fn header_value_copy(h: &axum::http::HeaderValue) -> Option { + HeaderValue::from_bytes(h.as_bytes()).ok() +} + +async fn proxy_mcp( + State(state): State>, + method: Method, + headers: HeaderMap, + body: Body, +) -> Result { + let now = Instant::now(); + let unlock = state.unlock.read().await; + let Some(u) = unlock.as_ref() else { + return Ok(Response::builder() + .status(StatusCode::UNAUTHORIZED) + .header( + axum::http::header::CONTENT_TYPE, + "application/json; charset=utf-8", + ) + .body(Body::from( + r#"{"error":"local gateway locked: POST /local/unlock first"}"#, + )) + .unwrap()); + }; + if u.expires_at <= now { + drop(unlock); + let mut w = state.unlock.write().await; + *w = None; + return Ok(Response::builder() + .status(StatusCode::UNAUTHORIZED) + .header( + axum::http::header::CONTENT_TYPE, + "application/json; charset=utf-8", + ) + .body(Body::from( + r#"{"error":"local gateway unlock expired: POST /local/unlock again"}"#, + )) + .unwrap()); + } + + let api_key = u.api_key.clone(); + let enc_key = u.encryption_key_hex.clone(); + drop(unlock); + + let bytes = match axum::body::to_bytes(body, 10 * 1024 * 1024).await { + Ok(b) => b.to_vec(), + Err(e) => { + tracing::warn!(error = %e, "read body failed"); + return Ok(Response::builder() + .status(StatusCode::BAD_REQUEST) + .body(Body::from("body read failed")) + .unwrap()); + } + }; + + let body_to_send = if let Some(blocked) = + maybe_block_plaintext_request(state.allow_plaintext_tools, &method, &bytes) + { + blocked + } else { + bytes + }; + + let mut req_builder = state + .http_client + .request(method.clone(), state.remote_mcp_url.as_str()) + .body(body_to_send); + + // Forward MCP session / accept headers from client. + for name in ["accept", "content-type", "mcp-session-id", "x-mcp-session"] { + if let Ok(hn) = HeaderName::from_bytes(name.as_bytes()) + && let Some(v) = headers.get(&hn) + && let Some(copy) = header_value_copy(v) + { + req_builder = req_builder.header(hn, copy); + } + } + + req_builder = req_builder + .header( + axum::http::header::AUTHORIZATION, + format!("Bearer {}", api_key), + ) + .header("X-Encryption-Key", enc_key); + + let upstream = match req_builder.send().await { + Ok(r) => r, + Err(e) => { + tracing::error!(error = %e, "upstream request failed"); + return Ok(Response::builder() + .status(StatusCode::BAD_GATEWAY) + .body(Body::from(format!("upstream error: {e}"))) + .unwrap()); + } + }; + + let status = upstream.status(); + let mut response_builder = Response::builder().status(status.as_u16()); + + for (key, value) in upstream.headers().iter() { + // Skip hop-by-hop headers if any; reqwest already decompresses. + let key_str = key.as_str(); + if key_str.eq_ignore_ascii_case("transfer-encoding") { + continue; + } + if let Some(v) = header_value_copy(value) { + response_builder = response_builder.header(key, v); + } + } + + let stream = upstream.bytes_stream().map_err(std::io::Error::other); + let body = Body::from_stream(stream); + + Ok(response_builder.body(body).unwrap()) +} + +#[tokio::main] +async fn main() -> Result<()> { + let _ = dotenvy::dotenv(); + + tracing_subscriber::fmt() + .with_env_filter( + EnvFilter::try_from_default_env() + .unwrap_or_else(|_| "secrets_mcp_local=info,tower_http=info".into()), + ) + .init(); + + let remote_mcp_url = load_env("SECRETS_REMOTE_MCP_URL") + .context("SECRETS_REMOTE_MCP_URL is required (e.g. https://secrets.example.com/mcp)")?; + let remote_mcp_url: Url = remote_mcp_url + .parse() + .context("invalid SECRETS_REMOTE_MCP_URL")?; + + let dashboard_hint_url = dashboard_url_from_remote(&remote_mcp_url); + let bind = load_env("SECRETS_MCP_LOCAL_BIND").unwrap_or_else(|| DEFAULT_BIND.to_string()); + let default_api_key = load_env("SECRETS_LOCAL_API_KEY"); + let ttl_secs: u64 = load_env("SECRETS_LOCAL_UNLOCK_TTL_SECS") + .and_then(|s| s.parse().ok()) + .unwrap_or(DEFAULT_TTL_SECS); + let ttl = Duration::from_secs(ttl_secs); + let allow_plaintext_tools = parse_bool_env("SECRETS_LOCAL_ALLOW_PLAINTEXT_TOOLS", false); + + let http_client = reqwest::Client::builder() + .timeout(Duration::from_secs(120)) + .build() + .context("failed to build HTTP client")?; + + let state = Arc::new(AppState { + remote_mcp_url: remote_mcp_url.clone(), + dashboard_hint_url, + http_client, + unlock: Arc::new(RwLock::new(None)), + default_api_key, + ttl, + allow_plaintext_tools, + }); + + let app = Router::new() + .route("/", get(index_html)) + .route("/local/unlock", post(local_unlock)) + .route("/local/lock", post(local_lock)) + .route("/local/status", get(local_status)) + .route("/mcp", axum::routing::any(proxy_mcp)) + .layer( + CorsLayer::new() + .allow_origin(tower_http::cors::Any) + .allow_methods(tower_http::cors::Any) + .allow_headers(tower_http::cors::Any), + ) + .layer(tower_http::limit::RequestBodyLimitLayer::new( + 10 * 1024 * 1024, + )) + .with_state(state); + + let addr: SocketAddr = bind + .parse() + .with_context(|| format!("invalid SECRETS_MCP_LOCAL_BIND: {bind}"))?; + + tracing::info!( + bind = %addr, + remote = %remote_mcp_url, + allow_plaintext_tools = allow_plaintext_tools, + "secrets-mcp-local gateway" + ); + tracing::info!("MCP (agent): http://{}/mcp", addr); + tracing::info!("Unlock: POST http://{}/local/unlock", addr); + + let listener = tokio::net::TcpListener::bind(addr) + .await + .with_context(|| format!("failed to bind {addr}"))?; + + axum::serve( + listener, + app.into_make_service_with_connect_info::(), + ) + .await + .context("server error")?; + + Ok(()) +} diff --git a/crates/secrets-mcp/CHANGELOG.md b/crates/secrets-mcp/CHANGELOG.md index 2053c54..4e3092e 100644 --- a/crates/secrets-mcp/CHANGELOG.md +++ b/crates/secrets-mcp/CHANGELOG.md @@ -1,5 +1,11 @@ 本文档在构建时嵌入 Web 的 `/changelog` 页面,并由服务端渲染为 HTML。 +## [0.5.28] - 2026-04-12 + +### Added + +- 工作区新增 **`secrets-mcp-local`**:本地 MCP gateway(`secrets-mcp-local` 二进制),在解锁后缓存 `Authorization` + `X-Encryption-Key` 并代理至远程 `/mcp`;可选默认拦截 `secrets_get` / `secrets_export` / `secrets_env_map`(`SECRETS_LOCAL_ALLOW_PLAINTEXT_TOOLS`)。 + ## [0.5.27] - 2026-04-11 ### Added diff --git a/crates/secrets-mcp/Cargo.toml b/crates/secrets-mcp/Cargo.toml index e6c817d..42c3bda 100644 --- a/crates/secrets-mcp/Cargo.toml +++ b/crates/secrets-mcp/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "secrets-mcp" -version = "0.5.27" +version = "0.5.28" edition.workspace = true [[bin]] diff --git a/deploy/.env.example b/deploy/.env.example index ab88fde..ca335c9 100644 --- a/deploy/.env.example +++ b/deploy/.env.example @@ -56,3 +56,12 @@ GOOGLE_CLIENT_SECRET= # 设为 1/true/yes 时从 X-Forwarded-For / X-Real-IP 提取客户端 IP # 仅在反代环境下启用,否则客户端可伪造 IP 绕过限流 # TRUST_PROXY=1 + +# ─── 本机 MCP gateway(secrets-mcp-local,可选)──────────────────────── +# 在开发者机器上运行,与上方服务端 .env 通常分开配置;用于代理远程 /mcp 并缓存解锁状态。 +# SECRETS_REMOTE_MCP_URL=https://secrets.example.com/mcp +# SECRETS_MCP_LOCAL_BIND=127.0.0.1:9316 +# SECRETS_LOCAL_API_KEY= +# SECRETS_LOCAL_UNLOCK_TTL_SECS=3600 +# SECRETS_LOCAL_ALLOW_PLAINTEXT_TOOLS=0 +# SECRETS_REMOTE_DASHBOARD_URL=https://secrets.example.com/dashboard