feat: add secrets-mcp-local gateway (proxy, unlock cache, plaintext tool gate)
This commit is contained in:
37
Cargo.lock
generated
37
Cargo.lock
generated
@@ -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"
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
members = [
|
||||
"crates/secrets-core",
|
||||
"crates/secrets-mcp",
|
||||
"crates/secrets-mcp-local",
|
||||
]
|
||||
resolver = "2"
|
||||
|
||||
|
||||
28
README.md
28
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-<version>`,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 <api_key>` + `X-Encryption-Key: <hex>` 请求头(读密文工具须带密钥)。
|
||||
|
||||
### 本地 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":"<Bearer token>"}'
|
||||
```
|
||||
|
||||
## 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
|
||||
|
||||
25
crates/secrets-mcp-local/Cargo.toml
Normal file
25
crates/secrets-mcp-local/Cargo.toml
Normal file
@@ -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"
|
||||
450
crates/secrets-mcp-local/src/main.rs
Normal file
450
crates/secrets-mcp-local/src/main.rs
Normal file
@@ -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<RwLock<Option<UnlockState>>>,
|
||||
default_api_key: Option<String>,
|
||||
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<String>,
|
||||
/// Override TTL for this unlock (seconds).
|
||||
#[serde(default)]
|
||||
ttl_secs: Option<u64>,
|
||||
}
|
||||
|
||||
fn load_env(name: &str) -> Option<String> {
|
||||
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<Vec<u8>> {
|
||||
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<Arc<AppState>>) -> impl IntoResponse {
|
||||
let remote = state.remote_mcp_url.as_str();
|
||||
let dash = &state.dashboard_hint_url;
|
||||
Html(format!(
|
||||
r#"<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head><meta charset="utf-8"><title>secrets-mcp-local</title></head>
|
||||
<body>
|
||||
<h1>本地 MCP Gateway</h1>
|
||||
<p>远程 MCP: <code>{remote}</code></p>
|
||||
<p>在浏览器打开 Dashboard 登录并复制 API Key:<a href="{dash}">{dash}</a></p>
|
||||
<p>然后在本机执行解锁(示例):</p>
|
||||
<pre>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"}}'</pre>
|
||||
<p>或将 Cursor MCP 指向 <code>http://127.0.0.1:9316/mcp</code>(无需在配置里写 <code>X-Encryption-Key</code>)。</p>
|
||||
<p><a href="/local/status">/local/status</a></p>
|
||||
</body>
|
||||
</html>"#,
|
||||
remote = remote,
|
||||
dash = dash
|
||||
))
|
||||
}
|
||||
|
||||
async fn local_status(State(state): State<Arc<AppState>>) -> 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<Arc<AppState>>,
|
||||
axum::Json(body): axum::Json<UnlockBody>,
|
||||
) -> Result<impl IntoResponse, (StatusCode, String)> {
|
||||
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<Arc<AppState>>) -> 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> {
|
||||
HeaderValue::from_bytes(h.as_bytes()).ok()
|
||||
}
|
||||
|
||||
async fn proxy_mcp(
|
||||
State(state): State<Arc<AppState>>,
|
||||
method: Method,
|
||||
headers: HeaderMap,
|
||||
body: Body,
|
||||
) -> Result<Response, Infallible> {
|
||||
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::<SocketAddr>(),
|
||||
)
|
||||
.await
|
||||
.context("server error")?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "secrets-mcp"
|
||||
version = "0.5.27"
|
||||
version = "0.5.28"
|
||||
edition.workspace = true
|
||||
|
||||
[[bin]]
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user