release(secrets-mcp): 0.6.0 - local gateway onboarding and target_exec
This commit is contained in:
7
Cargo.lock
generated
7
Cargo.lock
generated
@@ -2108,7 +2108,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "secrets-mcp"
|
||||
version = "0.5.28"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"askama",
|
||||
@@ -2147,16 +2147,15 @@ dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
"dotenvy",
|
||||
"futures-util",
|
||||
"http",
|
||||
"reqwest",
|
||||
"secrets-core",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tokio",
|
||||
"tower-http",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"url",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
58
README.md
58
README.md
@@ -11,7 +11,7 @@ cargo build --release -p secrets-mcp
|
||||
|
||||
```bash
|
||||
cargo build --release -p secrets-mcp-local
|
||||
# 产物: target/release/secrets-mcp-local(本机代理远程 /mcp,见下节)
|
||||
# 产物: target/release/secrets-mcp-local(本机 MCP gateway,见下节)
|
||||
```
|
||||
|
||||
发版产物见 Gitea Release(tag:`secrets-mcp-<version>`,Linux musl 预编译);其它平台本地 `cargo build`。
|
||||
@@ -57,22 +57,54 @@ SECRETS_ENV=production
|
||||
|
||||
### 本地 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-mcp-local` 现在是**独立的本地 MCP 入口**,不再依赖把远程 `/mcp` 原样透传到本机。它始终能完成 MCP `initialize` / `tools/list`,但会按状态暴露不同工具面:
|
||||
|
||||
- `bootstrap`:尚未绑定或尚未解锁,只暴露 `local_status`、`local_bind_start`、`local_bind_exchange`、`local_unlock_status`、`local_onboarding_info`
|
||||
- `pendingUnlock`:远端授权已完成,但本地仍未完成 passphrase 解锁;仍只暴露 bootstrap 工具
|
||||
- `ready`:绑定 + 解锁均完成,额外暴露 `secrets_find`、`secrets_search`、`secrets_history`、`secrets_overview`、`secrets_delete(dry_run)`、`target_exec`
|
||||
|
||||
上线流程:
|
||||
1. 启动 `secrets-mcp-local`
|
||||
2. 在浏览器打开本地首页 `http://127.0.0.1:9316/`
|
||||
3. 点击“开始绑定”,打开页面给出的 `approve_url`
|
||||
4. 在远端网页确认授权后,返回本地首页等待自动进入解锁阶段
|
||||
5. 在本地页面或 `/unlock` 完成浏览器内 PBKDF2 派生、`key_check` 校验与本地解锁
|
||||
6. 之后将 Cursor 等客户端的 MCP URL 配为 `http://127.0.0.1:9316/mcp`
|
||||
|
||||
这套流程下,Cursor 会先稳定连上 local MCP;未就绪时 AI 只能看到 bootstrap 工具,因此会明确告诉用户去打开本地 onboarding 页面或 `approve_url`,不会再因为 `401` 被误判成“连接失败”。
|
||||
|
||||
运行时说明:
|
||||
- local gateway 的业务数据面已切到远端 JSON HTTP API:`find/search/history/overview/delete-preview/decrypt` 直接走 `/api/local-mcp/...`
|
||||
- `target_exec` 首次执行某个目标时,建议同时传入 `secrets_find/search` 返回的目标摘要;local gateway 会按 `entry_id` 缓存解析后的执行上下文,后续同一目标可复用而不必重新读取密钥
|
||||
- 远端 `key_version` 变化时,本地会自动从 `ready` 回退到 `pendingUnlock`
|
||||
- 远端 API key 已失效或绑定用户不存在时,本地会自动清除 bound 状态并重新回到 `bootstrap`
|
||||
|
||||
`target_exec` 运行时会注入一组标准环境变量,例如:
|
||||
- `TARGET_ENTRY_ID`、`TARGET_NAME`、`TARGET_FOLDER`、`TARGET_TYPE`
|
||||
- `TARGET_HOST`、`TARGET_PORT`、`TARGET_USER`、`TARGET_BASE_URL`
|
||||
- `TARGET_API_KEY`、`TARGET_TOKEN`、`TARGET_SSH_KEY`
|
||||
- `TARGET_META_<KEY>` 与 `TARGET_SECRET_<KEY>`(对 metadata / secret 字段名做大写与下划线归一化)
|
||||
|
||||
典型用法:
|
||||
- 先 `secrets_find` 找到目标服务器,再用 `target_exec` 执行 `ssh -i <(printf '%s' \"$TARGET_SSH_KEY\") \"$TARGET_USER@$TARGET_HOST\" 'df -h'`
|
||||
- 先 `secrets_search` 找到 API 服务条目,再用 `target_exec` 执行 `curl -H \"Authorization: Bearer $TARGET_API_KEY\" \"$TARGET_BASE_URL/health\"`
|
||||
|
||||
本地状态行为:
|
||||
- `POST /local/lock`:仅清除本地解锁缓存,保留绑定
|
||||
- `POST /local/unbind`:同时清除本地绑定与解锁状态
|
||||
- `GET /local/status`:返回 `bootstrap` / `pendingUnlock` / `ready`、待确认绑定会话、缓存目标数、`onboarding_url` / `unlock_url`
|
||||
|
||||
| 变量 | 说明 |
|
||||
|------|------|
|
||||
| `SECRETS_REMOTE_MCP_URL` | **必填**。远程 MCP 完整 URL,例如 `https://secrets.example.com/mcp`。 |
|
||||
| `SECRETS_REMOTE_BASE_URL` | **必填**。远程 Web 基址,例如 `https://secrets.example.com`。 |
|
||||
| `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`。 |
|
||||
| `SECRETS_LOCAL_UNLOCK_TTL_SECS` | 可选。默认解锁缓存秒数(`/local/unlock/complete` 可传 `ttl_secs` 覆盖)。 |
|
||||
| `SECRETS_LOCAL_EXEC_CONTEXT_TTL_SECS` | 可选。按 `entry_id` 复用已解析执行上下文的缓存秒数;到期、`lock`、`unbind` 或远端 `key_version` 变化后会失效。 |
|
||||
|
||||
```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>"}'
|
||||
SECRETS_REMOTE_BASE_URL=https://secrets.example.com cargo run -p secrets-mcp-local
|
||||
# 启动后直接打开 http://127.0.0.1:9316/
|
||||
# 页面会引导你完成 bind -> approve -> unlock -> ready 全流程
|
||||
```
|
||||
|
||||
## PostgreSQL TLS 加固
|
||||
@@ -205,7 +237,7 @@ flowchart LR
|
||||
|
||||
## 数据模型
|
||||
|
||||
主表 **`entries`**(`folder`、`type`、`name`、`notes`、`tags`、`metadata`,多租户时带 `user_id`)+ 子表 **`secrets`**(每行一个加密字段:`name`、`type`、`encrypted`,通过 `entry_secrets` 中间表与 entry 建立 N:N 关联)。**唯一性**:`UNIQUE(user_id, folder, name)`(`user_id` 为空时为遗留行唯一 `(folder, name)`)。另有 `entries_history`、`secrets_history`、`audit_log`,以及 **`users`**(含 `key_salt`、`key_check`、`key_params`、`api_key`)、**`oauth_accounts`**。首次连库自动迁移建表(`secrets-core` 的 `migrate`);已有库在进程启动时亦由同一 `migrate()` 增量补齐表、索引与 N:N 结构。若需从更早版本对照一次性 SQL,可在 git 历史中检索已移除的 `scripts/migrate-v0.3.0.sql`。**Web 登录会话**(tower-sessions)使用同一 `SECRETS_DATABASE_URL`,进程启动时对会话存储执行迁移(见 `secrets-mcp` 中 `PostgresStore::migrate`),无需额外环境变量。
|
||||
主表 **`entries`**(`folder`、`type`、`name`、`notes`、`tags`、`metadata`,多租户时带 `user_id`)+ 子表 **`secrets`**(每行一个加密字段:`name`、`type`、`encrypted`,通过 `entry_secrets` 中间表与 entry 建立 N:N 关联)。**唯一性**:`UNIQUE(user_id, folder, name)`(`user_id` 为空时为遗留行唯一 `(folder, name)`)。另有 `entries_history`、`secrets_history`、`audit_log`,以及 **`users`**(含 `key_salt`、`key_check`、`key_params`、`api_key`)、**`oauth_accounts`**、**`local_mcp_bind_sessions`**(短时本地绑定确认会话)。首次连库自动迁移建表(`secrets-core` 的 `migrate`);已有库在进程启动时亦由同一 `migrate()` 增量补齐表、索引与 N:N 结构。若需从更早版本对照一次性 SQL,可在 git 历史中检索已移除的 `scripts/migrate-v0.3.0.sql`。**Web 登录会话**(tower-sessions)使用同一 `SECRETS_DATABASE_URL`,进程启动时对会话存储执行迁移(见 `secrets-mcp` 中 `PostgresStore::migrate`),无需额外环境变量。
|
||||
|
||||
| 位置 | 字段 | 说明 |
|
||||
|------|------|------|
|
||||
@@ -253,7 +285,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)
|
||||
crates/secrets-mcp-local/ # 可选:本机 MCP gateway(bootstrap + ready 双工具面)
|
||||
scripts/
|
||||
release-check.sh # 发版前 fmt / clippy / test
|
||||
setup-gitea-actions.sh
|
||||
|
||||
@@ -220,6 +220,20 @@ pub async fn migrate(pool: &PgPool) -> Result<()> {
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_oauth_accounts_user_provider
|
||||
ON oauth_accounts(user_id, provider);
|
||||
|
||||
-- ── local_mcp_bind_sessions: short-lived browser approval state ──────────
|
||||
CREATE TABLE IF NOT EXISTS local_mcp_bind_sessions (
|
||||
bind_id TEXT PRIMARY KEY,
|
||||
device_code TEXT NOT NULL,
|
||||
user_id UUID,
|
||||
approved BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_local_mcp_bind_sessions_expires_at
|
||||
ON local_mcp_bind_sessions(expires_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_local_mcp_bind_sessions_user_id
|
||||
ON local_mcp_bind_sessions(user_id) WHERE user_id IS NOT NULL;
|
||||
|
||||
-- FK: user_id columns -> users(id) (nullable = legacy rows; ON DELETE SET NULL)
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
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"
|
||||
description = "Local MCP gateway for onboarding, unlock caching, and delegated target execution"
|
||||
license = "MIT OR Apache-2.0"
|
||||
|
||||
[[bin]]
|
||||
@@ -12,14 +12,13 @@ path = "src/main.rs"
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
axum = "0.8"
|
||||
futures-util = "0.3"
|
||||
http = "1"
|
||||
dotenvy.workspace = true
|
||||
reqwest = { workspace = true, features = ["stream"] }
|
||||
secrets-core = { path = "../secrets-core" }
|
||||
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"
|
||||
uuid.workspace = true
|
||||
|
||||
212
crates/secrets-mcp-local/src/bind.rs
Normal file
212
crates/secrets-mcp-local/src/bind.rs
Normal file
@@ -0,0 +1,212 @@
|
||||
use axum::extract::State;
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::IntoResponse;
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::cache::{BoundState, PendingBindState};
|
||||
use crate::server::AppState;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct BindExchangeBody {
|
||||
bind_id: Option<String>,
|
||||
device_code: Option<String>,
|
||||
}
|
||||
|
||||
fn bind_exchange_error_message(value: &Value) -> String {
|
||||
value
|
||||
.get("error")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(ToOwned::to_owned)
|
||||
.or_else(|| {
|
||||
value
|
||||
.get("message")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(ToOwned::to_owned)
|
||||
})
|
||||
.unwrap_or_else(|| value.to_string())
|
||||
}
|
||||
|
||||
pub async fn refresh_bound_state(state: &AppState) {
|
||||
let api_key = {
|
||||
let guard = state.cache.read().await;
|
||||
guard.bound.as_ref().map(|bound| bound.api_key.clone())
|
||||
};
|
||||
let Some(api_key) = api_key else {
|
||||
return;
|
||||
};
|
||||
if let Ok(refreshed) = state.remote.bind_refresh(&api_key).await {
|
||||
let mut guard = state.cache.write().await;
|
||||
if matches!(refreshed.status, 401 | 404) {
|
||||
guard.clear_bound_and_unlock();
|
||||
return;
|
||||
}
|
||||
if let Some(refreshed) = refreshed.body {
|
||||
let clear_unlock = if let Some(bound) = guard.bound.as_mut() {
|
||||
let changed = bound.key_version != refreshed.key_version;
|
||||
bound.key_version = refreshed.key_version;
|
||||
bound.key_salt_hex = refreshed.key_salt_hex.clone();
|
||||
bound.key_check_hex = refreshed.key_check_hex.clone();
|
||||
bound.key_params = refreshed.key_params.clone();
|
||||
changed
|
||||
} else {
|
||||
false
|
||||
};
|
||||
if clear_unlock {
|
||||
guard.clear_unlock_and_exec();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn start_bind(state: &AppState) -> Result<serde_json::Value, (StatusCode, String)> {
|
||||
let res = state
|
||||
.remote
|
||||
.bind_start()
|
||||
.await
|
||||
.map_err(|e| (StatusCode::BAD_GATEWAY, format!("bind/start failed: {e}")))?;
|
||||
let started_at = std::time::Instant::now();
|
||||
let expires_at = started_at + std::time::Duration::from_secs(res.expires_in_secs);
|
||||
let mut guard = state.cache.write().await;
|
||||
guard.clear_bound_and_unlock();
|
||||
guard.pending_bind = Some(PendingBindState {
|
||||
bind_id: res.bind_id.clone(),
|
||||
device_code: res.device_code.clone(),
|
||||
approve_url: res.approve_url.clone(),
|
||||
expires_at,
|
||||
started_at,
|
||||
});
|
||||
Ok(json!({
|
||||
"ok": true,
|
||||
"bind_id": res.bind_id,
|
||||
"device_code": res.device_code,
|
||||
"approve_url": res.approve_url,
|
||||
"expires_in_secs": res.expires_in_secs,
|
||||
"onboarding_url": format!("http://{}/", state.config.bind),
|
||||
"next_action": "在浏览器打开 approve_url 完成授权,然后继续轮询 local_bind_exchange",
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn exchange_bind(
|
||||
state: &AppState,
|
||||
bind_id: Option<String>,
|
||||
device_code: Option<String>,
|
||||
) -> Result<(StatusCode, serde_json::Value), (StatusCode, String)> {
|
||||
let (bind_id, device_code) = if let (Some(bind_id), Some(device_code)) = (bind_id, device_code)
|
||||
{
|
||||
(bind_id, device_code)
|
||||
} else {
|
||||
let guard = state.cache.read().await;
|
||||
let pending = guard.pending_bind.as_ref().ok_or_else(|| {
|
||||
(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"missing bind session; call /local/bind/start first".to_string(),
|
||||
)
|
||||
})?;
|
||||
(pending.bind_id.clone(), pending.device_code.clone())
|
||||
};
|
||||
|
||||
let result = state
|
||||
.remote
|
||||
.bind_exchange(&bind_id, &device_code)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
(
|
||||
StatusCode::BAD_GATEWAY,
|
||||
format!("bind/exchange failed: {e}"),
|
||||
)
|
||||
})?;
|
||||
let status = result.status;
|
||||
let payload = result.body;
|
||||
|
||||
if status == 202 || payload.get("status").and_then(|v| v.as_str()) == Some("pending") {
|
||||
let approve_url = {
|
||||
let guard = state.cache.read().await;
|
||||
guard
|
||||
.pending_bind
|
||||
.as_ref()
|
||||
.filter(|pending| pending.bind_id == bind_id && pending.device_code == device_code)
|
||||
.map(|pending| pending.approve_url.clone())
|
||||
};
|
||||
return Ok((
|
||||
StatusCode::ACCEPTED,
|
||||
json!({
|
||||
"ok": false,
|
||||
"status": "pending",
|
||||
"bind_id": bind_id,
|
||||
"device_code": device_code,
|
||||
"approve_url": approve_url,
|
||||
"next_action": "继续等待远端授权完成,或重新打开 approve_url",
|
||||
}),
|
||||
));
|
||||
}
|
||||
if !(200..300).contains(&status) {
|
||||
return Err((
|
||||
StatusCode::from_u16(status).unwrap_or(StatusCode::BAD_GATEWAY),
|
||||
bind_exchange_error_message(&payload),
|
||||
));
|
||||
}
|
||||
let payload: crate::remote::BindExchangeResponse =
|
||||
serde_json::from_value(payload).map_err(|e| {
|
||||
(
|
||||
StatusCode::BAD_GATEWAY,
|
||||
format!("invalid bind/exchange response: {e}"),
|
||||
)
|
||||
})?;
|
||||
let api_key = payload.api_key.ok_or_else(|| {
|
||||
(
|
||||
StatusCode::BAD_GATEWAY,
|
||||
"bind/exchange missing api_key".to_string(),
|
||||
)
|
||||
})?;
|
||||
let user_id = payload.user_id.ok_or_else(|| {
|
||||
(
|
||||
StatusCode::BAD_GATEWAY,
|
||||
"bind/exchange missing user_id".to_string(),
|
||||
)
|
||||
})?;
|
||||
let mut guard = state.cache.write().await;
|
||||
guard.clear_pending_bind();
|
||||
guard.bound = Some(BoundState {
|
||||
user_id,
|
||||
api_key,
|
||||
key_salt_hex: payload.key_salt_hex,
|
||||
key_check_hex: payload.key_check_hex,
|
||||
key_params: payload.key_params,
|
||||
key_version: payload.key_version.unwrap_or(0),
|
||||
bound_at: std::time::Instant::now(),
|
||||
});
|
||||
guard.clear_unlock_and_exec();
|
||||
|
||||
Ok((
|
||||
StatusCode::OK,
|
||||
json!({
|
||||
"ok": true,
|
||||
"status": "bound",
|
||||
"unlock_url": format!("http://{}/unlock", state.config.bind),
|
||||
"onboarding_url": format!("http://{}/", state.config.bind),
|
||||
"next_action": "打开本地 unlock 页面完成 passphrase 解锁",
|
||||
}),
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn bind_start(
|
||||
State(state): State<AppState>,
|
||||
) -> Result<impl IntoResponse, (StatusCode, String)> {
|
||||
let payload = start_bind(&state).await?;
|
||||
Ok((StatusCode::OK, axum::Json(payload)))
|
||||
}
|
||||
|
||||
pub async fn bind_exchange(
|
||||
State(state): State<AppState>,
|
||||
axum::Json(input): axum::Json<BindExchangeBody>,
|
||||
) -> Result<impl IntoResponse, (StatusCode, String)> {
|
||||
let (status, payload) = exchange_bind(&state, input.bind_id, input.device_code).await?;
|
||||
Ok((status, axum::Json(payload)))
|
||||
}
|
||||
|
||||
pub async fn unbind(State(state): State<AppState>) -> impl IntoResponse {
|
||||
let mut guard = state.cache.write().await;
|
||||
guard.clear_bound_and_unlock();
|
||||
(StatusCode::OK, axum::Json(json!({ "ok": true })))
|
||||
}
|
||||
234
crates/secrets-mcp-local/src/cache.rs
Normal file
234
crates/secrets-mcp-local/src/cache.rs
Normal file
@@ -0,0 +1,234 @@
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use tokio::sync::RwLock;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::target::ExecutionTarget;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct BoundState {
|
||||
pub user_id: Uuid,
|
||||
pub api_key: String,
|
||||
pub key_salt_hex: Option<String>,
|
||||
pub key_check_hex: Option<String>,
|
||||
pub key_params: Option<Value>,
|
||||
pub key_version: i64,
|
||||
pub bound_at: Instant,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct UnlockState {
|
||||
pub encryption_key_hex: String,
|
||||
pub expires_at: Instant,
|
||||
pub last_used_at: Instant,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct ExecContext {
|
||||
pub target: ExecutionTarget,
|
||||
pub expires_at: Instant,
|
||||
pub last_used_at: Instant,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct PendingBindState {
|
||||
pub bind_id: String,
|
||||
pub device_code: String,
|
||||
pub approve_url: String,
|
||||
pub expires_at: Instant,
|
||||
pub started_at: Instant,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum GatewayPhase {
|
||||
Bootstrap,
|
||||
PendingUnlock,
|
||||
Ready,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct GatewayCache {
|
||||
pub pending_bind: Option<PendingBindState>,
|
||||
pub bound: Option<BoundState>,
|
||||
pub unlock: Option<UnlockState>,
|
||||
pub exec_contexts: HashMap<String, ExecContext>,
|
||||
}
|
||||
|
||||
impl GatewayCache {
|
||||
pub fn clear_bound_and_unlock(&mut self) {
|
||||
self.pending_bind = None;
|
||||
self.bound = None;
|
||||
self.unlock = None;
|
||||
self.exec_contexts.clear();
|
||||
}
|
||||
|
||||
pub fn clear_pending_bind(&mut self) {
|
||||
self.pending_bind = None;
|
||||
}
|
||||
|
||||
pub fn clear_unlock_and_exec(&mut self) {
|
||||
self.unlock = None;
|
||||
self.exec_contexts.clear();
|
||||
}
|
||||
|
||||
pub fn phase(&self, now: Instant) -> GatewayPhase {
|
||||
if self.bound.is_none() {
|
||||
return GatewayPhase::Bootstrap;
|
||||
}
|
||||
if self
|
||||
.unlock
|
||||
.as_ref()
|
||||
.is_some_and(|unlock| unlock.expires_at > now && !unlock.encryption_key_hex.is_empty())
|
||||
{
|
||||
GatewayPhase::Ready
|
||||
} else {
|
||||
GatewayPhase::PendingUnlock
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub type SharedCache = Arc<RwLock<GatewayCache>>;
|
||||
|
||||
pub fn new_cache() -> SharedCache {
|
||||
Arc::new(RwLock::new(GatewayCache::default()))
|
||||
}
|
||||
|
||||
fn cleanup_expired(cache: &mut GatewayCache, now: Instant) {
|
||||
if cache
|
||||
.pending_bind
|
||||
.as_ref()
|
||||
.is_some_and(|bind| bind.expires_at <= now)
|
||||
{
|
||||
cache.pending_bind = None;
|
||||
}
|
||||
if let Some(unlock) = cache.unlock.as_ref()
|
||||
&& unlock.expires_at <= now
|
||||
{
|
||||
cache.clear_unlock_and_exec();
|
||||
}
|
||||
cache.exec_contexts.retain(|_, ctx| ctx.expires_at > now);
|
||||
if cache.unlock.is_none() {
|
||||
cache.exec_contexts.clear();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn spawn_cleanup_task(cache: SharedCache) -> tokio::task::JoinHandle<()> {
|
||||
tokio::spawn(async move {
|
||||
let mut interval = tokio::time::interval(Duration::from_secs(30));
|
||||
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
|
||||
loop {
|
||||
interval.tick().await;
|
||||
let now = Instant::now();
|
||||
let mut guard = cache.write().await;
|
||||
cleanup_expired(&mut guard, now);
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use crate::target::ResolvedTarget;
|
||||
|
||||
#[tokio::test]
|
||||
async fn cleanup_task_clears_expired_unlock() {
|
||||
let mut cache = GatewayCache {
|
||||
pending_bind: None,
|
||||
bound: None,
|
||||
unlock: Some(UnlockState {
|
||||
encryption_key_hex: "11".repeat(32),
|
||||
expires_at: Instant::now() - Duration::from_secs(1),
|
||||
last_used_at: Instant::now(),
|
||||
}),
|
||||
exec_contexts: HashMap::new(),
|
||||
};
|
||||
cleanup_expired(&mut cache, Instant::now());
|
||||
assert!(cache.unlock.is_none());
|
||||
assert!(cache.exec_contexts.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clear_unlock_and_exec_drops_entry_contexts() {
|
||||
let mut cache = GatewayCache {
|
||||
pending_bind: None,
|
||||
bound: None,
|
||||
unlock: Some(UnlockState {
|
||||
encryption_key_hex: "11".repeat(32),
|
||||
expires_at: Instant::now() + Duration::from_secs(30),
|
||||
last_used_at: Instant::now(),
|
||||
}),
|
||||
exec_contexts: HashMap::from([(
|
||||
"entry-1".to_string(),
|
||||
ExecContext {
|
||||
target: ExecutionTarget {
|
||||
resolved: ResolvedTarget {
|
||||
id: "entry-1".to_string(),
|
||||
folder: "refining".to_string(),
|
||||
name: "api".to_string(),
|
||||
entry_type: Some("service".to_string()),
|
||||
},
|
||||
env: BTreeMap::from([(
|
||||
"TARGET_API_KEY".to_string(),
|
||||
"sk_test".to_string(),
|
||||
)]),
|
||||
},
|
||||
expires_at: Instant::now() + Duration::from_secs(30),
|
||||
last_used_at: Instant::now(),
|
||||
},
|
||||
)]),
|
||||
};
|
||||
cache.clear_unlock_and_exec();
|
||||
assert!(cache.unlock.is_none());
|
||||
assert!(cache.exec_contexts.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cleanup_drops_expired_pending_bind() {
|
||||
let mut cache = GatewayCache {
|
||||
pending_bind: Some(PendingBindState {
|
||||
bind_id: "bind-1".to_string(),
|
||||
device_code: "device-1".to_string(),
|
||||
approve_url: "http://example.com/approve".to_string(),
|
||||
expires_at: Instant::now() - Duration::from_secs(1),
|
||||
started_at: Instant::now() - Duration::from_secs(30),
|
||||
}),
|
||||
bound: None,
|
||||
unlock: None,
|
||||
exec_contexts: HashMap::new(),
|
||||
};
|
||||
cleanup_expired(&mut cache, Instant::now());
|
||||
assert!(cache.pending_bind.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn phase_transitions_match_bound_and_unlock() {
|
||||
let now = Instant::now();
|
||||
let mut cache = GatewayCache::default();
|
||||
assert_eq!(cache.phase(now), GatewayPhase::Bootstrap);
|
||||
|
||||
cache.bound = Some(BoundState {
|
||||
user_id: Uuid::nil(),
|
||||
api_key: "api-key".to_string(),
|
||||
key_salt_hex: None,
|
||||
key_check_hex: None,
|
||||
key_params: None,
|
||||
key_version: 0,
|
||||
bound_at: now,
|
||||
});
|
||||
assert_eq!(cache.phase(now), GatewayPhase::PendingUnlock);
|
||||
|
||||
cache.unlock = Some(UnlockState {
|
||||
encryption_key_hex: "11".repeat(32),
|
||||
expires_at: now + Duration::from_secs(60),
|
||||
last_used_at: now,
|
||||
});
|
||||
assert_eq!(cache.phase(now), GatewayPhase::Ready);
|
||||
}
|
||||
}
|
||||
46
crates/secrets-mcp-local/src/config.rs
Normal file
46
crates/secrets-mcp-local/src/config.rs
Normal file
@@ -0,0 +1,46 @@
|
||||
use anyhow::{Context, Result};
|
||||
use std::net::SocketAddr;
|
||||
use std::time::Duration;
|
||||
use url::Url;
|
||||
|
||||
const DEFAULT_BIND: &str = "127.0.0.1:9316";
|
||||
const DEFAULT_UNLOCK_TTL_SECS: u64 = 3600;
|
||||
const DEFAULT_EXEC_CONTEXT_TTL_SECS: u64 = 3600;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct LocalConfig {
|
||||
pub bind: SocketAddr,
|
||||
pub remote_base_url: Url,
|
||||
pub default_unlock_ttl: Duration,
|
||||
pub default_exec_context_ttl: Duration,
|
||||
}
|
||||
|
||||
fn load_env(name: &str) -> Option<String> {
|
||||
std::env::var(name).ok().filter(|s| !s.is_empty())
|
||||
}
|
||||
|
||||
pub fn load_config() -> Result<LocalConfig> {
|
||||
let bind = load_env("SECRETS_MCP_LOCAL_BIND").unwrap_or_else(|| DEFAULT_BIND.to_string());
|
||||
let bind: SocketAddr = bind
|
||||
.parse()
|
||||
.with_context(|| format!("invalid SECRETS_MCP_LOCAL_BIND: {bind}"))?;
|
||||
|
||||
let remote_base_url: Url = load_env("SECRETS_REMOTE_BASE_URL")
|
||||
.context("SECRETS_REMOTE_BASE_URL is required")?
|
||||
.parse()
|
||||
.context("invalid SECRETS_REMOTE_BASE_URL")?;
|
||||
|
||||
let unlock_ttl_secs: u64 = load_env("SECRETS_LOCAL_UNLOCK_TTL_SECS")
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(DEFAULT_UNLOCK_TTL_SECS);
|
||||
let exec_context_ttl_secs: u64 = load_env("SECRETS_LOCAL_EXEC_CONTEXT_TTL_SECS")
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(DEFAULT_EXEC_CONTEXT_TTL_SECS);
|
||||
|
||||
Ok(LocalConfig {
|
||||
bind,
|
||||
remote_base_url,
|
||||
default_unlock_ttl: Duration::from_secs(unlock_ttl_secs.clamp(60, 86400 * 7)),
|
||||
default_exec_context_ttl: Duration::from_secs(exec_context_ttl_secs.clamp(60, 86400 * 7)),
|
||||
})
|
||||
}
|
||||
200
crates/secrets-mcp-local/src/exec.rs
Normal file
200
crates/secrets-mcp-local/src/exec.rs
Normal file
@@ -0,0 +1,200 @@
|
||||
use std::collections::BTreeMap;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{Context, Result, anyhow};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Map, Value};
|
||||
use tokio::process::Command;
|
||||
|
||||
use crate::target::{ExecutionTarget, ResolvedTarget};
|
||||
|
||||
const MAX_OUTPUT_CHARS: usize = 64 * 1024;
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
pub struct TargetExecInput {
|
||||
pub target_ref: Option<String>,
|
||||
pub target: Option<crate::target::TargetSnapshot>,
|
||||
pub command: String,
|
||||
pub timeout_secs: Option<u64>,
|
||||
pub working_dir: Option<String>,
|
||||
pub env_overrides: Option<Map<String, Value>>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct ExecResult {
|
||||
pub resolved_target: ResolvedTarget,
|
||||
pub resolved_env_keys: Vec<String>,
|
||||
pub command: String,
|
||||
pub exit_code: Option<i32>,
|
||||
pub stdout: String,
|
||||
pub stderr: String,
|
||||
pub timed_out: bool,
|
||||
pub duration_ms: u128,
|
||||
pub stdout_truncated: bool,
|
||||
pub stderr_truncated: bool,
|
||||
}
|
||||
|
||||
fn truncate_output(text: String) -> (String, bool) {
|
||||
if text.chars().count() <= MAX_OUTPUT_CHARS {
|
||||
return (text, false);
|
||||
}
|
||||
let truncated = text.chars().take(MAX_OUTPUT_CHARS).collect::<String>();
|
||||
(truncated, true)
|
||||
}
|
||||
|
||||
fn stringify_env_override(value: &Value) -> Option<String> {
|
||||
match value {
|
||||
Value::Null => None,
|
||||
Value::String(s) => Some(s.clone()),
|
||||
Value::Bool(v) => Some(v.to_string()),
|
||||
Value::Number(v) => Some(v.to_string()),
|
||||
other => serde_json::to_string(other).ok(),
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_env_overrides(
|
||||
env: &mut BTreeMap<String, String>,
|
||||
overrides: Option<&Map<String, Value>>,
|
||||
) -> Result<()> {
|
||||
let Some(overrides) = overrides else {
|
||||
return Ok(());
|
||||
};
|
||||
for (key, value) in overrides {
|
||||
if key.is_empty() || key.contains('=') {
|
||||
return Err(anyhow!("invalid env override key: {key}"));
|
||||
}
|
||||
if key.starts_with("TARGET_") {
|
||||
return Err(anyhow!(
|
||||
"env override `{key}` cannot override reserved TARGET_* variables"
|
||||
));
|
||||
}
|
||||
if let Some(value) = stringify_env_override(value) {
|
||||
env.insert(key.clone(), value);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn execute_command(
|
||||
input: &TargetExecInput,
|
||||
target: &ExecutionTarget,
|
||||
timeout_secs: u64,
|
||||
) -> Result<ExecResult> {
|
||||
let mut env = target.env.clone();
|
||||
apply_env_overrides(&mut env, input.env_overrides.as_ref())?;
|
||||
|
||||
let started = std::time::Instant::now();
|
||||
let mut command = Command::new("/bin/sh");
|
||||
command
|
||||
.arg("-lc")
|
||||
.arg(&input.command)
|
||||
.kill_on_drop(true)
|
||||
.stdout(std::process::Stdio::piped())
|
||||
.stderr(std::process::Stdio::piped());
|
||||
|
||||
if let Some(dir) = input.working_dir.as_ref().filter(|dir| !dir.is_empty()) {
|
||||
command.current_dir(dir);
|
||||
}
|
||||
for (key, value) in &env {
|
||||
command.env(key, value);
|
||||
}
|
||||
|
||||
let child = command
|
||||
.spawn()
|
||||
.with_context(|| format!("failed to spawn command: {}", input.command))?;
|
||||
|
||||
let timed = tokio::time::timeout(
|
||||
Duration::from_secs(timeout_secs.clamp(1, 86400)),
|
||||
child.wait_with_output(),
|
||||
)
|
||||
.await;
|
||||
|
||||
let (exit_code, stdout, stderr, timed_out) = match timed {
|
||||
Ok(output) => {
|
||||
let output = output.context("failed waiting for command output")?;
|
||||
(
|
||||
output.status.code(),
|
||||
String::from_utf8_lossy(&output.stdout).to_string(),
|
||||
String::from_utf8_lossy(&output.stderr).to_string(),
|
||||
false,
|
||||
)
|
||||
}
|
||||
Err(_) => (None, String::new(), "command timed out".to_string(), true),
|
||||
};
|
||||
|
||||
let (stdout, stdout_truncated) = truncate_output(stdout);
|
||||
let (stderr, stderr_truncated) = truncate_output(stderr);
|
||||
|
||||
Ok(ExecResult {
|
||||
resolved_target: target.resolved.clone(),
|
||||
resolved_env_keys: target.resolved_env_keys(),
|
||||
command: input.command.clone(),
|
||||
exit_code,
|
||||
stdout,
|
||||
stderr,
|
||||
timed_out,
|
||||
duration_ms: started.elapsed().as_millis(),
|
||||
stdout_truncated,
|
||||
stderr_truncated,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::target::ExecutionTarget;
|
||||
use serde_json::json;
|
||||
|
||||
#[tokio::test]
|
||||
async fn execute_command_injects_target_env() {
|
||||
let target = ExecutionTarget {
|
||||
resolved: ResolvedTarget {
|
||||
id: "entry-1".to_string(),
|
||||
folder: "refining".to_string(),
|
||||
name: "api".to_string(),
|
||||
entry_type: Some("service".to_string()),
|
||||
},
|
||||
env: BTreeMap::from([
|
||||
("TARGET_HOST".to_string(), "47.238.146.244".to_string()),
|
||||
("TARGET_API_KEY".to_string(), "sk_test_123".to_string()),
|
||||
]),
|
||||
};
|
||||
let input = TargetExecInput {
|
||||
target_ref: Some("entry-1".to_string()),
|
||||
target: None,
|
||||
command: "printf '%s|%s' \"$TARGET_HOST\" \"$TARGET_API_KEY\"".to_string(),
|
||||
timeout_secs: Some(5),
|
||||
working_dir: None,
|
||||
env_overrides: None,
|
||||
};
|
||||
let result = execute_command(&input, &target, 5).await.unwrap();
|
||||
assert_eq!(result.exit_code, Some(0));
|
||||
assert_eq!(result.stdout, "47.238.146.244|sk_test_123");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn execute_command_rejects_reserved_target_override() {
|
||||
let target = ExecutionTarget {
|
||||
resolved: ResolvedTarget {
|
||||
id: "entry-1".to_string(),
|
||||
folder: "refining".to_string(),
|
||||
name: "api".to_string(),
|
||||
entry_type: Some("service".to_string()),
|
||||
},
|
||||
env: BTreeMap::from([("TARGET_HOST".to_string(), "47.238.146.244".to_string())]),
|
||||
};
|
||||
let input = TargetExecInput {
|
||||
target_ref: Some("entry-1".to_string()),
|
||||
target: None,
|
||||
command: "echo test".to_string(),
|
||||
timeout_secs: Some(5),
|
||||
working_dir: None,
|
||||
env_overrides: Some(serde_json::from_value(json!({"TARGET_HOST":"override"})).unwrap()),
|
||||
};
|
||||
let err = execute_command(&input, &target, 5).await.unwrap_err();
|
||||
assert!(
|
||||
err.to_string()
|
||||
.contains("cannot override reserved TARGET_* variables")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,368 +1,15 @@
|
||||
//! 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};
|
||||
mod bind;
|
||||
mod cache;
|
||||
mod config;
|
||||
mod exec;
|
||||
mod mcp;
|
||||
mod remote;
|
||||
mod server;
|
||||
mod target;
|
||||
mod unlock;
|
||||
|
||||
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<()> {
|
||||
@@ -375,76 +22,34 @@ async fn main() -> Result<()> {
|
||||
)
|
||||
.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 config = config::load_config()?;
|
||||
let remote = std::sync::Arc::new(remote::RemoteClient::new(config.remote_base_url.clone())?);
|
||||
let cache = cache::new_cache();
|
||||
let cleanup = cache::spawn_cleanup_task(cache.clone());
|
||||
|
||||
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}"))?;
|
||||
let app_state = server::AppState {
|
||||
config: config.clone(),
|
||||
cache,
|
||||
remote,
|
||||
};
|
||||
let app = server::router(app_state);
|
||||
|
||||
tracing::info!(
|
||||
bind = %addr,
|
||||
remote = %remote_mcp_url,
|
||||
allow_plaintext_tools = allow_plaintext_tools,
|
||||
"secrets-mcp-local gateway"
|
||||
bind = %config.bind,
|
||||
remote = %config.remote_base_url,
|
||||
"secrets-mcp-local service started"
|
||||
);
|
||||
tracing::info!("MCP (agent): http://{}/mcp", addr);
|
||||
tracing::info!("Unlock: POST http://{}/local/unlock", addr);
|
||||
|
||||
let listener = tokio::net::TcpListener::bind(addr)
|
||||
let listener = tokio::net::TcpListener::bind(config.bind)
|
||||
.await
|
||||
.with_context(|| format!("failed to bind {addr}"))?;
|
||||
.with_context(|| format!("failed to bind {}", config.bind))?;
|
||||
|
||||
axum::serve(
|
||||
let result = axum::serve(
|
||||
listener,
|
||||
app.into_make_service_with_connect_info::<SocketAddr>(),
|
||||
app.into_make_service_with_connect_info::<std::net::SocketAddr>(),
|
||||
)
|
||||
.await
|
||||
.context("server error")?;
|
||||
|
||||
Ok(())
|
||||
.context("server error");
|
||||
cleanup.abort();
|
||||
result
|
||||
}
|
||||
|
||||
828
crates/secrets-mcp-local/src/mcp.rs
Normal file
828
crates/secrets-mcp-local/src/mcp.rs
Normal file
@@ -0,0 +1,828 @@
|
||||
use std::convert::Infallible;
|
||||
use std::time::Instant;
|
||||
|
||||
use axum::body::Body;
|
||||
use axum::extract::State;
|
||||
use axum::http::{StatusCode, header};
|
||||
use axum::response::Response;
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::bind::{exchange_bind, start_bind};
|
||||
use crate::cache::{ExecContext, GatewayPhase};
|
||||
use crate::exec::{TargetExecInput, execute_command};
|
||||
use crate::server::AppState;
|
||||
use crate::target::{TargetSnapshot, build_execution_target};
|
||||
use crate::unlock::status_payload;
|
||||
|
||||
const LOCAL_EXEC_TOOL: &str = "target_exec";
|
||||
|
||||
#[derive(Deserialize, Default)]
|
||||
struct BindExchangeArgs {
|
||||
bind_id: Option<String>,
|
||||
device_code: Option<String>,
|
||||
}
|
||||
|
||||
fn json_response(status: StatusCode, value: Value) -> Response {
|
||||
Response::builder()
|
||||
.status(status)
|
||||
.header(header::CONTENT_TYPE, "application/json; charset=utf-8")
|
||||
.body(Body::from(value.to_string()))
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn jsonrpc_result_response(id: Value, result: Value) -> Response {
|
||||
json_response(
|
||||
StatusCode::OK,
|
||||
json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": id,
|
||||
"result": result,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
fn tool_success_response(id: Value, value: Value) -> Response {
|
||||
let pretty = serde_json::to_string_pretty(&value).unwrap_or_else(|_| value.to_string());
|
||||
jsonrpc_result_response(
|
||||
id,
|
||||
json!({
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": pretty,
|
||||
}
|
||||
],
|
||||
"isError": false
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
fn tool_error_response(id: Value, message: impl Into<String>) -> Response {
|
||||
jsonrpc_result_response(
|
||||
id,
|
||||
json!({
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": message.into(),
|
||||
}
|
||||
],
|
||||
"isError": true
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
fn empty_notification_response() -> Response {
|
||||
Response::builder()
|
||||
.status(StatusCode::ACCEPTED)
|
||||
.body(Body::empty())
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn method_not_found(id: Value, method: &str) -> Response {
|
||||
json_response(
|
||||
StatusCode::OK,
|
||||
json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": id,
|
||||
"error": {
|
||||
"code": -32601,
|
||||
"message": format!("method `{method}` not supported by secrets-mcp-local"),
|
||||
}
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
fn invalid_request_response(message: impl Into<String>) -> Response {
|
||||
json_response(
|
||||
StatusCode::BAD_REQUEST,
|
||||
json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": null,
|
||||
"error": {
|
||||
"code": -32600,
|
||||
"message": message.into(),
|
||||
}
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
fn status_tool_definitions() -> Vec<Value> {
|
||||
vec![
|
||||
json!({
|
||||
"name": "local_status",
|
||||
"description": "Read the local gateway readiness state, onboarding URL, unlock URL, and any pending approval session.",
|
||||
"inputSchema": { "type": "object", "properties": {} },
|
||||
"annotations": { "title": "Local MCP Status" }
|
||||
}),
|
||||
json!({
|
||||
"name": "local_unlock_status",
|
||||
"description": "Return whether the local gateway is waiting for passphrase unlock or already ready.",
|
||||
"inputSchema": { "type": "object", "properties": {} },
|
||||
"annotations": { "title": "Local Unlock Status" }
|
||||
}),
|
||||
json!({
|
||||
"name": "local_onboarding_info",
|
||||
"description": "Return the local onboarding page URL, MCP URL, and current next-step guidance for the user.",
|
||||
"inputSchema": { "type": "object", "properties": {} },
|
||||
"annotations": { "title": "Local Onboarding Info" }
|
||||
}),
|
||||
]
|
||||
}
|
||||
|
||||
fn bind_tool_definitions() -> Vec<Value> {
|
||||
vec![
|
||||
json!({
|
||||
"name": "local_bind_start",
|
||||
"description": "Start a new remote authorization session and return the approve_url that the user should open in a browser.",
|
||||
"inputSchema": { "type": "object", "properties": {} },
|
||||
"annotations": { "title": "Start Local MCP Binding" }
|
||||
}),
|
||||
json!({
|
||||
"name": "local_bind_exchange",
|
||||
"description": "Poll the current bind session. When the user has approved in the browser, this moves the gateway into pendingUnlock and returns the local unlock URL.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"bind_id": { "type": ["string", "null"] },
|
||||
"device_code": { "type": ["string", "null"] }
|
||||
}
|
||||
},
|
||||
"annotations": { "title": "Poll Binding State" }
|
||||
}),
|
||||
]
|
||||
}
|
||||
|
||||
fn ready_tool_definitions() -> Vec<Value> {
|
||||
vec![
|
||||
json!({
|
||||
"name": "secrets_find",
|
||||
"description": "Find entries in the secrets store and return target snapshots suitable for target_exec.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": { "type": ["string", "null"] },
|
||||
"metadata_query": { "type": ["string", "null"] },
|
||||
"folder": { "type": ["string", "null"] },
|
||||
"type": { "type": ["string", "null"] },
|
||||
"name": { "type": ["string", "null"] },
|
||||
"name_query": { "type": ["string", "null"] },
|
||||
"tags": { "type": ["array", "null"], "items": { "type": "string" } },
|
||||
"limit": { "type": ["integer", "null"] },
|
||||
"offset": { "type": ["integer", "null"] }
|
||||
}
|
||||
},
|
||||
"annotations": { "title": "Find Secrets" }
|
||||
}),
|
||||
json!({
|
||||
"name": "secrets_search",
|
||||
"description": "Search entries with optional summary mode. Returns metadata and secret field names, not secret values.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": { "type": ["string", "null"] },
|
||||
"metadata_query": { "type": ["string", "null"] },
|
||||
"folder": { "type": ["string", "null"] },
|
||||
"type": { "type": ["string", "null"] },
|
||||
"name": { "type": ["string", "null"] },
|
||||
"name_query": { "type": ["string", "null"] },
|
||||
"tags": { "type": ["array", "null"], "items": { "type": "string" } },
|
||||
"summary": { "type": ["boolean", "null"] },
|
||||
"sort": { "type": ["string", "null"] },
|
||||
"limit": { "type": ["integer", "null"] },
|
||||
"offset": { "type": ["integer", "null"] }
|
||||
}
|
||||
},
|
||||
"annotations": { "title": "Search Secrets" }
|
||||
}),
|
||||
json!({
|
||||
"name": "secrets_history",
|
||||
"description": "View change history for an entry by id or by name/folder.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": { "type": ["string", "null"] },
|
||||
"name": { "type": ["string", "null"] },
|
||||
"folder": { "type": ["string", "null"] },
|
||||
"limit": { "type": ["integer", "null"] }
|
||||
}
|
||||
},
|
||||
"annotations": { "title": "View Secret History" }
|
||||
}),
|
||||
json!({
|
||||
"name": "secrets_overview",
|
||||
"description": "Get counts of entries per folder and per type.",
|
||||
"inputSchema": { "type": "object", "properties": {} },
|
||||
"annotations": { "title": "Secrets Overview" }
|
||||
}),
|
||||
json!({
|
||||
"name": "secrets_delete",
|
||||
"description": "Preview deletions only. dry_run must be true.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": { "type": ["string", "null"] },
|
||||
"name": { "type": ["string", "null"] },
|
||||
"folder": { "type": ["string", "null"] },
|
||||
"type": { "type": ["string", "null"] },
|
||||
"dry_run": { "type": ["boolean", "null"] }
|
||||
}
|
||||
},
|
||||
"annotations": { "title": "Delete Secret Entry Preview", "destructiveHint": true }
|
||||
}),
|
||||
json!({
|
||||
"name": LOCAL_EXEC_TOOL,
|
||||
"description": "Execute a standard local command against a resolved secrets target. The local gateway injects target metadata and secret values as environment variables without exposing raw secret values to the AI.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"target_ref": {
|
||||
"type": ["string", "null"],
|
||||
"description": "Target entry id from secrets_find/secrets_search. Required on first use; later calls may reuse the cached execution context for the same entry id."
|
||||
},
|
||||
"target": {
|
||||
"type": ["object", "null"],
|
||||
"description": "Optional target snapshot copied from secrets_find/secrets_search. Required on first use when the local gateway has not cached this entry id yet."
|
||||
},
|
||||
"command": {
|
||||
"type": "string",
|
||||
"description": "Standard shell command to execute locally, such as ssh/curl/docker/http."
|
||||
},
|
||||
"timeout_secs": {
|
||||
"type": ["integer", "null"],
|
||||
"description": "Execution timeout in seconds."
|
||||
},
|
||||
"working_dir": {
|
||||
"type": ["string", "null"],
|
||||
"description": "Optional working directory for the command."
|
||||
},
|
||||
"env_overrides": {
|
||||
"type": ["object", "null"],
|
||||
"description": "Optional extra environment variables. Reserved TARGET_* names cannot be overridden."
|
||||
}
|
||||
},
|
||||
"required": ["command"]
|
||||
},
|
||||
"annotations": { "title": "Execute Against Target" }
|
||||
}),
|
||||
]
|
||||
}
|
||||
|
||||
fn tools_for_phase(phase: GatewayPhase) -> Vec<Value> {
|
||||
let mut tools = status_tool_definitions();
|
||||
if phase != GatewayPhase::Ready {
|
||||
tools.extend(bind_tool_definitions());
|
||||
}
|
||||
if phase == GatewayPhase::Ready {
|
||||
tools.extend(ready_tool_definitions());
|
||||
}
|
||||
tools
|
||||
}
|
||||
|
||||
async fn current_phase_and_status(state: &AppState) -> (GatewayPhase, Value) {
|
||||
let payload = status_payload(state).await;
|
||||
let phase = payload
|
||||
.get("state")
|
||||
.cloned()
|
||||
.and_then(|value| serde_json::from_value(value).ok())
|
||||
.unwrap_or(GatewayPhase::Bootstrap);
|
||||
(phase, payload)
|
||||
}
|
||||
|
||||
fn instructions_for_phase(phase: GatewayPhase) -> &'static str {
|
||||
match phase {
|
||||
GatewayPhase::Bootstrap => {
|
||||
"Use local_status and local_bind_start first. The user must open the approve_url in a browser before the local gateway can continue."
|
||||
}
|
||||
GatewayPhase::PendingUnlock => {
|
||||
"Remote authorization is complete. Ask the user to open the local unlock page and finish passphrase unlock before calling business tools."
|
||||
}
|
||||
GatewayPhase::Ready => {
|
||||
"The local gateway is ready. Use secrets_find/secrets_search for discovery and target_exec for delegated command execution against decrypted targets."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn initialize_response(id: Value, phase: GatewayPhase) -> Response {
|
||||
let session_id = format!(
|
||||
"local-{}",
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|duration| duration.as_nanos())
|
||||
.unwrap_or(0)
|
||||
);
|
||||
let response = json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": id,
|
||||
"result": {
|
||||
"protocolVersion": "2025-06-18",
|
||||
"capabilities": {
|
||||
"tools": {}
|
||||
},
|
||||
"serverInfo": {
|
||||
"name": "secrets-mcp-local",
|
||||
"version": env!("CARGO_PKG_VERSION"),
|
||||
"title": "Secrets MCP Local"
|
||||
},
|
||||
"instructions": instructions_for_phase(phase),
|
||||
}
|
||||
});
|
||||
Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header(header::CONTENT_TYPE, "application/json; charset=utf-8")
|
||||
.header("mcp-session-id", session_id)
|
||||
.body(Body::from(response.to_string()))
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
async fn resolve_target_context(
|
||||
state: &AppState,
|
||||
api_key: &str,
|
||||
unlock_key: &str,
|
||||
unlock_expires_at: Instant,
|
||||
input: &TargetExecInput,
|
||||
) -> anyhow::Result<crate::target::ExecutionTarget> {
|
||||
let target_ref = input
|
||||
.target_ref
|
||||
.clone()
|
||||
.or_else(|| input.target.as_ref().map(|t| t.id.clone()))
|
||||
.ok_or_else(|| anyhow::anyhow!("target_ref is required"))?;
|
||||
|
||||
{
|
||||
let mut guard = state.cache.write().await;
|
||||
if let Some(ctx) = guard.exec_contexts.get_mut(&target_ref)
|
||||
&& ctx.expires_at > Instant::now()
|
||||
{
|
||||
ctx.last_used_at = Instant::now();
|
||||
return Ok(ctx.target.clone());
|
||||
}
|
||||
}
|
||||
|
||||
let snapshot: TargetSnapshot = input.target.clone().ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"target details required on first use for entry `{target_ref}`; pass the matching secrets_find/search result as `target`"
|
||||
)
|
||||
})?;
|
||||
if snapshot.id != target_ref {
|
||||
return Err(anyhow::anyhow!(
|
||||
"target_ref `{target_ref}` does not match target.id `{}`",
|
||||
snapshot.id
|
||||
));
|
||||
}
|
||||
|
||||
let secrets = state
|
||||
.remote
|
||||
.get_entry_secrets_by_id(api_key, unlock_key, &target_ref)
|
||||
.await?;
|
||||
let target = build_execution_target(&snapshot, &secrets)?;
|
||||
let expires_at = std::cmp::min(
|
||||
Instant::now() + state.config.default_exec_context_ttl,
|
||||
unlock_expires_at,
|
||||
);
|
||||
let mut guard = state.cache.write().await;
|
||||
guard.exec_contexts.insert(
|
||||
target_ref,
|
||||
ExecContext {
|
||||
target: target.clone(),
|
||||
expires_at,
|
||||
last_used_at: Instant::now(),
|
||||
},
|
||||
);
|
||||
Ok(target)
|
||||
}
|
||||
|
||||
async fn handle_target_exec(state: &AppState, id: Value, args: Option<Value>) -> Response {
|
||||
let input: TargetExecInput = match args {
|
||||
Some(value) => match serde_json::from_value(value) {
|
||||
Ok(input) => input,
|
||||
Err(err) => {
|
||||
return tool_error_response(id, format!("invalid `{LOCAL_EXEC_TOOL}` args: {err}"));
|
||||
}
|
||||
},
|
||||
None => {
|
||||
return tool_error_response(id, format!("`{LOCAL_EXEC_TOOL}` arguments are required"));
|
||||
}
|
||||
};
|
||||
if input.command.trim().is_empty() {
|
||||
return tool_error_response(id, "command is required");
|
||||
}
|
||||
|
||||
let api_key = {
|
||||
let guard = state.cache.read().await;
|
||||
match guard.bound.as_ref() {
|
||||
Some(bound) => bound.api_key.clone(),
|
||||
None => {
|
||||
return tool_error_response(
|
||||
id,
|
||||
"local MCP is not bound; call local_bind_start first",
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
let (unlock_key, unlock_expires_at) = {
|
||||
let mut guard = state.cache.write().await;
|
||||
match guard.unlock.as_mut() {
|
||||
Some(unlock) if unlock.expires_at > Instant::now() => {
|
||||
unlock.last_used_at = Instant::now();
|
||||
(unlock.encryption_key_hex.clone(), unlock.expires_at)
|
||||
}
|
||||
_ => {
|
||||
guard.clear_unlock_and_exec();
|
||||
return tool_error_response(
|
||||
id,
|
||||
"local MCP is not unlocked; ask the user to open the local unlock page first",
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
let target =
|
||||
match resolve_target_context(state, &api_key, &unlock_key, unlock_expires_at, &input).await
|
||||
{
|
||||
Ok(target) => target,
|
||||
Err(err) => return tool_error_response(id, format!("failed resolving target: {err}")),
|
||||
};
|
||||
let timeout_secs = input.timeout_secs.unwrap_or(120).clamp(1, 3600);
|
||||
let result = match execute_command(&input, &target, timeout_secs).await {
|
||||
Ok(result) => result,
|
||||
Err(err) => return tool_error_response(id, format!("execution failed: {err}")),
|
||||
};
|
||||
tool_success_response(
|
||||
id,
|
||||
serde_json::to_value(result).unwrap_or_else(|_| json!({})),
|
||||
)
|
||||
}
|
||||
|
||||
async fn handle_bootstrap_tool(
|
||||
state: &AppState,
|
||||
tool_name: &str,
|
||||
id: Value,
|
||||
args: Option<Value>,
|
||||
) -> Response {
|
||||
match tool_name {
|
||||
"local_status" | "local_unlock_status" | "local_onboarding_info" => {
|
||||
tool_success_response(id, status_payload(state).await)
|
||||
}
|
||||
"local_bind_start" => match start_bind(state).await {
|
||||
Ok(payload) => tool_success_response(id, payload),
|
||||
Err((_status, message)) => tool_error_response(id, message),
|
||||
},
|
||||
"local_bind_exchange" => {
|
||||
let parsed = match args {
|
||||
Some(value) => match serde_json::from_value::<BindExchangeArgs>(value) {
|
||||
Ok(parsed) => parsed,
|
||||
Err(err) => {
|
||||
return tool_error_response(
|
||||
id,
|
||||
format!("invalid local_bind_exchange args: {err}"),
|
||||
);
|
||||
}
|
||||
},
|
||||
None => BindExchangeArgs::default(),
|
||||
};
|
||||
match exchange_bind(state, parsed.bind_id, parsed.device_code).await {
|
||||
Ok((_status, payload)) => tool_success_response(id, payload),
|
||||
Err((_status, message)) => tool_error_response(id, message),
|
||||
}
|
||||
}
|
||||
_ => tool_error_response(id, format!("unknown bootstrap tool `{tool_name}`")),
|
||||
}
|
||||
}
|
||||
|
||||
fn bootstrap_tool_allowed_in_phase(tool_name: &str, phase: GatewayPhase) -> bool {
|
||||
is_status_tool(tool_name) || (phase != GatewayPhase::Ready && is_bind_tool(tool_name))
|
||||
}
|
||||
|
||||
fn is_status_tool(tool_name: &str) -> bool {
|
||||
matches!(
|
||||
tool_name,
|
||||
"local_status" | "local_unlock_status" | "local_onboarding_info"
|
||||
)
|
||||
}
|
||||
|
||||
fn is_bind_tool(tool_name: &str) -> bool {
|
||||
matches!(tool_name, "local_bind_start" | "local_bind_exchange")
|
||||
}
|
||||
|
||||
fn is_bootstrap_tool(tool_name: &str) -> bool {
|
||||
is_status_tool(tool_name) || is_bind_tool(tool_name)
|
||||
}
|
||||
|
||||
fn is_ready_tool(tool_name: &str) -> bool {
|
||||
matches!(
|
||||
tool_name,
|
||||
"secrets_find"
|
||||
| "secrets_search"
|
||||
| "secrets_history"
|
||||
| "secrets_overview"
|
||||
| "secrets_delete"
|
||||
| LOCAL_EXEC_TOOL
|
||||
)
|
||||
}
|
||||
|
||||
fn not_ready_message(status: &Value) -> String {
|
||||
let onboarding_url = status
|
||||
.get("onboarding_url")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("/");
|
||||
let state_name = status
|
||||
.get("state")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("bootstrap");
|
||||
format!(
|
||||
"local MCP is not ready (state: {state_name}). Use local_status/local_bind_start first and ask the user to complete onboarding at {onboarding_url}"
|
||||
)
|
||||
}
|
||||
|
||||
async fn handle_ready_tool(
|
||||
state: &AppState,
|
||||
tool_name: &str,
|
||||
id: Value,
|
||||
args: Option<Value>,
|
||||
) -> Response {
|
||||
let api_key = {
|
||||
let guard = state.cache.read().await;
|
||||
match guard.bound.as_ref() {
|
||||
Some(bound) => bound.api_key.clone(),
|
||||
None => return tool_error_response(id, "local MCP is not bound"),
|
||||
}
|
||||
};
|
||||
let args_value = args.unwrap_or_else(|| json!({}));
|
||||
let result = match tool_name {
|
||||
"secrets_find" => state.remote.entries_find(&api_key, &args_value).await,
|
||||
"secrets_search" => state.remote.entries_search(&api_key, &args_value).await,
|
||||
"secrets_history" => state.remote.entry_history(&api_key, &args_value).await,
|
||||
"secrets_overview" => state.remote.entries_overview(&api_key).await,
|
||||
"secrets_delete" => {
|
||||
if args_value.get("dry_run").and_then(|value| value.as_bool()) != Some(true) {
|
||||
return tool_error_response(
|
||||
id,
|
||||
"secrets_delete is exposed in local mode only for dry_run=true previews",
|
||||
);
|
||||
}
|
||||
state.remote.delete_preview(&api_key, &args_value).await
|
||||
}
|
||||
LOCAL_EXEC_TOOL => return handle_target_exec(state, id, Some(args_value)).await,
|
||||
_ => return tool_error_response(id, format!("unknown ready tool `{tool_name}`")),
|
||||
};
|
||||
match result {
|
||||
Ok(value) => tool_success_response(id, value),
|
||||
Err(err) => tool_error_response(id, err.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn handle_mcp(State(state): State<AppState>, body: Body) -> Result<Response, Infallible> {
|
||||
let body_bytes = match axum::body::to_bytes(body, 10 * 1024 * 1024).await {
|
||||
Ok(bytes) => bytes,
|
||||
Err(_) => return Ok(invalid_request_response("invalid request body")),
|
||||
};
|
||||
let request: Value = match serde_json::from_slice(&body_bytes) {
|
||||
Ok(request) => request,
|
||||
Err(err) => {
|
||||
return Ok(invalid_request_response(format!(
|
||||
"invalid json body: {err}"
|
||||
)));
|
||||
}
|
||||
};
|
||||
let method = request
|
||||
.get("method")
|
||||
.and_then(|value| value.as_str())
|
||||
.unwrap_or_default();
|
||||
let id = request.get("id").cloned().unwrap_or(json!(null));
|
||||
let (phase, status) = current_phase_and_status(&state).await;
|
||||
|
||||
let response = match method {
|
||||
"initialize" => initialize_response(id, phase),
|
||||
"notifications/initialized" => empty_notification_response(),
|
||||
"tools/list" => jsonrpc_result_response(id, json!({ "tools": tools_for_phase(phase) })),
|
||||
"tools/call" => {
|
||||
let params = request.get("params").cloned().unwrap_or_else(|| json!({}));
|
||||
let tool_name = params
|
||||
.get("name")
|
||||
.and_then(|value| value.as_str())
|
||||
.unwrap_or_default();
|
||||
let args = params.get("arguments").cloned();
|
||||
if is_bootstrap_tool(tool_name) {
|
||||
if !bootstrap_tool_allowed_in_phase(tool_name, phase) {
|
||||
tool_error_response(
|
||||
id,
|
||||
"local MCP is already ready; binding tools are disabled until you explicitly unbind",
|
||||
)
|
||||
} else {
|
||||
handle_bootstrap_tool(&state, tool_name, id, args).await
|
||||
}
|
||||
} else if phase != GatewayPhase::Ready {
|
||||
tool_error_response(id, not_ready_message(&status))
|
||||
} else if is_ready_tool(tool_name) {
|
||||
handle_ready_tool(&state, tool_name, id, args).await
|
||||
} else {
|
||||
tool_error_response(
|
||||
id,
|
||||
format!("tool `{tool_name}` is not exposed by local policy"),
|
||||
)
|
||||
}
|
||||
}
|
||||
"ping" => jsonrpc_result_response(id, json!({})),
|
||||
_ => method_not_found(id, method),
|
||||
};
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::cache::{BoundState, UnlockState, new_cache};
|
||||
use crate::config::LocalConfig;
|
||||
use crate::remote::RemoteClient;
|
||||
use crate::server::AppState;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use url::Url;
|
||||
use uuid::Uuid;
|
||||
|
||||
fn test_state() -> AppState {
|
||||
AppState {
|
||||
config: LocalConfig {
|
||||
bind: "127.0.0.1:9316".parse().unwrap(),
|
||||
remote_base_url: Url::parse("https://example.com").unwrap(),
|
||||
default_unlock_ttl: Duration::from_secs(3600),
|
||||
default_exec_context_ttl: Duration::from_secs(3600),
|
||||
},
|
||||
cache: new_cache(),
|
||||
remote: Arc::new(
|
||||
RemoteClient::new(Url::parse("https://example.com").unwrap()).unwrap(),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bootstrap_phase_hides_ready_tools() {
|
||||
let tools = tools_for_phase(GatewayPhase::Bootstrap);
|
||||
let names: Vec<_> = tools
|
||||
.iter()
|
||||
.filter_map(|tool| tool.get("name").and_then(|value| value.as_str()))
|
||||
.collect();
|
||||
assert!(names.contains(&"local_status"));
|
||||
assert!(names.contains(&"local_bind_start"));
|
||||
assert!(!names.contains(&"secrets_find"));
|
||||
assert!(!names.contains(&LOCAL_EXEC_TOOL));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn initialize_succeeds_when_unbound() {
|
||||
let response = handle_mcp(
|
||||
State(test_state()),
|
||||
Body::from(
|
||||
json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"method": "initialize",
|
||||
"params": {}
|
||||
})
|
||||
.to_string(),
|
||||
),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tools_list_returns_bootstrap_tools_when_unbound() {
|
||||
let response = handle_mcp(
|
||||
State(test_state()),
|
||||
Body::from(
|
||||
json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": 2,
|
||||
"method": "tools/list",
|
||||
"params": {}
|
||||
})
|
||||
.to_string(),
|
||||
),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let bytes = axum::body::to_bytes(response.into_body(), 1024 * 1024)
|
||||
.await
|
||||
.unwrap();
|
||||
let value: Value = serde_json::from_slice(&bytes).unwrap();
|
||||
let names: Vec<_> = value["result"]["tools"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter_map(|tool| tool.get("name").and_then(|name| name.as_str()))
|
||||
.collect();
|
||||
assert!(names.contains(&"local_status"));
|
||||
assert!(names.contains(&"local_bind_exchange"));
|
||||
assert!(!names.contains(&"secrets_find"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tools_list_in_ready_phase_exposes_business_tools() {
|
||||
let state = test_state();
|
||||
{
|
||||
let mut guard = state.cache.write().await;
|
||||
guard.bound = Some(BoundState {
|
||||
user_id: Uuid::nil(),
|
||||
api_key: "api-key".to_string(),
|
||||
key_salt_hex: None,
|
||||
key_check_hex: None,
|
||||
key_params: None,
|
||||
key_version: 0,
|
||||
bound_at: Instant::now(),
|
||||
});
|
||||
guard.unlock = Some(UnlockState {
|
||||
encryption_key_hex: "11".repeat(32),
|
||||
expires_at: Instant::now() + Duration::from_secs(600),
|
||||
last_used_at: Instant::now(),
|
||||
});
|
||||
}
|
||||
let response = handle_mcp(
|
||||
State(state),
|
||||
Body::from(
|
||||
json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": 3,
|
||||
"method": "tools/list",
|
||||
"params": {}
|
||||
})
|
||||
.to_string(),
|
||||
),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let bytes = axum::body::to_bytes(response.into_body(), 1024 * 1024)
|
||||
.await
|
||||
.unwrap();
|
||||
let value: Value = serde_json::from_slice(&bytes).unwrap();
|
||||
let names: Vec<_> = value["result"]["tools"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter_map(|tool| tool.get("name").and_then(|name| name.as_str()))
|
||||
.collect();
|
||||
assert!(names.contains(&"local_status"));
|
||||
assert!(names.contains(&"secrets_find"));
|
||||
assert!(names.contains(&LOCAL_EXEC_TOOL));
|
||||
assert!(!names.contains(&"local_bind_start"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tools_call_rejects_bind_start_when_ready() {
|
||||
let state = test_state();
|
||||
{
|
||||
let mut guard = state.cache.write().await;
|
||||
guard.bound = Some(BoundState {
|
||||
user_id: Uuid::nil(),
|
||||
api_key: "api-key".to_string(),
|
||||
key_salt_hex: None,
|
||||
key_check_hex: None,
|
||||
key_params: None,
|
||||
key_version: 0,
|
||||
bound_at: Instant::now(),
|
||||
});
|
||||
guard.unlock = Some(UnlockState {
|
||||
encryption_key_hex: "11".repeat(32),
|
||||
expires_at: Instant::now() + Duration::from_secs(600),
|
||||
last_used_at: Instant::now(),
|
||||
});
|
||||
}
|
||||
let response = handle_mcp(
|
||||
State(state),
|
||||
Body::from(
|
||||
json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": 4,
|
||||
"method": "tools/call",
|
||||
"params": {
|
||||
"name": "local_bind_start",
|
||||
"arguments": {}
|
||||
}
|
||||
})
|
||||
.to_string(),
|
||||
),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let bytes = axum::body::to_bytes(response.into_body(), 1024 * 1024)
|
||||
.await
|
||||
.unwrap();
|
||||
let value: Value = serde_json::from_slice(&bytes).unwrap();
|
||||
assert_eq!(value["result"]["isError"], Value::Bool(true));
|
||||
assert!(value.get("error").is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tool_error_response_uses_mcp_tool_result_shape() {
|
||||
let response = tool_error_response(json!(9), "boom");
|
||||
let bytes = axum::body::to_bytes(response.into_body(), 1024 * 1024)
|
||||
.await
|
||||
.unwrap();
|
||||
let value: Value = serde_json::from_slice(&bytes).unwrap();
|
||||
assert_eq!(value["id"], json!(9));
|
||||
assert_eq!(value["result"]["isError"], Value::Bool(true));
|
||||
assert_eq!(value["result"]["content"][0]["text"], json!("boom"));
|
||||
assert!(value.get("error").is_none());
|
||||
}
|
||||
}
|
||||
263
crates/secrets-mcp-local/src/remote.rs
Normal file
263
crates/secrets-mcp-local/src/remote.rs
Normal file
@@ -0,0 +1,263 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use anyhow::{Context, Result, anyhow};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use url::Url;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct RemoteClient {
|
||||
pub http_client: reqwest::Client,
|
||||
pub remote_base_url: Url,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
pub struct BindStartResponse {
|
||||
pub bind_id: String,
|
||||
pub device_code: String,
|
||||
pub approve_url: String,
|
||||
pub expires_in_secs: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
pub struct BindExchangeResponse {
|
||||
pub status: Option<String>,
|
||||
pub user_id: Option<Uuid>,
|
||||
pub api_key: Option<String>,
|
||||
pub key_salt_hex: Option<String>,
|
||||
pub key_check_hex: Option<String>,
|
||||
pub key_params: Option<Value>,
|
||||
pub key_version: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct BindExchangeResult {
|
||||
pub status: u16,
|
||||
pub body: Value,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
pub struct BindRefreshResponse {
|
||||
pub user_id: Uuid,
|
||||
pub key_salt_hex: Option<String>,
|
||||
pub key_check_hex: Option<String>,
|
||||
pub key_params: Option<Value>,
|
||||
pub key_version: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct BindRefreshResult {
|
||||
pub status: u16,
|
||||
pub body: Option<BindRefreshResponse>,
|
||||
}
|
||||
|
||||
impl RemoteClient {
|
||||
pub fn new(remote_base_url: Url) -> Result<Self> {
|
||||
let http_client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(120))
|
||||
.build()
|
||||
.context("failed to build HTTP client")?;
|
||||
Ok(Self {
|
||||
http_client,
|
||||
remote_base_url,
|
||||
})
|
||||
}
|
||||
|
||||
fn authed_request(
|
||||
&self,
|
||||
method: reqwest::Method,
|
||||
path: &str,
|
||||
api_key: &str,
|
||||
encryption_key_hex: Option<&str>,
|
||||
) -> reqwest::RequestBuilder {
|
||||
let mut url = self.remote_base_url.clone();
|
||||
url.set_path(path);
|
||||
let mut req = self
|
||||
.http_client
|
||||
.request(method, url.as_str())
|
||||
.bearer_auth(api_key)
|
||||
.header(reqwest::header::ACCEPT, "application/json");
|
||||
if let Some(key) = encryption_key_hex {
|
||||
req = req.header("X-Encryption-Key", key);
|
||||
}
|
||||
req
|
||||
}
|
||||
|
||||
async fn parse_json_response(
|
||||
&self,
|
||||
res: reqwest::Response,
|
||||
label: &str,
|
||||
) -> Result<serde_json::Value> {
|
||||
let status = res.status();
|
||||
let bytes = res
|
||||
.bytes()
|
||||
.await
|
||||
.with_context(|| format!("{label} body read failed"))?;
|
||||
let value = if bytes.is_empty() {
|
||||
Value::Null
|
||||
} else {
|
||||
serde_json::from_slice::<Value>(&bytes).unwrap_or_else(|_| {
|
||||
Value::String(String::from_utf8_lossy(&bytes).trim().to_string())
|
||||
})
|
||||
};
|
||||
if !status.is_success() {
|
||||
let message = value
|
||||
.get("error")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(ToOwned::to_owned)
|
||||
.unwrap_or_else(|| value.to_string());
|
||||
return Err(anyhow!("{label} failed ({}): {message}", status));
|
||||
}
|
||||
Ok(value)
|
||||
}
|
||||
|
||||
pub async fn bind_start(&self) -> Result<BindStartResponse> {
|
||||
let mut url = self.remote_base_url.clone();
|
||||
url.set_path("/api/local-mcp/bind/start");
|
||||
let res = self
|
||||
.http_client
|
||||
.post(url.as_str())
|
||||
.send()
|
||||
.await
|
||||
.context("bind/start request failed")?;
|
||||
if !res.status().is_success() {
|
||||
return Err(anyhow!("bind/start failed: {}", res.status()));
|
||||
}
|
||||
res.json::<BindStartResponse>()
|
||||
.await
|
||||
.context("invalid bind/start response")
|
||||
}
|
||||
|
||||
pub async fn bind_exchange(
|
||||
&self,
|
||||
bind_id: &str,
|
||||
device_code: &str,
|
||||
) -> Result<BindExchangeResult> {
|
||||
let mut url = self.remote_base_url.clone();
|
||||
url.set_path("/api/local-mcp/bind/exchange");
|
||||
let res = self
|
||||
.http_client
|
||||
.post(url.as_str())
|
||||
.json(&serde_json::json!({
|
||||
"bind_id": bind_id,
|
||||
"device_code": device_code,
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.context("bind/exchange request failed")?;
|
||||
let status = res.status().as_u16();
|
||||
let bytes = res
|
||||
.bytes()
|
||||
.await
|
||||
.context("bind/exchange body read failed")?;
|
||||
let body = if bytes.is_empty() {
|
||||
Value::Null
|
||||
} else {
|
||||
serde_json::from_slice::<Value>(&bytes).unwrap_or_else(|_| {
|
||||
Value::String(String::from_utf8_lossy(&bytes).trim().to_string())
|
||||
})
|
||||
};
|
||||
Ok(BindExchangeResult { status, body })
|
||||
}
|
||||
|
||||
pub async fn bind_refresh(&self, api_key: &str) -> Result<BindRefreshResult> {
|
||||
let mut url = self.remote_base_url.clone();
|
||||
url.set_path("/api/local-mcp/bind/refresh");
|
||||
let res = self
|
||||
.http_client
|
||||
.post(url.as_str())
|
||||
.header(
|
||||
axum::http::header::AUTHORIZATION,
|
||||
format!("Bearer {api_key}"),
|
||||
)
|
||||
.send()
|
||||
.await
|
||||
.context("bind/refresh request failed")?;
|
||||
let status = res.status().as_u16();
|
||||
if !res.status().is_success() {
|
||||
return Ok(BindRefreshResult { status, body: None });
|
||||
}
|
||||
let body = res
|
||||
.json::<BindRefreshResponse>()
|
||||
.await
|
||||
.context("invalid bind/refresh response")?;
|
||||
Ok(BindRefreshResult {
|
||||
status,
|
||||
body: Some(body),
|
||||
})
|
||||
}
|
||||
|
||||
async fn post_api_json(
|
||||
&self,
|
||||
api_key: &str,
|
||||
encryption_key_hex: Option<&str>,
|
||||
path: &str,
|
||||
body: &Value,
|
||||
) -> Result<Value> {
|
||||
let res = self
|
||||
.authed_request(reqwest::Method::POST, path, api_key, encryption_key_hex)
|
||||
.json(body)
|
||||
.send()
|
||||
.await
|
||||
.with_context(|| format!("{path} request failed"))?;
|
||||
self.parse_json_response(res, path).await
|
||||
}
|
||||
|
||||
async fn get_api_json(
|
||||
&self,
|
||||
api_key: &str,
|
||||
encryption_key_hex: Option<&str>,
|
||||
path: &str,
|
||||
) -> Result<reqwest::Response> {
|
||||
let req = self.authed_request(reqwest::Method::GET, path, api_key, encryption_key_hex);
|
||||
let res = req
|
||||
.send()
|
||||
.await
|
||||
.with_context(|| format!("{path} request failed"))?;
|
||||
Ok(res)
|
||||
}
|
||||
|
||||
pub async fn entries_find(&self, api_key: &str, args: &Value) -> Result<Value> {
|
||||
self.post_api_json(api_key, None, "/api/local-mcp/entries/find", args)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn entries_search(&self, api_key: &str, args: &Value) -> Result<Value> {
|
||||
self.post_api_json(api_key, None, "/api/local-mcp/entries/search", args)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn entry_history(&self, api_key: &str, args: &Value) -> Result<Value> {
|
||||
self.post_api_json(api_key, None, "/api/local-mcp/entries/history", args)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn entries_overview(&self, api_key: &str) -> Result<Value> {
|
||||
let res = self
|
||||
.get_api_json(api_key, None, "/api/local-mcp/entries/overview")
|
||||
.await?;
|
||||
self.parse_json_response(res, "/api/local-mcp/entries/overview")
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn delete_preview(&self, api_key: &str, args: &Value) -> Result<Value> {
|
||||
self.post_api_json(api_key, None, "/api/local-mcp/entries/delete-preview", args)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn get_entry_secrets_by_id(
|
||||
&self,
|
||||
api_key: &str,
|
||||
encryption_key_hex: &str,
|
||||
entry_id: &str,
|
||||
) -> Result<HashMap<String, Value>> {
|
||||
let path = format!("/api/local-mcp/entries/{entry_id}/secrets");
|
||||
let res = self
|
||||
.get_api_json(api_key, Some(encryption_key_hex), &path)
|
||||
.await?;
|
||||
let value = self.parse_json_response(res, &path).await?;
|
||||
serde_json::from_value::<HashMap<String, Value>>(value)
|
||||
.context("invalid decrypt payload from remote HTTP API")
|
||||
}
|
||||
}
|
||||
157
crates/secrets-mcp-local/src/server.rs
Normal file
157
crates/secrets-mcp-local/src/server.rs
Normal file
@@ -0,0 +1,157 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::Router;
|
||||
use axum::extract::State;
|
||||
use axum::response::{Html, IntoResponse};
|
||||
use axum::routing::{get, post};
|
||||
|
||||
use crate::cache::SharedCache;
|
||||
use crate::config::LocalConfig;
|
||||
use crate::remote::RemoteClient;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AppState {
|
||||
pub config: LocalConfig,
|
||||
pub cache: SharedCache,
|
||||
pub remote: Arc<RemoteClient>,
|
||||
}
|
||||
|
||||
async fn index(State(state): State<AppState>) -> impl IntoResponse {
|
||||
Html(format!(
|
||||
r#"<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>secrets-mcp-local onboarding</title>
|
||||
<style>
|
||||
body {{ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; max-width: 920px; margin: 24px auto; padding: 0 16px; line-height: 1.5; }}
|
||||
code, pre {{ background: #f6f8fa; border-radius: 6px; }}
|
||||
code {{ padding: 2px 6px; }}
|
||||
pre {{ padding: 12px; overflow-x: auto; }}
|
||||
.card {{ border: 1px solid #d0d7de; border-radius: 12px; padding: 16px; margin: 16px 0; }}
|
||||
.row {{ display: flex; gap: 12px; flex-wrap: wrap; align-items: center; }}
|
||||
button, a.button {{ border: 1px solid #1f2328; background: #1f2328; color: white; padding: 8px 14px; border-radius: 8px; text-decoration: none; cursor: pointer; }}
|
||||
a.secondary, button.secondary {{ background: white; color: #1f2328; }}
|
||||
iframe {{ width: 100%; min-height: 420px; border: 1px solid #d0d7de; border-radius: 12px; }}
|
||||
.muted {{ color: #57606a; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>secrets-mcp-local</h1>
|
||||
<p class="muted">本地 MCP 地址:<code>http://{bind}/mcp</code></p>
|
||||
<p class="muted">远端服务地址:<code>{remote}</code></p>
|
||||
|
||||
<div class="card">
|
||||
<h2>当前状态</h2>
|
||||
<pre id="status">loading...</pre>
|
||||
<div class="row">
|
||||
<button id="start-bind">开始绑定</button>
|
||||
<button id="poll-bind" class="secondary">检查授权结果</button>
|
||||
<a class="button secondary" href="/unlock" target="_blank" rel="noreferrer">打开解锁页</a>
|
||||
<button id="refresh" class="secondary">刷新状态</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>步骤 1:远端授权</h2>
|
||||
<p id="approve-hint" class="muted">点击“开始绑定”后,这里会显示授权地址。</p>
|
||||
<div id="approve-actions" class="row"></div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>步骤 2:本地解锁</h2>
|
||||
<p class="muted">授权完成后,本页会自动切换到解锁阶段。你也可以直接在下方完成解锁。</p>
|
||||
<iframe id="unlock-frame" src="/unlock"></iframe>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>接入 Cursor</h2>
|
||||
<p>把 MCP 地址配置为 <code>http://{bind}/mcp</code>。在未就绪时,AI 只会看到 bootstrap 工具;完成授权和解锁后会自动暴露业务工具。</p>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const statusEl = document.getElementById('status');
|
||||
const approveHint = document.getElementById('approve-hint');
|
||||
const approveActions = document.getElementById('approve-actions');
|
||||
const unlockFrame = document.getElementById('unlock-frame');
|
||||
|
||||
function renderApprove(info) {{
|
||||
approveActions.innerHTML = '';
|
||||
if (!info?.approve_url) return;
|
||||
approveHint.textContent = '请先在浏览器完成远端授权,然后回到这里等待自动进入解锁状态。';
|
||||
const link = document.createElement('a');
|
||||
link.href = info.approve_url;
|
||||
link.target = '_blank';
|
||||
link.rel = 'noreferrer';
|
||||
link.className = 'button';
|
||||
link.textContent = '打开远端授权页';
|
||||
approveActions.appendChild(link);
|
||||
}}
|
||||
|
||||
async function refreshStatus() {{
|
||||
const res = await fetch('/local/status');
|
||||
const data = await res.json();
|
||||
statusEl.textContent = JSON.stringify(data, null, 2);
|
||||
if (data.pending_bind) renderApprove(data.pending_bind);
|
||||
if (data.state === 'ready') {{
|
||||
approveHint.textContent = '本地 MCP 已 ready,可以返回 Cursor 正常使用。';
|
||||
}} else if (data.state === 'pendingUnlock') {{
|
||||
approveHint.textContent = '远端授权已完成,继续在下方完成本地解锁。';
|
||||
}}
|
||||
return data;
|
||||
}}
|
||||
|
||||
async function startBind() {{
|
||||
const res = await fetch('/local/bind/start', {{ method: 'POST' }});
|
||||
const data = await res.json();
|
||||
statusEl.textContent = JSON.stringify(data, null, 2);
|
||||
renderApprove(data);
|
||||
}}
|
||||
|
||||
async function pollBind() {{
|
||||
const res = await fetch('/local/bind/exchange', {{
|
||||
method: 'POST',
|
||||
headers: {{ 'content-type': 'application/json' }},
|
||||
body: JSON.stringify({{}})
|
||||
}});
|
||||
const data = await res.json();
|
||||
statusEl.textContent = JSON.stringify(data, null, 2);
|
||||
await refreshStatus();
|
||||
if (res.ok && data.status === 'bound') {{
|
||||
unlockFrame.src = '/unlock';
|
||||
}}
|
||||
}}
|
||||
|
||||
document.getElementById('start-bind').onclick = startBind;
|
||||
document.getElementById('poll-bind').onclick = pollBind;
|
||||
document.getElementById('refresh').onclick = refreshStatus;
|
||||
window.addEventListener('message', (event) => {{
|
||||
if (event?.data?.type === 'secrets-mcp-local-ready') refreshStatus();
|
||||
}});
|
||||
refreshStatus();
|
||||
setInterval(refreshStatus, 3000);
|
||||
</script>
|
||||
</body>
|
||||
</html>"#,
|
||||
bind = state.config.bind,
|
||||
remote = state.config.remote_base_url,
|
||||
))
|
||||
}
|
||||
|
||||
pub fn router(state: AppState) -> Router {
|
||||
Router::new()
|
||||
.route("/", get(index))
|
||||
.route("/mcp", axum::routing::any(crate::mcp::handle_mcp))
|
||||
.route("/local/bind/start", post(crate::bind::bind_start))
|
||||
.route("/local/bind/exchange", post(crate::bind::bind_exchange))
|
||||
.route("/local/unbind", post(crate::bind::unbind))
|
||||
.route("/unlock", get(crate::unlock::unlock_page))
|
||||
.route(
|
||||
"/local/unlock/complete",
|
||||
post(crate::unlock::unlock_complete),
|
||||
)
|
||||
.route("/local/lock", post(crate::unlock::lock))
|
||||
.route("/local/status", get(crate::unlock::status))
|
||||
.layer(axum::extract::DefaultBodyLimit::max(10 * 1024 * 1024))
|
||||
.with_state(state)
|
||||
}
|
||||
263
crates/secrets-mcp-local/src/target.rs
Normal file
263
crates/secrets-mcp-local/src/target.rs
Normal file
@@ -0,0 +1,263 @@
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
|
||||
use anyhow::{Result, anyhow};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
pub struct SecretFieldRef {
|
||||
pub name: String,
|
||||
#[serde(rename = "type")]
|
||||
pub secret_type: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
pub struct TargetSnapshot {
|
||||
pub id: String,
|
||||
pub folder: String,
|
||||
pub name: String,
|
||||
#[serde(rename = "type")]
|
||||
pub entry_type: Option<String>,
|
||||
#[serde(default)]
|
||||
pub notes: Option<String>,
|
||||
#[serde(default)]
|
||||
pub metadata: Map<String, Value>,
|
||||
#[serde(default)]
|
||||
pub secret_fields: Vec<SecretFieldRef>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct ResolvedTarget {
|
||||
pub id: String,
|
||||
pub folder: String,
|
||||
pub name: String,
|
||||
#[serde(rename = "type")]
|
||||
pub entry_type: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ExecutionTarget {
|
||||
pub resolved: ResolvedTarget,
|
||||
pub env: BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
impl ExecutionTarget {
|
||||
pub fn resolved_env_keys(&self) -> Vec<String> {
|
||||
self.env.keys().cloned().collect()
|
||||
}
|
||||
}
|
||||
|
||||
fn stringify_value(value: &Value) -> Option<String> {
|
||||
match value {
|
||||
Value::Null => None,
|
||||
Value::String(s) => Some(s.clone()),
|
||||
Value::Bool(v) => Some(v.to_string()),
|
||||
Value::Number(v) => Some(v.to_string()),
|
||||
other => serde_json::to_string(other).ok(),
|
||||
}
|
||||
}
|
||||
|
||||
fn sanitize_env_key(key: &str) -> String {
|
||||
let mut out = String::with_capacity(key.len());
|
||||
for ch in key.chars() {
|
||||
if ch.is_ascii_alphanumeric() {
|
||||
out.push(ch.to_ascii_uppercase());
|
||||
} else {
|
||||
out.push('_');
|
||||
}
|
||||
}
|
||||
while out.contains("__") {
|
||||
out = out.replace("__", "_");
|
||||
}
|
||||
out.trim_matches('_').to_string()
|
||||
}
|
||||
|
||||
fn set_if_missing(env: &mut BTreeMap<String, String>, key: &str, value: Option<String>) {
|
||||
if let Some(value) = value.filter(|v| !v.is_empty()) {
|
||||
env.entry(key.to_string()).or_insert(value);
|
||||
}
|
||||
}
|
||||
|
||||
fn metadata_alias(metadata: &Map<String, Value>, keys: &[&str]) -> Option<String> {
|
||||
keys.iter()
|
||||
.find_map(|key| metadata.get(*key))
|
||||
.and_then(stringify_value)
|
||||
}
|
||||
|
||||
fn secret_alias(
|
||||
secrets: &HashMap<String, Value>,
|
||||
secret_types: &HashMap<&str, Option<&str>>,
|
||||
name_match: impl Fn(&str) -> bool,
|
||||
type_match: impl Fn(Option<&str>) -> bool,
|
||||
) -> Option<String> {
|
||||
secrets.iter().find_map(|(name, value)| {
|
||||
let normalized = sanitize_env_key(name);
|
||||
let ty = secret_types.get(name.as_str()).copied().flatten();
|
||||
if name_match(&normalized) || type_match(ty) {
|
||||
stringify_value(value)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub fn build_execution_target(
|
||||
snapshot: &TargetSnapshot,
|
||||
secrets: &HashMap<String, Value>,
|
||||
) -> Result<ExecutionTarget> {
|
||||
if snapshot.id.trim().is_empty() {
|
||||
return Err(anyhow!("target snapshot missing id"));
|
||||
}
|
||||
|
||||
let mut env = BTreeMap::new();
|
||||
env.insert("TARGET_ENTRY_ID".to_string(), snapshot.id.clone());
|
||||
env.insert("TARGET_NAME".to_string(), snapshot.name.clone());
|
||||
env.insert("TARGET_FOLDER".to_string(), snapshot.folder.clone());
|
||||
if let Some(entry_type) = snapshot.entry_type.as_ref().filter(|v| !v.is_empty()) {
|
||||
env.insert("TARGET_TYPE".to_string(), entry_type.clone());
|
||||
}
|
||||
if let Some(notes) = snapshot.notes.as_ref().filter(|v| !v.is_empty()) {
|
||||
env.insert("TARGET_NOTES".to_string(), notes.clone());
|
||||
}
|
||||
|
||||
for (key, value) in &snapshot.metadata {
|
||||
if let Some(value) = stringify_value(value) {
|
||||
let name = sanitize_env_key(key);
|
||||
if !name.is_empty() {
|
||||
env.insert(format!("TARGET_META_{name}"), value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let secret_type_map: HashMap<&str, Option<&str>> = snapshot
|
||||
.secret_fields
|
||||
.iter()
|
||||
.map(|field| (field.name.as_str(), field.secret_type.as_deref()))
|
||||
.collect();
|
||||
|
||||
for (key, value) in secrets {
|
||||
if let Some(value) = stringify_value(value) {
|
||||
let name = sanitize_env_key(key);
|
||||
if !name.is_empty() {
|
||||
env.insert(format!("TARGET_SECRET_{name}"), value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
set_if_missing(
|
||||
&mut env,
|
||||
"TARGET_HOST",
|
||||
metadata_alias(
|
||||
&snapshot.metadata,
|
||||
&["public_ip", "ipv4", "private_ip", "host", "hostname"],
|
||||
),
|
||||
);
|
||||
set_if_missing(
|
||||
&mut env,
|
||||
"TARGET_PORT",
|
||||
metadata_alias(&snapshot.metadata, &["ssh_port", "port"]),
|
||||
);
|
||||
set_if_missing(
|
||||
&mut env,
|
||||
"TARGET_USER",
|
||||
metadata_alias(&snapshot.metadata, &["username", "ssh_user", "user"]),
|
||||
);
|
||||
set_if_missing(
|
||||
&mut env,
|
||||
"TARGET_BASE_URL",
|
||||
metadata_alias(&snapshot.metadata, &["base_url", "url", "endpoint"]),
|
||||
);
|
||||
set_if_missing(
|
||||
&mut env,
|
||||
"TARGET_API_KEY",
|
||||
secret_alias(
|
||||
secrets,
|
||||
&secret_type_map,
|
||||
|name| matches!(name, "API_KEY" | "APIKEY" | "ACCESS_KEY" | "ACCESS_KEY_ID"),
|
||||
|_| false,
|
||||
),
|
||||
);
|
||||
set_if_missing(
|
||||
&mut env,
|
||||
"TARGET_TOKEN",
|
||||
secret_alias(
|
||||
secrets,
|
||||
&secret_type_map,
|
||||
|name| name.contains("TOKEN"),
|
||||
|_| false,
|
||||
),
|
||||
);
|
||||
set_if_missing(
|
||||
&mut env,
|
||||
"TARGET_SSH_KEY",
|
||||
secret_alias(
|
||||
secrets,
|
||||
&secret_type_map,
|
||||
|name| name.contains("SSH") || name.ends_with("PEM"),
|
||||
|ty| ty.is_some_and(|v| v.eq_ignore_ascii_case("ssh-key")),
|
||||
),
|
||||
);
|
||||
|
||||
Ok(ExecutionTarget {
|
||||
resolved: ResolvedTarget {
|
||||
id: snapshot.id.clone(),
|
||||
folder: snapshot.folder.clone(),
|
||||
name: snapshot.name.clone(),
|
||||
entry_type: snapshot.entry_type.clone(),
|
||||
},
|
||||
env,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn build_execution_target_maps_common_aliases() {
|
||||
let snapshot = TargetSnapshot {
|
||||
id: "entry-1".to_string(),
|
||||
folder: "refining".to_string(),
|
||||
name: "hk_api_hub".to_string(),
|
||||
entry_type: Some("server".to_string()),
|
||||
notes: None,
|
||||
metadata: serde_json::from_value(json!({
|
||||
"public_ip": "47.238.146.244",
|
||||
"username": "ecs-user",
|
||||
"base_url": "https://api.refining.dev"
|
||||
}))
|
||||
.unwrap(),
|
||||
secret_fields: vec![
|
||||
SecretFieldRef {
|
||||
name: "api_key".to_string(),
|
||||
secret_type: None,
|
||||
},
|
||||
SecretFieldRef {
|
||||
name: "hk-20240726.pem".to_string(),
|
||||
secret_type: Some("ssh-key".to_string()),
|
||||
},
|
||||
],
|
||||
};
|
||||
let secrets = HashMap::from([
|
||||
("api_key".to_string(), json!("sk_test_123")),
|
||||
(
|
||||
"hk-20240726.pem".to_string(),
|
||||
json!("-----BEGIN PRIVATE KEY-----"),
|
||||
),
|
||||
]);
|
||||
|
||||
let target = build_execution_target(&snapshot, &secrets).unwrap();
|
||||
assert_eq!(target.env.get("TARGET_HOST").unwrap(), "47.238.146.244");
|
||||
assert_eq!(target.env.get("TARGET_USER").unwrap(), "ecs-user");
|
||||
assert_eq!(
|
||||
target.env.get("TARGET_BASE_URL").unwrap(),
|
||||
"https://api.refining.dev"
|
||||
);
|
||||
assert_eq!(target.env.get("TARGET_API_KEY").unwrap(), "sk_test_123");
|
||||
assert_eq!(
|
||||
target.env.get("TARGET_SSH_KEY").unwrap(),
|
||||
"-----BEGIN PRIVATE KEY-----"
|
||||
);
|
||||
}
|
||||
}
|
||||
265
crates/secrets-mcp-local/src/unlock.rs
Normal file
265
crates/secrets-mcp-local/src/unlock.rs
Normal file
@@ -0,0 +1,265 @@
|
||||
use std::time::Instant;
|
||||
|
||||
use axum::extract::State;
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::{Html, IntoResponse};
|
||||
use secrets_core::crypto::{decrypt, extract_key_from_hex, hex};
|
||||
use serde::Deserialize;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::bind::refresh_bound_state;
|
||||
use crate::cache::UnlockState;
|
||||
use crate::server::AppState;
|
||||
|
||||
const KEY_CHECK_PLAINTEXT: &[u8] = b"secrets-mcp-key-check";
|
||||
|
||||
fn verify_key_check_hex(key_hex: &str, key_check_hex: &str) -> Result<(), (StatusCode, String)> {
|
||||
let key_check = hex::decode_hex(key_check_hex).map_err(|e| {
|
||||
(
|
||||
StatusCode::BAD_REQUEST,
|
||||
format!("invalid key_check hex: {e}"),
|
||||
)
|
||||
})?;
|
||||
let user_key = extract_key_from_hex(key_hex).map_err(|e| {
|
||||
(
|
||||
StatusCode::BAD_REQUEST,
|
||||
format!("invalid encryption key: {e}"),
|
||||
)
|
||||
})?;
|
||||
let plaintext = decrypt(&user_key, &key_check)
|
||||
.map_err(|_| (StatusCode::UNAUTHORIZED, "wrong passphrase".to_string()))?;
|
||||
if plaintext != KEY_CHECK_PLAINTEXT {
|
||||
return Err((StatusCode::UNAUTHORIZED, "wrong passphrase".to_string()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct UnlockCompleteBody {
|
||||
encryption_key: String,
|
||||
ttl_secs: Option<u64>,
|
||||
}
|
||||
|
||||
pub async fn unlock_page(State(state): State<AppState>) -> impl IntoResponse {
|
||||
refresh_bound_state(&state).await;
|
||||
let bound = {
|
||||
let guard = state.cache.read().await;
|
||||
guard.bound.clone()
|
||||
};
|
||||
let Some(mut bound) = bound else {
|
||||
return Html(
|
||||
"<h1>Not bound</h1><p>Run /local/bind/start and complete approve first.</p>"
|
||||
.to_string(),
|
||||
);
|
||||
};
|
||||
{
|
||||
let guard = state.cache.read().await;
|
||||
if let Some(updated) = guard.bound.clone() {
|
||||
bound = updated;
|
||||
}
|
||||
}
|
||||
let key_salt_hex = bound.key_salt_hex.as_deref().unwrap_or("");
|
||||
let key_check_hex = bound.key_check_hex.as_deref().unwrap_or("");
|
||||
let iterations = bound
|
||||
.key_params
|
||||
.as_ref()
|
||||
.and_then(|v| v.get("iterations"))
|
||||
.and_then(|n| n.as_u64())
|
||||
.unwrap_or(600_000);
|
||||
|
||||
Html(format!(
|
||||
r#"<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head><meta charset="utf-8"><title>Local MCP Unlock</title></head>
|
||||
<body>
|
||||
<h1>解锁本地 MCP</h1>
|
||||
<p>用户:<code>{user_id}</code></p>
|
||||
<label>Passphrase: <input id="pp" type="password" autocomplete="off"/></label>
|
||||
<label>TTL(sec): <input id="ttl" type="number" value="{ttl}" min="60" max="604800"/></label>
|
||||
<button id="go">Derive and Unlock</button>
|
||||
<pre id="out"></pre>
|
||||
<script>
|
||||
const SALT_HEX = "{salt}";
|
||||
const KEY_CHECK_HEX = "{key_check}";
|
||||
const ITER = {iter};
|
||||
function notifyParentReady() {{
|
||||
try {{
|
||||
window.parent?.postMessage({{type:'secrets-mcp-local-ready'}}, '*');
|
||||
}} catch (_err) {{}}
|
||||
}}
|
||||
function hexToBytes(hex) {{
|
||||
const out = new Uint8Array(hex.length / 2);
|
||||
for (let i = 0; i < out.length; i++) out[i] = parseInt(hex.substr(i*2,2), 16);
|
||||
return out;
|
||||
}}
|
||||
function bytesToHex(bytes) {{
|
||||
return Array.from(bytes).map(b => b.toString(16).padStart(2,'0')).join('');
|
||||
}}
|
||||
async function verifyKeyCheck(hexKey) {{
|
||||
const keyBytes = hexToBytes(hexKey);
|
||||
const cryptoKey = await crypto.subtle.importKey('raw', keyBytes, {{name:'AES-GCM'}}, false, ['decrypt']);
|
||||
const payload = hexToBytes(KEY_CHECK_HEX);
|
||||
const nonce = payload.slice(0, 12);
|
||||
const ciphertext = payload.slice(12);
|
||||
try {{
|
||||
const plain = await crypto.subtle.decrypt({{name:'AES-GCM', iv: nonce}}, cryptoKey, ciphertext);
|
||||
return new TextDecoder().decode(plain) === 'secrets-mcp-key-check';
|
||||
}} catch {{
|
||||
return false;
|
||||
}}
|
||||
}}
|
||||
document.getElementById('go').onclick = async () => {{
|
||||
const pp = document.getElementById('pp').value;
|
||||
const ttl = Number(document.getElementById('ttl').value || {ttl});
|
||||
const out = document.getElementById('out');
|
||||
if (!SALT_HEX) {{ out.textContent = 'key_salt missing; set passphrase on remote first'; return; }}
|
||||
if (!KEY_CHECK_HEX) {{ out.textContent = 'key_check missing; refresh bind first'; return; }}
|
||||
if (!pp) {{ out.textContent = 'passphrase required'; return; }}
|
||||
out.textContent = 'deriving...';
|
||||
try {{
|
||||
const keyMat = await crypto.subtle.importKey('raw', new TextEncoder().encode(pp), {{name:'PBKDF2'}}, false, ['deriveBits']);
|
||||
const bits = await crypto.subtle.deriveBits({{name:'PBKDF2', salt: hexToBytes(SALT_HEX), iterations: ITER, hash: 'SHA-256'}}, keyMat, 256);
|
||||
const hex = bytesToHex(new Uint8Array(bits));
|
||||
const valid = await verifyKeyCheck(hex);
|
||||
if (!valid) {{ out.textContent = 'wrong passphrase'; return; }}
|
||||
const res = await fetch('/local/unlock/complete', {{
|
||||
method:'POST', headers:{{'content-type':'application/json'}},
|
||||
body: JSON.stringify({{encryption_key: hex, ttl_secs: ttl}})
|
||||
}});
|
||||
const txt = await res.text();
|
||||
out.textContent = txt;
|
||||
if (res.ok) notifyParentReady();
|
||||
}} catch (e) {{
|
||||
out.textContent = String(e);
|
||||
}}
|
||||
}};
|
||||
</script>
|
||||
</body>
|
||||
</html>"#,
|
||||
user_id = bound.user_id,
|
||||
ttl = state.config.default_unlock_ttl.as_secs(),
|
||||
salt = key_salt_hex,
|
||||
key_check = key_check_hex,
|
||||
iter = iterations
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn unlock_complete(
|
||||
State(state): State<AppState>,
|
||||
axum::Json(input): axum::Json<UnlockCompleteBody>,
|
||||
) -> Result<impl IntoResponse, (StatusCode, String)> {
|
||||
let key = input.encryption_key.trim();
|
||||
if key.len() != 64 || !key.chars().all(|c| c.is_ascii_hexdigit()) {
|
||||
return Err((
|
||||
StatusCode::BAD_REQUEST,
|
||||
"encryption_key must be 64 hex chars".to_string(),
|
||||
));
|
||||
}
|
||||
let ttl = std::time::Duration::from_secs(
|
||||
input
|
||||
.ttl_secs
|
||||
.unwrap_or(state.config.default_unlock_ttl.as_secs())
|
||||
.clamp(60, 86400 * 7),
|
||||
);
|
||||
let mut guard = state.cache.write().await;
|
||||
let Some(bound) = guard.bound.as_ref() else {
|
||||
return Err((StatusCode::UNAUTHORIZED, "not bound".to_string()));
|
||||
};
|
||||
let key_check_hex = bound
|
||||
.key_check_hex
|
||||
.as_deref()
|
||||
.ok_or((StatusCode::BAD_REQUEST, "key_check missing".to_string()))?;
|
||||
verify_key_check_hex(key, key_check_hex)?;
|
||||
guard.exec_contexts.clear();
|
||||
guard.unlock = Some(UnlockState {
|
||||
encryption_key_hex: key.to_string(),
|
||||
expires_at: Instant::now() + ttl,
|
||||
last_used_at: Instant::now(),
|
||||
});
|
||||
Ok((
|
||||
StatusCode::OK,
|
||||
axum::Json(json!({"ok": true, "ttl_secs": ttl.as_secs()})),
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn lock(State(state): State<AppState>) -> impl IntoResponse {
|
||||
let mut guard = state.cache.write().await;
|
||||
guard.clear_unlock_and_exec();
|
||||
(StatusCode::OK, axum::Json(json!({"ok": true})))
|
||||
}
|
||||
|
||||
pub async fn status(State(state): State<AppState>) -> impl IntoResponse {
|
||||
let payload = status_payload(&state).await;
|
||||
(StatusCode::OK, axum::Json(payload))
|
||||
}
|
||||
|
||||
pub async fn status_payload(state: &AppState) -> serde_json::Value {
|
||||
refresh_bound_state(state).await;
|
||||
let now = Instant::now();
|
||||
let mut guard = state.cache.write().await;
|
||||
let unlocked = guard
|
||||
.unlock
|
||||
.as_ref()
|
||||
.is_some_and(|u| u.expires_at > now && !u.encryption_key_hex.is_empty());
|
||||
let expires_in_secs = guard
|
||||
.unlock
|
||||
.as_ref()
|
||||
.and_then(|u| (u.expires_at > now).then_some(u.expires_at.duration_since(now).as_secs()));
|
||||
if guard.unlock.as_ref().is_some_and(|u| u.expires_at <= now) {
|
||||
guard.clear_unlock_and_exec();
|
||||
}
|
||||
let state_name = guard.phase(now);
|
||||
let bound = guard.bound.as_ref().map(|b| {
|
||||
json!({
|
||||
"user_id": b.user_id,
|
||||
"key_version": b.key_version,
|
||||
"bound_for_secs": b.bound_at.elapsed().as_secs(),
|
||||
})
|
||||
});
|
||||
let pending_bind = guard.pending_bind.as_ref().map(|pending| {
|
||||
json!({
|
||||
"bind_id": pending.bind_id,
|
||||
"device_code": pending.device_code,
|
||||
"approve_url": pending.approve_url,
|
||||
"expires_in_secs": pending.expires_at.saturating_duration_since(now).as_secs(),
|
||||
"started_for_secs": pending.started_at.elapsed().as_secs(),
|
||||
})
|
||||
});
|
||||
json!({
|
||||
"state": state_name,
|
||||
"bound": bound,
|
||||
"pending_bind": pending_bind,
|
||||
"unlocked": unlocked,
|
||||
"expires_in_secs": expires_in_secs,
|
||||
"cached_targets": guard.exec_contexts.len(),
|
||||
"onboarding_url": format!("http://{}/", state.config.bind),
|
||||
"unlock_url": format!("http://{}/unlock", state.config.bind),
|
||||
"mcp_url": format!("http://{}/mcp", state.config.bind),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use secrets_core::crypto::encrypt;
|
||||
|
||||
#[test]
|
||||
fn verify_key_check_accepts_matching_key() {
|
||||
let key_hex = "11".repeat(32);
|
||||
let key = extract_key_from_hex(&key_hex).unwrap();
|
||||
let ciphertext = encrypt(&key, KEY_CHECK_PLAINTEXT).unwrap();
|
||||
let ciphertext_hex = hex::encode_hex(&ciphertext);
|
||||
assert!(verify_key_check_hex(&key_hex, &ciphertext_hex).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verify_key_check_rejects_wrong_key() {
|
||||
let correct_key_hex = "11".repeat(32);
|
||||
let wrong_key_hex = "22".repeat(32);
|
||||
let key = extract_key_from_hex(&correct_key_hex).unwrap();
|
||||
let ciphertext = encrypt(&key, KEY_CHECK_PLAINTEXT).unwrap();
|
||||
let ciphertext_hex = hex::encode_hex(&ciphertext);
|
||||
let err = verify_key_check_hex(&wrong_key_hex, &ciphertext_hex).unwrap_err();
|
||||
assert_eq!(err.0, StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,23 @@
|
||||
本文档在构建时嵌入 Web 的 `/changelog` 页面,并由服务端渲染为 HTML。
|
||||
|
||||
## [0.6.0] - 2026-04-12
|
||||
|
||||
### Changed
|
||||
|
||||
- 重构 `secrets-mcp-local` 为本地 MCP 服务:`initialize` / `tools/list` 在未绑定、未解锁时也始终成功,不再通过连接级 `401` 让 MCP 客户端误判为服务离线。
|
||||
- 本地 gateway 改为三态工具面:`bootstrap` / `pendingUnlock` / `ready`;未就绪时仅暴露 `local_status`、`local_bind_start`、`local_bind_exchange`、`local_unlock_status`、`local_onboarding_info` 等 bootstrap 工具。
|
||||
- 本地首页改为真实 onboarding 页面:可直接发起绑定、展示 `approve_url`、轮询授权结果,并衔接本地 unlock;不再要求用户手工拼 `curl` 请求。
|
||||
- 本地绑定闭环改为持久化短时会话:远程 `secrets-mcp` 新增 `local_mcp_bind_sessions` 存储绑定确认状态,避免仅靠单进程内存状态。
|
||||
- 本地解锁增加 `key_check` 校验与生命周期收敛:浏览器内先验证密码短语,再缓存本地 unlock;当远程 `key_version` 变化、API key 失效或绑定用户缺失时,本地自动失效 unlock 或清除 bound 状态。
|
||||
- 远程 `secrets-mcp` 新增 `/api/local-mcp/entries/find|search|history|overview|delete-preview|{id}/secrets` JSON API;local gateway 的发现、预览删除与解密读取已切到这些 HTTP API,不再依赖远程 `/mcp` 作为运行时后端。
|
||||
- 本地 gateway 新增 `target_exec` 通用代执行能力:AI 可先发现服务器或 API 服务条目,再由 local gateway 内部读取条目密钥并注入 `TARGET_*` 环境变量执行标准命令;执行上下文按 `entry_id` 本地缓存,可在 unlock 生命周期内复用。
|
||||
|
||||
## [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`)。
|
||||
- 工作区新增 **`secrets-mcp-local`** 并升级为本地 MCP 服务:支持 `bind/start -> approve -> bind/exchange -> /unlock` 闭环,复用远程 Web 会话完成本地绑定,浏览器内派生后按 TTL 缓存解锁状态。
|
||||
- 远程 `secrets-mcp` 新增本地绑定 API:`/api/local-mcp/bind/start`、`/api/local-mcp/bind/approve`、`/api/local-mcp/bind/exchange` 以及确认页 `/local-mcp/approve`。
|
||||
|
||||
## [0.5.27] - 2026-04-11
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "secrets-mcp"
|
||||
version = "0.5.28"
|
||||
version = "0.6.0"
|
||||
edition.workspace = true
|
||||
|
||||
[[bin]]
|
||||
|
||||
894
crates/secrets-mcp/src/web/local_mcp.rs
Normal file
894
crates/secrets-mcp/src/web/local_mcp.rs
Normal file
@@ -0,0 +1,894 @@
|
||||
use askama::Template;
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Path, Query, State},
|
||||
http::{HeaderMap, StatusCode, header},
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
use sqlx::PgPool;
|
||||
use tower_sessions::Session;
|
||||
use uuid::Uuid;
|
||||
|
||||
use secrets_core::crypto::hex;
|
||||
use secrets_core::service::api_key::validate_api_key;
|
||||
use secrets_core::service::delete::{DeleteParams, run as svc_delete};
|
||||
use secrets_core::service::get_secret::get_all_secrets_by_id;
|
||||
use secrets_core::service::history::run as svc_history;
|
||||
use secrets_core::service::relations::get_relations_for_entries;
|
||||
use secrets_core::service::search::{
|
||||
SearchParams, count_entries, resolve_entry_by_id, run as svc_search,
|
||||
};
|
||||
use secrets_core::service::user::get_user_by_id;
|
||||
|
||||
use crate::AppState;
|
||||
|
||||
use super::{
|
||||
UiLang, render_template, request_ui_lang, require_valid_user, require_valid_user_json,
|
||||
};
|
||||
|
||||
const BIND_TTL_SECS: u64 = 600;
|
||||
|
||||
#[derive(Clone, sqlx::FromRow)]
|
||||
struct BindRow {
|
||||
device_code: String,
|
||||
user_id: Option<Uuid>,
|
||||
approved: bool,
|
||||
}
|
||||
|
||||
enum ConsumeBindOutcome {
|
||||
Pending,
|
||||
Ready(BindRow),
|
||||
NotFound,
|
||||
DeviceMismatch,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub(super) struct BindStartOutput {
|
||||
bind_id: String,
|
||||
device_code: String,
|
||||
approve_url: String,
|
||||
expires_in_secs: u64,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub(super) struct BindApproveInput {
|
||||
bind_id: String,
|
||||
device_code: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub(super) struct BindExchangeInput {
|
||||
bind_id: String,
|
||||
device_code: String,
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(
|
||||
source = r#"<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head><meta charset="utf-8"><title>Local MCP 绑定确认</title></head>
|
||||
<body>
|
||||
<h1>确认绑定本地 MCP</h1>
|
||||
{% if error.is_some() %}
|
||||
<p style="color:#c00">{{ error.as_ref().unwrap() }}</p>
|
||||
{% endif %}
|
||||
{% if approved %}
|
||||
<p>绑定已确认。你可以返回本地页面继续下一步。</p>
|
||||
{% else %}
|
||||
<p>Bind ID: <code>{{ bind_id }}</code></p>
|
||||
<form method="post" action="/api/local-mcp/bind/approve">
|
||||
<input type="hidden" name="bind_id" value="{{ bind_id }}"/>
|
||||
<input type="hidden" name="device_code" value="{{ device_code }}"/>
|
||||
<button type="submit">确认绑定</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</body>
|
||||
</html>"#,
|
||||
ext = "html"
|
||||
)]
|
||||
struct ApproveTemplate {
|
||||
bind_id: String,
|
||||
device_code: String,
|
||||
approved: bool,
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
async fn cleanup_expired(pool: &PgPool) {
|
||||
let _ = sqlx::query("DELETE FROM local_mcp_bind_sessions WHERE expires_at <= NOW()")
|
||||
.execute(pool)
|
||||
.await;
|
||||
}
|
||||
|
||||
async fn fetch_bind(pool: &PgPool, bind_id: &str) -> Result<Option<BindRow>, StatusCode> {
|
||||
sqlx::query_as::<_, BindRow>(
|
||||
"SELECT device_code, user_id, approved
|
||||
FROM local_mcp_bind_sessions
|
||||
WHERE bind_id = $1 AND expires_at > NOW()",
|
||||
)
|
||||
.bind(bind_id)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!(error = %e, bind_id, "failed to fetch local MCP bind");
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
})
|
||||
}
|
||||
|
||||
async fn require_user_from_bearer(pool: &PgPool, headers: &HeaderMap) -> Result<Uuid, StatusCode> {
|
||||
let auth_header = headers
|
||||
.get(header::AUTHORIZATION)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.ok_or(StatusCode::UNAUTHORIZED)?;
|
||||
let raw_key = auth_header
|
||||
.strip_prefix("Bearer ")
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.ok_or(StatusCode::UNAUTHORIZED)?;
|
||||
validate_api_key(pool, raw_key)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!(error = %e, "failed to validate api key for local MCP refresh");
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
})?
|
||||
.ok_or(StatusCode::UNAUTHORIZED)
|
||||
}
|
||||
|
||||
async fn consume_bind_session(
|
||||
pool: &PgPool,
|
||||
bind_id: &str,
|
||||
device_code: &str,
|
||||
) -> Result<ConsumeBindOutcome, (StatusCode, Json<serde_json::Value>)> {
|
||||
let mut tx = pool.begin().await.map_err(|e| {
|
||||
tracing::error!(error = %e, bind_id, "failed to start tx for bind exchange");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({ "error": "failed to start bind exchange" })),
|
||||
)
|
||||
})?;
|
||||
let stored = sqlx::query_as::<_, BindRow>(
|
||||
"SELECT device_code, user_id, approved
|
||||
FROM local_mcp_bind_sessions
|
||||
WHERE bind_id = $1 AND expires_at > NOW()
|
||||
FOR UPDATE",
|
||||
)
|
||||
.bind(bind_id)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!(error = %e, bind_id, "failed to lock bind session");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({ "error": "failed to load bind session" })),
|
||||
)
|
||||
})?;
|
||||
let Some(bind) = stored else {
|
||||
tx.rollback().await.ok();
|
||||
return Ok(ConsumeBindOutcome::NotFound);
|
||||
};
|
||||
if bind.device_code != device_code {
|
||||
tx.rollback().await.ok();
|
||||
return Ok(ConsumeBindOutcome::DeviceMismatch);
|
||||
}
|
||||
if !bind.approved {
|
||||
tx.rollback().await.ok();
|
||||
return Ok(ConsumeBindOutcome::Pending);
|
||||
}
|
||||
sqlx::query("DELETE FROM local_mcp_bind_sessions WHERE bind_id = $1")
|
||||
.bind(bind_id)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!(error = %e, bind_id, "failed to consume bind session");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({ "error": "failed to consume bind session" })),
|
||||
)
|
||||
})?;
|
||||
tx.commit().await.map_err(|e| {
|
||||
tracing::error!(error = %e, bind_id, "failed to commit bind exchange");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({ "error": "failed to commit bind exchange" })),
|
||||
)
|
||||
})?;
|
||||
Ok(ConsumeBindOutcome::Ready(bind))
|
||||
}
|
||||
|
||||
pub(super) async fn api_bind_start(
|
||||
State(state): State<AppState>,
|
||||
) -> Result<Json<BindStartOutput>, (StatusCode, Json<serde_json::Value>)> {
|
||||
cleanup_expired(&state.pool).await;
|
||||
let bind_id = Uuid::new_v4().to_string();
|
||||
let device_code = Uuid::new_v4().simple().to_string();
|
||||
sqlx::query(
|
||||
"INSERT INTO local_mcp_bind_sessions (bind_id, device_code, expires_at)
|
||||
VALUES ($1, $2, NOW() + ($3 * INTERVAL '1 second'))",
|
||||
)
|
||||
.bind(&bind_id)
|
||||
.bind(&device_code)
|
||||
.bind(BIND_TTL_SECS as i64)
|
||||
.execute(&state.pool)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!(error = %e, bind_id, "failed to insert local MCP bind session");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({ "error": "failed to create bind session" })),
|
||||
)
|
||||
})?;
|
||||
|
||||
let approve_url = format!(
|
||||
"{}/local-mcp/approve?bind_id={}&device_code={}",
|
||||
state.base_url, bind_id, device_code
|
||||
);
|
||||
|
||||
Ok(Json(BindStartOutput {
|
||||
bind_id,
|
||||
device_code,
|
||||
approve_url,
|
||||
expires_in_secs: BIND_TTL_SECS,
|
||||
}))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub(super) struct ApproveQuery {
|
||||
bind_id: String,
|
||||
device_code: String,
|
||||
}
|
||||
|
||||
pub(super) async fn approve_page(
|
||||
State(state): State<AppState>,
|
||||
session: Session,
|
||||
Query(query): Query<ApproveQuery>,
|
||||
) -> Result<Response, Response> {
|
||||
let _user = require_valid_user(&state.pool, &session, "local_mcp.approve_page").await?;
|
||||
|
||||
cleanup_expired(&state.pool).await;
|
||||
let mut approved = false;
|
||||
let mut error = None;
|
||||
|
||||
match fetch_bind(&state.pool, &query.bind_id).await {
|
||||
Ok(Some(bind)) if bind.device_code == query.device_code => approved = bind.approved,
|
||||
Ok(Some(_)) => error = Some("device_code 不匹配".to_string()),
|
||||
Ok(None) => error = Some("绑定已过期或不存在".to_string()),
|
||||
Err(status) => return Err(status.into_response()),
|
||||
}
|
||||
|
||||
render_template(ApproveTemplate {
|
||||
bind_id: query.bind_id,
|
||||
device_code: query.device_code,
|
||||
approved,
|
||||
error,
|
||||
})
|
||||
.map_err(|status| status.into_response())
|
||||
}
|
||||
|
||||
pub(super) async fn api_bind_approve(
|
||||
State(state): State<AppState>,
|
||||
session: Session,
|
||||
headers: axum::http::HeaderMap,
|
||||
axum::Form(input): axum::Form<BindApproveInput>,
|
||||
) -> Result<Response, (StatusCode, Json<serde_json::Value>)> {
|
||||
let lang: UiLang = request_ui_lang(&headers);
|
||||
let user = require_valid_user_json(&state.pool, &session, lang).await?;
|
||||
cleanup_expired(&state.pool).await;
|
||||
|
||||
match fetch_bind(&state.pool, &input.bind_id).await {
|
||||
Ok(Some(bind)) if bind.device_code == input.device_code => {
|
||||
sqlx::query(
|
||||
"UPDATE local_mcp_bind_sessions
|
||||
SET user_id = $1, approved = TRUE
|
||||
WHERE bind_id = $2 AND expires_at > NOW()",
|
||||
)
|
||||
.bind(user.id)
|
||||
.bind(&input.bind_id)
|
||||
.execute(&state.pool)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!(error = %e, bind_id = %input.bind_id, "failed to approve bind session");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({ "error": "failed to approve bind session" })),
|
||||
)
|
||||
})?;
|
||||
Ok(axum::response::Redirect::to(&format!(
|
||||
"/local-mcp/approve?bind_id={}&device_code={}&approved=1",
|
||||
input.bind_id, input.device_code
|
||||
))
|
||||
.into_response())
|
||||
}
|
||||
Ok(Some(_)) => Err((
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({ "error": "device_code mismatch" })),
|
||||
)),
|
||||
Ok(None) => Err((
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(json!({ "error": "bind session not found or expired" })),
|
||||
)),
|
||||
Err(status) => Err((
|
||||
status,
|
||||
Json(json!({ "error": "failed to load bind session" })),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) async fn api_bind_exchange(
|
||||
State(state): State<AppState>,
|
||||
Json(input): Json<BindExchangeInput>,
|
||||
) -> Result<Response, (StatusCode, Json<serde_json::Value>)> {
|
||||
cleanup_expired(&state.pool).await;
|
||||
|
||||
let bind = match consume_bind_session(&state.pool, &input.bind_id, &input.device_code).await? {
|
||||
ConsumeBindOutcome::Pending => {
|
||||
return Ok((StatusCode::ACCEPTED, Json(json!({ "status": "pending" }))).into_response());
|
||||
}
|
||||
ConsumeBindOutcome::NotFound => {
|
||||
return Err((
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(json!({ "error": "bind session not found or expired" })),
|
||||
));
|
||||
}
|
||||
ConsumeBindOutcome::DeviceMismatch => {
|
||||
return Err((
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({ "error": "device_code mismatch" })),
|
||||
));
|
||||
}
|
||||
ConsumeBindOutcome::Ready(bind) => bind,
|
||||
};
|
||||
let user_id = bind.user_id.ok_or_else(|| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({ "error": "approved bind missing user_id" })),
|
||||
)
|
||||
})?;
|
||||
let user = get_user_by_id(&state.pool, user_id)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({ "error": format!("failed to load user: {e}") })),
|
||||
)
|
||||
})?
|
||||
.ok_or_else(|| {
|
||||
(
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(json!({ "error": "user not found" })),
|
||||
)
|
||||
})?;
|
||||
|
||||
let key_salt_hex = user.key_salt.as_ref().map(|bytes| {
|
||||
bytes
|
||||
.iter()
|
||||
.map(|b| format!("{:02x}", b))
|
||||
.collect::<String>()
|
||||
});
|
||||
let key_check_hex = user.key_check.as_deref().map(hex::encode_hex);
|
||||
|
||||
Ok((
|
||||
StatusCode::OK,
|
||||
Json(json!({
|
||||
"status": "ok",
|
||||
"user_id": user.id,
|
||||
"api_key": user.api_key,
|
||||
"key_salt_hex": key_salt_hex,
|
||||
"key_check_hex": key_check_hex,
|
||||
"key_params": user.key_params,
|
||||
"key_version": user.key_version,
|
||||
})),
|
||||
)
|
||||
.into_response())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use secrets_core::{
|
||||
config::resolve_db_config,
|
||||
db::{create_pool, migrate},
|
||||
};
|
||||
|
||||
async fn test_pool() -> Option<PgPool> {
|
||||
let config = resolve_db_config("").ok()?;
|
||||
let pool = create_pool(&config).await.ok()?;
|
||||
migrate(&pool).await.ok()?;
|
||||
Some(pool)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn consume_bind_session_is_single_use() {
|
||||
let Some(pool) = test_pool().await else {
|
||||
return;
|
||||
};
|
||||
let bind_id = format!("test-{}", Uuid::new_v4());
|
||||
let device_code = Uuid::new_v4().simple().to_string();
|
||||
let user_id = Uuid::new_v4();
|
||||
sqlx::query(
|
||||
"INSERT INTO local_mcp_bind_sessions (bind_id, device_code, user_id, approved, expires_at)
|
||||
VALUES ($1, $2, $3, TRUE, NOW() + INTERVAL '10 minutes')",
|
||||
)
|
||||
.bind(&bind_id)
|
||||
.bind(&device_code)
|
||||
.bind(user_id)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let first = consume_bind_session(&pool, &bind_id, &device_code)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(matches!(first, ConsumeBindOutcome::Ready(_)));
|
||||
let second = consume_bind_session(&pool, &bind_id, &device_code)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(matches!(second, ConsumeBindOutcome::NotFound));
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) async fn api_bind_refresh(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
) -> Result<Response, (StatusCode, Json<serde_json::Value>)> {
|
||||
let user_id = require_user_from_bearer(&state.pool, &headers)
|
||||
.await
|
||||
.map_err(|status| (status, Json(json!({ "error": "unauthorized" }))))?;
|
||||
let user = get_user_by_id(&state.pool, user_id)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({ "error": format!("failed to load user: {e}") })),
|
||||
)
|
||||
})?
|
||||
.ok_or_else(|| {
|
||||
(
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(json!({ "error": "user not found" })),
|
||||
)
|
||||
})?;
|
||||
Ok((
|
||||
StatusCode::OK,
|
||||
Json(json!({
|
||||
"user_id": user.id,
|
||||
"key_salt_hex": user.key_salt.as_deref().map(hex::encode_hex),
|
||||
"key_check_hex": user.key_check.as_deref().map(hex::encode_hex),
|
||||
"key_params": user.key_params,
|
||||
"key_version": user.key_version,
|
||||
})),
|
||||
)
|
||||
.into_response())
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub(super) struct LocalSearchInput {
|
||||
query: Option<String>,
|
||||
metadata_query: Option<String>,
|
||||
folder: Option<String>,
|
||||
#[serde(rename = "type")]
|
||||
entry_type: Option<String>,
|
||||
name: Option<String>,
|
||||
name_query: Option<String>,
|
||||
tags: Option<Vec<String>>,
|
||||
summary: Option<bool>,
|
||||
sort: Option<String>,
|
||||
limit: Option<u32>,
|
||||
offset: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub(super) struct LocalHistoryInput {
|
||||
name: Option<String>,
|
||||
folder: Option<String>,
|
||||
id: Option<Uuid>,
|
||||
limit: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub(super) struct LocalDeleteInput {
|
||||
id: Option<Uuid>,
|
||||
name: Option<String>,
|
||||
folder: Option<String>,
|
||||
#[serde(rename = "type")]
|
||||
entry_type: Option<String>,
|
||||
dry_run: Option<bool>,
|
||||
}
|
||||
|
||||
fn require_encryption_key_local(
|
||||
headers: &HeaderMap,
|
||||
) -> Result<[u8; 32], (StatusCode, Json<serde_json::Value>)> {
|
||||
let enc_key_hex = headers
|
||||
.get("x-encryption-key")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.ok_or_else(|| {
|
||||
(
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({ "error": "Missing X-Encryption-Key header" })),
|
||||
)
|
||||
})?;
|
||||
secrets_core::crypto::extract_key_from_hex(enc_key_hex).map_err(|_| {
|
||||
(
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({ "error": "Invalid X-Encryption-Key format" })),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn render_entry_json(
|
||||
entry: &secrets_core::models::Entry,
|
||||
relations: secrets_core::service::relations::EntryRelations,
|
||||
secret_fields: &[secrets_core::models::SecretField],
|
||||
summary: bool,
|
||||
) -> Value {
|
||||
if summary {
|
||||
json!({
|
||||
"name": entry.name,
|
||||
"folder": entry.folder,
|
||||
"type": entry.entry_type,
|
||||
"tags": entry.tags,
|
||||
"notes": entry.notes,
|
||||
"parents": relations.parents,
|
||||
"children": relations.children,
|
||||
"updated_at": entry.updated_at.format("%Y-%m-%dT%H:%M:%SZ").to_string(),
|
||||
})
|
||||
} else {
|
||||
let schema: Vec<_> = secret_fields
|
||||
.iter()
|
||||
.map(|field| {
|
||||
json!({
|
||||
"id": field.id,
|
||||
"name": field.name,
|
||||
"type": field.secret_type,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
json!({
|
||||
"id": entry.id,
|
||||
"name": entry.name,
|
||||
"folder": entry.folder,
|
||||
"type": entry.entry_type,
|
||||
"notes": entry.notes,
|
||||
"tags": entry.tags,
|
||||
"metadata": entry.metadata,
|
||||
"parents": relations.parents,
|
||||
"children": relations.children,
|
||||
"secret_fields": schema,
|
||||
"version": entry.version,
|
||||
"updated_at": entry.updated_at.format("%Y-%m-%dT%H:%M:%SZ").to_string(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) async fn api_entries_find(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Json(input): Json<LocalSearchInput>,
|
||||
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
|
||||
let user_id = require_user_from_bearer(&state.pool, &headers)
|
||||
.await
|
||||
.map_err(|status| (status, Json(json!({ "error": "unauthorized" }))))?;
|
||||
let tags = input.tags.unwrap_or_default();
|
||||
let result = svc_search(
|
||||
&state.pool,
|
||||
SearchParams {
|
||||
folder: input.folder.as_deref(),
|
||||
entry_type: input.entry_type.as_deref(),
|
||||
name: input.name.as_deref(),
|
||||
name_query: input.name_query.as_deref(),
|
||||
tags: &tags,
|
||||
query: input.query.as_deref(),
|
||||
metadata_query: input.metadata_query.as_deref(),
|
||||
sort: "name",
|
||||
limit: input.limit.unwrap_or(20),
|
||||
offset: input.offset.unwrap_or(0),
|
||||
user_id: Some(user_id),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!(error = %e, %user_id, "local mcp find failed");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({ "error": "find failed" })),
|
||||
)
|
||||
})?;
|
||||
let total_count = count_entries(
|
||||
&state.pool,
|
||||
&SearchParams {
|
||||
folder: input.folder.as_deref(),
|
||||
entry_type: input.entry_type.as_deref(),
|
||||
name: input.name.as_deref(),
|
||||
name_query: input.name_query.as_deref(),
|
||||
tags: &tags,
|
||||
query: input.query.as_deref(),
|
||||
metadata_query: input.metadata_query.as_deref(),
|
||||
sort: "name",
|
||||
limit: 0,
|
||||
offset: 0,
|
||||
user_id: Some(user_id),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!(error = %e, %user_id, "local mcp find count failed");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({ "error": "find count failed" })),
|
||||
)
|
||||
})?;
|
||||
let entry_ids: Vec<_> = result.entries.iter().map(|entry| entry.id).collect();
|
||||
let relation_map = get_relations_for_entries(&state.pool, &entry_ids, Some(user_id))
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!(error = %e, %user_id, "local mcp find relations failed");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({ "error": "find relations failed" })),
|
||||
)
|
||||
})?;
|
||||
let entries = result
|
||||
.entries
|
||||
.iter()
|
||||
.map(|entry| {
|
||||
let relations = relation_map.get(&entry.id).cloned().unwrap_or_default();
|
||||
let secret_fields = result
|
||||
.secret_schemas
|
||||
.get(&entry.id)
|
||||
.map(Vec::as_slice)
|
||||
.unwrap_or(&[]);
|
||||
render_entry_json(entry, relations, secret_fields, false)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
Ok(Json(json!({
|
||||
"total_count": total_count,
|
||||
"entries": entries,
|
||||
})))
|
||||
}
|
||||
|
||||
pub(super) async fn api_entries_search(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Json(input): Json<LocalSearchInput>,
|
||||
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
|
||||
let user_id = require_user_from_bearer(&state.pool, &headers)
|
||||
.await
|
||||
.map_err(|status| (status, Json(json!({ "error": "unauthorized" }))))?;
|
||||
let tags = input.tags.unwrap_or_default();
|
||||
let result = svc_search(
|
||||
&state.pool,
|
||||
SearchParams {
|
||||
folder: input.folder.as_deref(),
|
||||
entry_type: input.entry_type.as_deref(),
|
||||
name: input.name.as_deref(),
|
||||
name_query: input.name_query.as_deref(),
|
||||
tags: &tags,
|
||||
query: input.query.as_deref(),
|
||||
metadata_query: input.metadata_query.as_deref(),
|
||||
sort: input.sort.as_deref().unwrap_or("name"),
|
||||
limit: input.limit.unwrap_or(20),
|
||||
offset: input.offset.unwrap_or(0),
|
||||
user_id: Some(user_id),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!(error = %e, %user_id, "local mcp search failed");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({ "error": "search failed" })),
|
||||
)
|
||||
})?;
|
||||
let entry_ids: Vec<_> = result.entries.iter().map(|entry| entry.id).collect();
|
||||
let relation_map = get_relations_for_entries(&state.pool, &entry_ids, Some(user_id))
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!(error = %e, %user_id, "local mcp search relations failed");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({ "error": "search relations failed" })),
|
||||
)
|
||||
})?;
|
||||
let summary = input.summary.unwrap_or(false);
|
||||
let entries = result
|
||||
.entries
|
||||
.iter()
|
||||
.map(|entry| {
|
||||
let relations = relation_map.get(&entry.id).cloned().unwrap_or_default();
|
||||
let secret_fields = result
|
||||
.secret_schemas
|
||||
.get(&entry.id)
|
||||
.map(Vec::as_slice)
|
||||
.unwrap_or(&[]);
|
||||
render_entry_json(entry, relations, secret_fields, summary)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
Ok(Json(Value::Array(entries)))
|
||||
}
|
||||
|
||||
pub(super) async fn api_entry_history(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Json(input): Json<LocalHistoryInput>,
|
||||
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
|
||||
let user_id = require_user_from_bearer(&state.pool, &headers)
|
||||
.await
|
||||
.map_err(|status| (status, Json(json!({ "error": "unauthorized" }))))?;
|
||||
let (name, folder) = if let Some(id) = input.id {
|
||||
let entry = resolve_entry_by_id(&state.pool, id, Some(user_id))
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::warn!(error = %e, %user_id, %id, "local mcp history missing entry");
|
||||
(
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(json!({ "error": "entry not found" })),
|
||||
)
|
||||
})?;
|
||||
(entry.name, Some(entry.folder))
|
||||
} else {
|
||||
let name = input.name.ok_or_else(|| {
|
||||
(
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({ "error": "name or id is required" })),
|
||||
)
|
||||
})?;
|
||||
(name, input.folder)
|
||||
};
|
||||
let result = svc_history(
|
||||
&state.pool,
|
||||
&name,
|
||||
folder.as_deref(),
|
||||
input.limit.unwrap_or(20),
|
||||
Some(user_id),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::warn!(error = %e, %user_id, name = %name, "local mcp history failed");
|
||||
(
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({ "error": e.to_string() })),
|
||||
)
|
||||
})?;
|
||||
Ok(Json(
|
||||
serde_json::to_value(result).unwrap_or_else(|_| json!([])),
|
||||
))
|
||||
}
|
||||
|
||||
pub(super) async fn api_entries_overview(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct CountRow {
|
||||
name: String,
|
||||
count: i64,
|
||||
}
|
||||
|
||||
let user_id = require_user_from_bearer(&state.pool, &headers)
|
||||
.await
|
||||
.map_err(|status| (status, Json(json!({ "error": "unauthorized" }))))?;
|
||||
let folder_rows: Vec<CountRow> = sqlx::query_as::<_, CountRow>(
|
||||
"SELECT folder AS name, COUNT(*) AS count FROM entries \
|
||||
WHERE user_id = $1 GROUP BY folder ORDER BY folder",
|
||||
)
|
||||
.bind(user_id)
|
||||
.fetch_all(&state.pool)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!(error = %e, %user_id, "local mcp overview folders failed");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({ "error": "overview failed" })),
|
||||
)
|
||||
})?;
|
||||
let type_rows: Vec<CountRow> = sqlx::query_as::<_, CountRow>(
|
||||
"SELECT type AS name, COUNT(*) AS count FROM entries \
|
||||
WHERE user_id = $1 GROUP BY type ORDER BY type",
|
||||
)
|
||||
.bind(user_id)
|
||||
.fetch_all(&state.pool)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!(error = %e, %user_id, "local mcp overview types failed");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({ "error": "overview failed" })),
|
||||
)
|
||||
})?;
|
||||
let total: i64 = folder_rows.iter().map(|row| row.count).sum();
|
||||
Ok(Json(json!({
|
||||
"total": total,
|
||||
"folders": folder_rows.iter().map(|row| json!({"name": row.name, "count": row.count})).collect::<Vec<_>>(),
|
||||
"types": type_rows.iter().map(|row| json!({"name": row.name, "count": row.count})).collect::<Vec<_>>(),
|
||||
})))
|
||||
}
|
||||
|
||||
pub(super) async fn api_entries_delete_preview(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Json(input): Json<LocalDeleteInput>,
|
||||
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
|
||||
let user_id = require_user_from_bearer(&state.pool, &headers)
|
||||
.await
|
||||
.map_err(|status| (status, Json(json!({ "error": "unauthorized" }))))?;
|
||||
if !input.dry_run.unwrap_or(false) {
|
||||
return Err((
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({ "error": "dry_run=true is required" })),
|
||||
));
|
||||
}
|
||||
let (effective_name, effective_folder) =
|
||||
if let Some(id) = input.id {
|
||||
let entry = resolve_entry_by_id(&state.pool, id, Some(user_id))
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::warn!(error = %e, %user_id, %id, "local mcp delete preview missing entry");
|
||||
(StatusCode::NOT_FOUND, Json(json!({ "error": "entry not found" })))
|
||||
})?;
|
||||
(Some(entry.name), Some(entry.folder))
|
||||
} else {
|
||||
(input.name, input.folder)
|
||||
};
|
||||
let result = svc_delete(
|
||||
&state.pool,
|
||||
DeleteParams {
|
||||
name: effective_name.as_deref(),
|
||||
folder: effective_folder.as_deref(),
|
||||
entry_type: input.entry_type.as_deref(),
|
||||
dry_run: true,
|
||||
user_id: Some(user_id),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::warn!(error = %e, %user_id, "local mcp delete preview failed");
|
||||
(
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({ "error": e.to_string() })),
|
||||
)
|
||||
})?;
|
||||
Ok(Json(
|
||||
serde_json::to_value(result).unwrap_or_else(|_| json!({})),
|
||||
))
|
||||
}
|
||||
|
||||
pub(super) async fn api_entry_secrets_decrypt_bearer(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Path(entry_id): Path<Uuid>,
|
||||
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
|
||||
let user_id = require_user_from_bearer(&state.pool, &headers)
|
||||
.await
|
||||
.map_err(|status| (status, Json(json!({ "error": "unauthorized" }))))?;
|
||||
let master_key = require_encryption_key_local(&headers)?;
|
||||
let secrets = get_all_secrets_by_id(&state.pool, entry_id, &master_key, Some(user_id))
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::warn!(error = %e, %user_id, %entry_id, "local mcp decrypt failed");
|
||||
if let Some(app_err) = e.downcast_ref::<secrets_core::error::AppError>() {
|
||||
return match app_err {
|
||||
secrets_core::error::AppError::DecryptionFailed => (
|
||||
StatusCode::UNPROCESSABLE_ENTITY,
|
||||
Json(json!({ "error": "Decryption failed, verify passphrase" })),
|
||||
),
|
||||
secrets_core::error::AppError::NotFoundEntry
|
||||
| secrets_core::error::AppError::NotFoundUser
|
||||
| secrets_core::error::AppError::NotFoundSecret => (
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(json!({ "error": "entry not found" })),
|
||||
),
|
||||
_ => (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({ "error": "decrypt failed" })),
|
||||
),
|
||||
};
|
||||
}
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({ "error": "decrypt failed" })),
|
||||
)
|
||||
})?;
|
||||
Ok(Json(
|
||||
serde_json::to_value(secrets).unwrap_or_else(|_| json!({})),
|
||||
))
|
||||
}
|
||||
@@ -18,6 +18,7 @@ mod audit;
|
||||
mod auth;
|
||||
mod changelog;
|
||||
mod entries;
|
||||
mod local_mcp;
|
||||
|
||||
// ── Session keys ──────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -259,6 +260,7 @@ pub fn web_router() -> Router<AppState> {
|
||||
.route("/auth/google", get(auth::auth_google))
|
||||
.route("/auth/google/callback", get(auth::auth_google_callback))
|
||||
.route("/auth/logout", post(auth::auth_logout))
|
||||
.route("/local-mcp/approve", get(local_mcp::approve_page))
|
||||
.route("/dashboard", get(account::dashboard))
|
||||
.route("/entries", get(entries::entries_page))
|
||||
.route("/trash", get(entries::trash_page))
|
||||
@@ -266,6 +268,43 @@ pub fn web_router() -> Router<AppState> {
|
||||
.route("/account/bind/google", get(auth::account_bind_google))
|
||||
.route("/account/unbind/{provider}", post(auth::account_unbind))
|
||||
.route("/api/key-salt", get(account::api_key_salt))
|
||||
.route("/api/local-mcp/bind/start", post(local_mcp::api_bind_start))
|
||||
.route(
|
||||
"/api/local-mcp/bind/approve",
|
||||
post(local_mcp::api_bind_approve),
|
||||
)
|
||||
.route(
|
||||
"/api/local-mcp/bind/exchange",
|
||||
post(local_mcp::api_bind_exchange),
|
||||
)
|
||||
.route(
|
||||
"/api/local-mcp/bind/refresh",
|
||||
post(local_mcp::api_bind_refresh),
|
||||
)
|
||||
.route(
|
||||
"/api/local-mcp/entries/find",
|
||||
post(local_mcp::api_entries_find),
|
||||
)
|
||||
.route(
|
||||
"/api/local-mcp/entries/search",
|
||||
post(local_mcp::api_entries_search),
|
||||
)
|
||||
.route(
|
||||
"/api/local-mcp/entries/history",
|
||||
post(local_mcp::api_entry_history),
|
||||
)
|
||||
.route(
|
||||
"/api/local-mcp/entries/overview",
|
||||
get(local_mcp::api_entries_overview),
|
||||
)
|
||||
.route(
|
||||
"/api/local-mcp/entries/delete-preview",
|
||||
post(local_mcp::api_entries_delete_preview),
|
||||
)
|
||||
.route(
|
||||
"/api/local-mcp/entries/{id}/secrets",
|
||||
get(local_mcp::api_entry_secrets_decrypt_bearer),
|
||||
)
|
||||
.route("/api/key-setup", post(account::api_key_setup))
|
||||
.route("/api/key-change", post(account::api_key_change))
|
||||
.route("/api/apikey", get(account::api_apikey_get))
|
||||
|
||||
@@ -58,10 +58,9 @@ GOOGLE_CLIENT_SECRET=
|
||||
# TRUST_PROXY=1
|
||||
|
||||
# ─── 本机 MCP gateway(secrets-mcp-local,可选)────────────────────────
|
||||
# 在开发者机器上运行,与上方服务端 .env 通常分开配置;用于代理远程 /mcp 并缓存解锁状态。
|
||||
# SECRETS_REMOTE_MCP_URL=https://secrets.example.com/mcp
|
||||
# 在开发者机器上运行,与上方服务端 .env 通常分开配置;用于本地 MCP onboarding、解锁缓存与 target_exec。
|
||||
# 直接配置远端 Web 基址。
|
||||
# SECRETS_REMOTE_BASE_URL=https://secrets.example.com
|
||||
# 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
|
||||
# SECRETS_LOCAL_EXEC_CONTEXT_TTL_SECS=3600
|
||||
|
||||
Reference in New Issue
Block a user