Compare commits

..

7 Commits

Author SHA1 Message Date
voson
cb5865b958 release(secrets-mcp): 0.6.0 - local gateway onboarding and target_exec
Some checks failed
Secrets MCP — Build & Release / 检查 / 构建 / 发版 (push) Successful in 6m54s
Secrets MCP — Build & Release / 部署 secrets-mcp (push) Failing after 2m16s
2026-04-12 22:47:46 +08:00
voson
34093b0e23 feat: add secrets-mcp-local gateway (proxy, unlock cache, plaintext tool gate) 2026-04-12 22:47:46 +08:00
voson
0bf06bbc73 chore: empty checkpoint 2026-04-12 22:47:46 +08:00
voson
f86d12b80e feat(secrets-mcp): /entries 按 tags 筛选,发版 0.5.27
All checks were successful
Secrets MCP — Build & Release / 检查 / 构建 / 发版 (push) Successful in 5m48s
Secrets MCP — Build & Release / 部署 secrets-mcp (push) Successful in 5s
- Web:tags 查询参数、folder 计数 SQL、分页与 tab 保留 tags;模板与 i18n
- README / AGENTS / CHANGELOG;plans/web-tags-filter 等计划文档
2026-04-11 21:39:36 +08:00
voson
43d6164a15 fix(oauth): reqwest 启用 system-proxy;Google 换 token 诊断与 0.5.26
Some checks failed
Secrets MCP — Build & Release / 检查 / 构建 / 发版 (push) Successful in 5m53s
Secrets MCP — Build & Release / 部署 secrets-mcp (push) Has been cancelled
- 工作区 reqwest 增加 system-proxy,与系统代理一致
- google.rs:超时/非 2xx 体日志;OAuth 请求单独 45s 超时
- README / AGENTS / deploy/.env.example:OAuth 出站与 HTTPS_PROXY 说明
2026-04-11 21:33:30 +08:00
voson
1b2fbdae4d feat(secrets-mcp): /changelog 页(Markdown 渲染)、首页与 Dashboard 入口
Some checks failed
Secrets MCP — Build & Release / 部署 secrets-mcp (push) Has been cancelled
Secrets MCP — Build & Release / 检查 / 构建 / 发版 (push) Has been cancelled
- 新增 CHANGELOG.md 构建嵌入、pulldown-cmark 渲染、路由 /changelog
- 首页页脚与 MCP Dashboard 页脚提供变更记录链接;同步 README、AGENTS
- 版本 secrets-mcp 0.5.24
2026-04-11 21:27:26 +08:00
voson
ab1e3329b9 docs: 同步 README/AGENTS — rollback 仅需 Bearer、env_map 命名规则、key_version 与 JSON 会话、导出 secret_types
本次不发版(仅文档)。
2026-04-11 20:41:20 +08:00
32 changed files with 4437 additions and 142 deletions

View File

@@ -42,7 +42,7 @@ secrets/
Cargo.toml
crates/
secrets-core/ # db / crypto / models / audit / service
secrets-mcp/ # rmcp tools、axum、OAuth、Dashboard
secrets-mcp/ # rmcp tools、axum、OAuth、DashboardCHANGELOG.md → /changelog
scripts/
release-check.sh
setup-gitea-actions.sh
@@ -113,6 +113,7 @@ users (
key_check BYTEA, -- 派生密钥加密已知常量,用于验证密码短语
key_params JSONB, -- 算法参数,如 {"alg":"pbkdf2-sha256","iterations":600000}
api_key TEXT UNIQUE, -- MCP Bearer token明文存储设计决策见下方说明
key_version BIGINT NOT NULL DEFAULT 0, -- 密码短语变更时递增,用于使其它设备会话失效
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
@@ -165,10 +166,28 @@ oauth_accounts (
| `secrets.type` | 密钥类型(调用方提供,默认 `text` | `text`, `password`, `key` |
| `secrets.encrypted` | 密文 | AES-GCM |
### Web 变更记录(`/changelog`
`crates/secrets-mcp/CHANGELOG.md` 在构建时嵌入,服务端以 **Markdown** 渲染为 HTML`pulldown-cmark`)。**首页**`/`)页脚与 **Dashboard**`/dashboard`MCP 配置页)页脚均提供「变更记录」链接;发版时随 `secrets-mcp` 版本更新该文件即可。
### Google OAuth 出站 HTTP
换 token`POST https://oauth2.googleapis.com/token`)与拉取 userinfo 使用工作区 **`reqwest`**。根目录 `Cargo.toml` 中为 `reqwest` 启用了 **`system-proxy`**(因 `default-features = false` 须显式打开),以便在 **macOS / Windows** 上读取**系统代理**,避免「浏览器能上 Google、服务端换 token 超时」这类代理不一致。若仅提供端口代理、系统代理未生效,可设 **`HTTPS_PROXY` / `NO_PROXY`**,见 `deploy/.env.example`
### Web JSON API 与会话
除页面路由使用的 `require_valid_user`(未登录或 `key_version` 与库不一致时重定向 `/login`JSON API`/api/...`)使用等价校验:会话中的 `key_version` 须与 `users.key_version` 一致,否则返回 **401** JSON避免仅校验 `user_id` 时与页面行为不一致。
### Web 条目页表格列(`/entries`
列表仅展示非敏感字段;**名称**与**操作**列为固定列(不可在「显示列」中关闭)。**文件夹**(对应 `entries.folder`)、类型、备注、标签、关联、密文等为**可选列**,由用户在「显示列」面板中勾选;可见性保存在浏览器 `localStorage`,键为 **`entries_col_vis`**。新增列会并入默认:若用户曾保存过旧版配置,缺失的列键会按当前默认补齐。**文件夹**列默认**显示**,便于在「全部」等跨 folder 视图下区分条目所属隔离空间。
筛选栏支持查询参数 **`tags`**(逗号分隔,多标签 **AND**,语义同 `SearchParams.tags` / `tags @> ARRAY[...]`);分页与 folder 标签计数与当前筛选一致。
### 导出 / 导入文件
JSON/TOML/YAML 导出可在每条目上包含 `secret_types`secret 名 → `text` / `password` / `key` 等),导入时写回 `secrets.type`**旧版导出无该字段**时导入仍成功,类型按 **`text`** 默认。
### 共享密钥N:N 关联)
多个 entry 可共享同一 secret 字段,通过 `entry_secrets` 中间表关联。

121
Cargo.lock generated
View File

@@ -356,6 +356,16 @@ dependencies = [
"version_check",
]
[[package]]
name = "core-foundation"
version = "0.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]]
name = "core-foundation-sys"
version = "0.8.7"
@@ -740,6 +750,15 @@ dependencies = [
"version_check",
]
[[package]]
name = "getopts"
version = "0.2.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df"
dependencies = [
"unicode-width",
]
[[package]]
name = "getrandom"
version = "0.2.17"
@@ -1016,9 +1035,11 @@ dependencies = [
"percent-encoding",
"pin-project-lite",
"socket2",
"system-configuration",
"tokio",
"tower-service",
"tracing",
"windows-registry",
]
[[package]]
@@ -1578,6 +1599,25 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "pulldown-cmark"
version = "0.13.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c3a14896dfa883796f1cb410461aef38810ea05f2b2c33c5aded3649095fdad"
dependencies = [
"bitflags",
"getopts",
"memchr",
"pulldown-cmark-escape",
"unicase",
]
[[package]]
name = "pulldown-cmark-escape"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "007d8adb5ddab6f8e3f491ac63566a7d5002cc7ed73901f72057943fa71ae1ae"
[[package]]
name = "quanta"
version = "0.12.6"
@@ -1818,6 +1858,7 @@ dependencies = [
"base64",
"bytes",
"futures-core",
"futures-util",
"http",
"http-body",
"http-body-util",
@@ -1837,12 +1878,14 @@ dependencies = [
"sync_wrapper",
"tokio",
"tokio-rustls",
"tokio-util",
"tower",
"tower-http",
"tower-service",
"url",
"wasm-bindgen",
"wasm-bindgen-futures",
"wasm-streams",
"web-sys",
"webpki-roots 1.0.6",
]
@@ -2065,7 +2108,7 @@ dependencies = [
[[package]]
name = "secrets-mcp"
version = "0.5.21"
version = "0.6.0"
dependencies = [
"anyhow",
"askama",
@@ -2075,6 +2118,7 @@ dependencies = [
"dotenvy",
"governor",
"http",
"pulldown-cmark",
"rand 0.10.0",
"reqwest",
"rmcp",
@@ -2096,6 +2140,24 @@ dependencies = [
"uuid",
]
[[package]]
name = "secrets-mcp-local"
version = "0.1.0"
dependencies = [
"anyhow",
"axum",
"dotenvy",
"reqwest",
"secrets-core",
"serde",
"serde_json",
"tokio",
"tracing",
"tracing-subscriber",
"url",
"uuid",
]
[[package]]
name = "semver"
version = "1.0.27"
@@ -2582,6 +2644,27 @@ dependencies = [
"syn",
]
[[package]]
name = "system-configuration"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b"
dependencies = [
"bitflags",
"core-foundation",
"system-configuration-sys",
]
[[package]]
name = "system-configuration-sys"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]]
name = "tempfile"
version = "3.27.0"
@@ -2985,6 +3068,12 @@ version = "1.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb"
[[package]]
name = "unicase"
version = "2.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142"
[[package]]
name = "unicode-bidi"
version = "0.3.18"
@@ -3012,6 +3101,12 @@ version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d"
[[package]]
name = "unicode-width"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
[[package]]
name = "unicode-xid"
version = "0.2.6"
@@ -3214,6 +3309,19 @@ dependencies = [
"wasmparser",
]
[[package]]
name = "wasm-streams"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65"
dependencies = [
"futures-util",
"js-sys",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
]
[[package]]
name = "wasmparser"
version = "0.244.0"
@@ -3337,6 +3445,17 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-registry"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720"
dependencies = [
"windows-link",
"windows-result",
"windows-strings",
]
[[package]]
name = "windows-result"
version = "0.4.1"

View File

@@ -2,6 +2,7 @@
members = [
"crates/secrets-core",
"crates/secrets-mcp",
"crates/secrets-mcp-local",
]
resolver = "2"
@@ -36,4 +37,5 @@ tracing-subscriber = { version = "^0.3", features = ["env-filter"] }
dotenvy = "^0.15"
# HTTP
reqwest = { version = "^0.12", default-features = false, features = ["rustls-tls", "json"] }
# system-proxy与浏览器一致读取 macOS/Windows 系统代理(禁用 default 后须显式开启,否则 OAuth 出站不走 Clash 等)
reqwest = { version = "^0.12", default-features = false, features = ["rustls-tls", "json", "system-proxy"] }

View File

@@ -1,6 +1,6 @@
# secrets-mcp
Workspace**`secrets-core`** + **`secrets-mcp`**HTTP Streamable MCP + Web。多租户密钥与元数据存 PostgreSQL用户通过 **Google OAuth** 登录,**API Key** 鉴权 MCP 请求;秘密数据用**用户密码短语派生的密钥**在客户端加密,服务端不持有原始密钥。
Workspace**`secrets-core`** + **`secrets-mcp`**HTTP Streamable MCP + Web+ **`secrets-mcp-local`**(可选:本机 MCP gateway。多租户密钥与元数据存 PostgreSQL用户通过 **Google OAuth** 登录,**API Key** 鉴权 MCP 请求;秘密数据用**用户密码短语派生的密钥**在客户端加密,服务端不持有原始密钥。
## 安装
@@ -9,6 +9,11 @@ cargo build --release -p secrets-mcp
# 产物: target/release/secrets-mcp
```
```bash
cargo build --release -p secrets-mcp-local
# 产物: target/release/secrets-mcp-local本机 MCP gateway见下节
```
发版产物见 Gitea Releasetag`secrets-mcp-<version>`Linux musl 预编译);其它平台本地 `cargo build`
## 环境变量与本地运行
@@ -23,7 +28,8 @@ cargo build --release -p secrets-mcp
| `SECRETS_ENV` | 可选。设为 `prod` / `production` 时会拒绝弱 PostgreSQL TLS 模式(`prefer``disable``allow``require`)。 |
| `BASE_URL` | 对外访问基址OAuth 回调为 `{BASE_URL}/auth/google/callback`。默认 `http://localhost:9315`。 |
| `SECRETS_MCP_BIND` | 监听地址,默认 `127.0.0.1:9315`。容器内或直接对外暴露端口时请改为 `0.0.0.0:9315`;反代时常为 `127.0.0.1:9315`。 |
| `GOOGLE_CLIENT_ID` / `GOOGLE_CLIENT_SECRET` | 可选;不配置则无 Google 登录入口。运行时从环境读取,勿写入 CI、勿打入二进制。 |
| `GOOGLE_CLIENT_ID` / `GOOGLE_CLIENT_SECRET` | 可选;不配置则无 Google 登录入口。运行时从环境读取,勿写入 CI、勿打入二进制。换 token 须访问 `oauth2.googleapis.com`:工作区 **`reqwest` 已启用 `system-proxy`**,与浏览器一致可走 macOS/Windows **系统代理**(如 Clash 系统代理模式)。 |
| `HTTPS_PROXY` / `NO_PROXY` | 可选。仅当系统代理未被进程识别、又需走本地端口代理时设置;示例见 [`deploy/.env.example`](deploy/.env.example)。 |
| `RUST_LOG` | 可选;日志级别,如 `secrets_mcp=debug`。 |
| `SECRETS_DATABASE_POOL_SIZE` | 可选。连接池最大连接数,默认 `10`。 |
| `SECRETS_DATABASE_ACQUIRE_TIMEOUT` | 可选。获取连接超时秒数,默认 `5`。 |
@@ -46,9 +52,61 @@ SECRETS_DATABASE_SSL_ROOT_CERT=/etc/secrets/pg-ca.crt
SECRETS_ENV=production
```
- **Web**`BASE_URL`登录、Dashboard、设置密码短语、创建 API Key。**条目**页 `/entries` 支持 folder 标签与条件筛选;表格列可在「显示列」中开关(名称与操作固定),**文件夹**列为可选列且默认显示。列可见性持久化见 [AGENTS.md](AGENTS.md)「Web 条目页表格列」。
- **Web**`BASE_URL`登录、Dashboard、设置密码短语、创建 API Key。**变更记录**页 **`/changelog`**:内容来自 `crates/secrets-mcp/CHANGELOG.md`(构建时嵌入并以 Markdown 渲染);首页页脚与 DashboardMCP页脚均提供入口。**条目**页 `/entries` 支持 folder 标签与条件筛选(含 **`tags`** 逗号分隔、多标签同时匹配);表格列可在「显示列」中开关(名称与操作固定),**文件夹**列为可选列且默认显示。列可见性持久化见 [AGENTS.md](AGENTS.md)「Web 条目页表格列」。
- **MCP**Streamable HTTP 基址 `{BASE_URL}/mcp`,需 `Authorization: Bearer <api_key>` + `X-Encryption-Key: <hex>` 请求头(读密文工具须带密钥)。
### 本地 MCP gateway`secrets-mcp-local`
`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_BASE_URL` | **必填**。远程 Web 基址,例如 `https://secrets.example.com`。 |
| `SECRETS_MCP_LOCAL_BIND` | 可选。监听地址,默认 `127.0.0.1:9316`。 |
| `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_BASE_URL=https://secrets.example.com cargo run -p secrets-mcp-local
# 启动后直接打开 http://127.0.0.1:9316/
# 页面会引导你完成 bind -> approve -> unlock -> ready 全流程
```
## PostgreSQL TLS 加固
- 推荐将数据库域名单独设置为 `db.refining.ltd`,服务域名保持 `secrets.refining.app`
@@ -72,9 +130,9 @@ SECRETS_ENV=production
| `secrets_update` | 是 | 更新条目,支持 `id``name`+`folder` 定位 |
| `secrets_delete` | 否 | 删除条目,支持 `id``name`+`folder` 定位;`dry_run=true` 预览删除 |
| `secrets_history` | 否 | 查看条目历史,支持 `id``name`+`folder` 定位 |
| `secrets_rollback` | | 回滚条目到指定历史版本,支持 `id``name`+`folder` 定位 |
| `secrets_rollback` | | 回滚条目到指定历史版本(服务端按历史快照恢复元数据与密文关联),支持 `id`;仅需 **Bearer**,不要求 `X-Encryption-Key` |
| `secrets_export` | 是 | 导出条目(含解密明文),支持 JSON/TOML/YAML 格式 |
| `secrets_env_map` | 是 | 将 secrets 转为环境变量映射`UPPER(entry)_UPPER(field)` 格式),支持 `prefix` |
| `secrets_env_map` | 是 | 将 secrets 转为环境变量映射`PREFIX_ENTRYNAME_FIELDNAME`(字段名中 `.``__``-``_` 再转大写,避免与纯下划线字段名碰撞),支持 `prefix` |
| `secrets_overview` | 否 | 返回各 folder 和 type 的 entry 计数概览 |
### 消歧规则
@@ -179,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`),无需额外环境变量。
| 位置 | 字段 | 说明 |
|------|------|------|
@@ -226,7 +284,8 @@ crates/secrets-core/ # db / crypto / models / audit / service
src/
taxonomy.rs # SECRET_TYPE_OPTIONSsecret 字段类型下拉选项)
service/ # 业务逻辑add, search, update, delete, export, env_map 等)
crates/secrets-mcp/ # MCP HTTP、Web、OAuth、API Key
crates/secrets-mcp/ # MCP HTTP、Web、OAuth、API KeyCHANGELOG.md 嵌入 /changelog
crates/secrets-mcp-local/ # 可选:本机 MCP gatewaybootstrap + ready 双工具面)
scripts/
release-check.sh # 发版前 fmt / clippy / test
setup-gitea-actions.sh

View File

@@ -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 (

View File

@@ -0,0 +1,24 @@
[package]
name = "secrets-mcp-local"
version = "0.1.0"
edition.workspace = true
description = "Local MCP gateway for onboarding, unlock caching, and delegated target execution"
license = "MIT OR Apache-2.0"
[[bin]]
name = "secrets-mcp-local"
path = "src/main.rs"
[dependencies]
anyhow.workspace = true
axum = "0.8"
dotenvy.workspace = true
reqwest = { workspace = true, features = ["stream"] }
secrets-core = { path = "../secrets-core" }
serde.workspace = true
serde_json.workspace = true
tokio.workspace = true
tracing.workspace = true
tracing-subscriber = { workspace = true, features = ["env-filter"] }
url = "2"
uuid.workspace = true

View 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 })))
}

View 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);
}
}

View 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)),
})
}

View 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")
);
}
}

View File

@@ -0,0 +1,55 @@
mod bind;
mod cache;
mod config;
mod exec;
mod mcp;
mod remote;
mod server;
mod target;
mod unlock;
use anyhow::{Context, Result};
use tracing_subscriber::EnvFilter;
#[tokio::main]
async fn main() -> Result<()> {
let _ = dotenvy::dotenv();
tracing_subscriber::fmt()
.with_env_filter(
EnvFilter::try_from_default_env()
.unwrap_or_else(|_| "secrets_mcp_local=info,tower_http=info".into()),
)
.init();
let 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 app_state = server::AppState {
config: config.clone(),
cache,
remote,
};
let app = server::router(app_state);
tracing::info!(
bind = %config.bind,
remote = %config.remote_base_url,
"secrets-mcp-local service started"
);
let listener = tokio::net::TcpListener::bind(config.bind)
.await
.with_context(|| format!("failed to bind {}", config.bind))?;
let result = axum::serve(
listener,
app.into_make_service_with_connect_info::<std::net::SocketAddr>(),
)
.await
.context("server error");
cleanup.abort();
result
}

View 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());
}
}

View 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")
}
}

View 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)
}

View 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-----"
);
}
}

View 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);
}
}

View File

@@ -0,0 +1,57 @@
本文档在构建时嵌入 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 APIlocal 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 服务:支持 `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
### Added
- Web **`/entries`**:按 **tags** 筛选逗号分隔、trim、多标签 **AND** 语义,与 `SearchParams` / MCP 一致folder 标签计数、分页与筛选栏状态同步保留 `tags`
## [0.5.26] - 2026-04-11
### Fixed
- **Google OAuth**:工作区 `reqwest` 此前关闭默认特性且未启用 **`system-proxy`**,进程不读取 macOS/Windows 系统代理,易出现与浏览器不一致(本机可上 Google 但换 token 超时)。已显式启用 `system-proxy`
## [0.5.25] - 2026-04-11
### Changed
- Google OAuthtoken / userinfo 请求单独 **45s** 超时(避免仅触达默认客户端 15s失败时区分超时、连接错误并在非 2xx 时记录/返回 Google 响应体片段(如 `invalid_grant``redirect_uri_mismatch`)。
## [0.5.24] - 2026-04-11
### Changed
- 首页页脚将原「登录」入口改为「变更记录」(`/changelog`);顶部导航仍保留登录 / 进入控制台。
## [0.5.23] - 2026-04-11
### Added
- Changelog 页使用 **Markdown** 渲染(`pulldown-cmark`:表格、~~删除线~~、任务列表等)。
## [0.5.22] - 2026-04-11
### Added
- DashboardMCP页脚版本旁增加「变更记录」链接打开本变更说明页。

View File

@@ -1,6 +1,6 @@
[package]
name = "secrets-mcp"
version = "0.5.21"
version = "0.6.0"
edition.workspace = true
[[bin]]
@@ -45,3 +45,4 @@ urlencoding = "2"
schemars = "1"
http = "1"
url = "2"
pulldown-cmark = "0.13.3"

View File

@@ -1,8 +1,13 @@
use std::time::Duration;
use anyhow::{Context, Result};
use serde::Deserialize;
use super::{OAuthConfig, OAuthUserInfo};
/// OAuth token / userinfo calls can be slow on poor routes; keep above client default if needed.
const OAUTH_HTTP_TIMEOUT: Duration = Duration::from_secs(45);
#[derive(Deserialize)]
struct TokenResponse {
access_token: String,
@@ -20,14 +25,28 @@ struct UserInfo {
picture: Option<String>,
}
fn map_reqwest_send_err(e: reqwest::Error) -> anyhow::Error {
if e.is_timeout() {
anyhow::anyhow!(
"timeout reaching Google OAuth ({}s); ensure outbound HTTPS to oauth2.googleapis.com works (firewall/proxy/VPN if Google is unreachable)",
OAUTH_HTTP_TIMEOUT.as_secs()
)
} else if e.is_connect() {
anyhow::anyhow!("connection error to Google OAuth: {e}")
} else {
anyhow::Error::new(e)
}
}
/// Exchange authorization code for tokens and fetch user profile.
pub async fn exchange_code(
client: &reqwest::Client,
config: &OAuthConfig,
code: &str,
) -> Result<OAuthUserInfo> {
let token_resp: TokenResponse = client
let token_http = client
.post("https://oauth2.googleapis.com/token")
.timeout(OAUTH_HTTP_TIMEOUT)
.form(&[
("code", code),
("client_id", &config.client_id),
@@ -37,24 +56,55 @@ pub async fn exchange_code(
])
.send()
.await
.context("failed to exchange Google code")?
.error_for_status()
.context("Google token endpoint error")?
.json()
.await
.context("failed to parse Google token response")?;
.map_err(map_reqwest_send_err)
.context("Google token HTTP request failed")?;
let user: UserInfo = client
let status = token_http.status();
let body_bytes = token_http
.bytes()
.await
.context("read Google token response body")?;
if !status.is_success() {
let body_lossy = String::from_utf8_lossy(&body_bytes);
tracing::warn!(%status, body = %body_lossy, "Google token endpoint error");
anyhow::bail!(
"Google token error {}: {}",
status,
body_lossy.chars().take(512).collect::<String>()
);
}
let token_resp: TokenResponse =
serde_json::from_slice(&body_bytes).context("failed to parse Google token JSON")?;
let user_http = client
.get("https://openidconnect.googleapis.com/v1/userinfo")
.timeout(OAUTH_HTTP_TIMEOUT)
.bearer_auth(&token_resp.access_token)
.send()
.await
.context("failed to fetch Google userinfo")?
.error_for_status()
.context("Google userinfo endpoint error")?
.json()
.map_err(map_reqwest_send_err)
.context("Google userinfo HTTP request failed")?;
let status = user_http.status();
let body_bytes = user_http
.bytes()
.await
.context("failed to parse Google userinfo")?;
.context("read Google userinfo body")?;
if !status.is_success() {
let body_lossy = String::from_utf8_lossy(&body_bytes);
tracing::warn!(%status, body = %body_lossy, "Google userinfo endpoint error");
anyhow::bail!(
"Google userinfo error {}: {}",
status,
body_lossy.chars().take(512).collect::<String>()
);
}
let user: UserInfo =
serde_json::from_slice(&body_bytes).context("failed to parse Google userinfo JSON")?;
Ok(OAuthUserInfo {
provider: "google".to_string(),

View File

@@ -0,0 +1,48 @@
use askama::Template;
use axum::{extract::State, http::StatusCode, response::Response};
use pulldown_cmark::{Options, Parser, html};
use crate::AppState;
use super::render_template;
#[derive(Template)]
#[template(path = "changelog.html")]
pub(super) struct ChangelogTemplate {
pub base_url: String,
pub version: &'static str,
pub changelog_html: String,
}
fn markdown_to_html(md: &str) -> String {
let mut opts = Options::empty();
opts.insert(Options::ENABLE_TABLES);
opts.insert(Options::ENABLE_STRIKETHROUGH);
opts.insert(Options::ENABLE_TASKLISTS);
let parser = Parser::new_ext(md, opts);
let mut out = String::new();
html::push_html(&mut out, parser);
out
}
pub(super) async fn changelog_page(State(state): State<AppState>) -> Result<Response, StatusCode> {
let md = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/CHANGELOG.md"));
render_template(ChangelogTemplate {
base_url: state.base_url.clone(),
version: env!("CARGO_PKG_VERSION"),
changelog_html: markdown_to_html(md),
})
}
#[cfg(test)]
mod tests {
use super::markdown_to_html;
#[test]
fn markdown_renders_heading_and_list() {
let html = markdown_to_html("# Title\n\n- a\n");
assert!(html.contains("<h1"));
assert!(html.contains("Title"));
assert!(html.contains("<ul") || html.contains("<li"));
}
}

View File

@@ -45,6 +45,7 @@ struct EntriesPageTemplate {
filter_folder: String,
filter_name: String,
filter_metadata_query: String,
filter_tags: String,
filter_type: String,
current_page: u32,
total_pages: u32,
@@ -125,6 +126,8 @@ pub(super) struct EntriesQuery {
/// URL query key is `type` (maps to DB column `entries.type`).
#[serde(rename = "type")]
entry_type: Option<String>,
/// Comma-separated tags (AND semantics); matches `SearchParams.tags`.
tags: Option<String>,
page: Option<u32>,
}
@@ -252,6 +255,18 @@ fn relation_views(items: &[RelationEntrySummary]) -> Vec<RelationSummaryView> {
.collect()
}
/// Parse Web `tags` query: comma-separated, trim, drop empties (AND semantics via `SearchParams`).
fn parse_tags_filter(raw: Option<&str>) -> Vec<String> {
let Some(s) = raw else {
return Vec::new();
};
s.split(',')
.map(str::trim)
.filter(|t| !t.is_empty())
.map(std::string::ToString::to_string)
.collect()
}
// ── Handlers ──────────────────────────────────────────────────────────────────
pub(super) async fn entries_page(
@@ -289,13 +304,15 @@ pub(super) async fn entries_page(
.map(|s| s.trim())
.filter(|s| !s.is_empty())
.map(|s| s.to_string());
let filter_tags = q.tags.clone().unwrap_or_default();
let tag_vec = parse_tags_filter(q.tags.as_deref());
let page = q.page.unwrap_or(1).max(1);
let count_params = SearchParams {
folder: folder_filter.as_deref(),
entry_type: type_filter.as_deref(),
name: None,
name_query: name_filter.as_deref(),
tags: &[],
tags: tag_vec.as_slice(),
query: None,
metadata_query: metadata_query_filter.as_deref(),
sort: "updated",
@@ -328,6 +345,16 @@ pub(super) async fn entries_page(
));
bind_idx += 1;
}
if !tag_vec.is_empty() {
let placeholders: Vec<String> = (0..tag_vec.len())
.map(|i| format!("${}", bind_idx + i as i32))
.collect();
folder_sql.push_str(&format!(
" AND tags @> ARRAY[{}]::text[]",
placeholders.join(", ")
));
bind_idx += tag_vec.len() as i32;
}
let _ = bind_idx;
folder_sql.push_str(" GROUP BY folder ORDER BY folder");
let mut folder_query = sqlx::query_as::<_, FolderCountRow>(&folder_sql).bind(user_id);
@@ -340,6 +367,9 @@ pub(super) async fn entries_page(
if let Some(v) = metadata_query_filter.as_deref() {
folder_query = folder_query.bind(ilike_pattern(v));
}
for t in &tag_vec {
folder_query = folder_query.bind(t);
}
#[derive(sqlx::FromRow)]
struct TypeOptionRow {
@@ -414,6 +444,7 @@ pub(super) async fn entries_page(
entry_type: Option<&str>,
name: Option<&str>,
metadata_query: Option<&str>,
tags: Option<&str>,
page: Option<u32>,
) -> String {
let mut pairs: Vec<String> = Vec::new();
@@ -437,6 +468,11 @@ pub(super) async fn entries_page(
{
pairs.push(format!("metadata_query={}", urlencoding::encode(v)));
}
if let Some(tg) = tags
&& !tg.is_empty()
{
pairs.push(format!("tags={}", urlencoding::encode(tg)));
}
if let Some(p) = page {
pairs.push(format!("page={}", p));
}
@@ -447,6 +483,7 @@ pub(super) async fn entries_page(
}
}
let tags_for_href = (!filter_tags.is_empty()).then_some(filter_tags.as_str());
let all_count: i64 = folder_rows.iter().map(|r| r.count).sum();
let mut folder_tabs: Vec<FolderTabView> = Vec::with_capacity(folder_rows.len() + 1);
folder_tabs.push(FolderTabView {
@@ -457,6 +494,7 @@ pub(super) async fn entries_page(
type_filter.as_deref(),
name_filter.as_deref(),
metadata_query_filter.as_deref(),
tags_for_href,
Some(1),
),
active: folder_filter.is_none(),
@@ -469,6 +507,7 @@ pub(super) async fn entries_page(
type_filter.as_deref(),
name_filter.as_deref(),
metadata_query_filter.as_deref(),
tags_for_href,
Some(1),
),
active: folder_filter.as_deref() == Some(name.as_str()),
@@ -534,6 +573,7 @@ pub(super) async fn entries_page(
filter_folder: folder_filter.unwrap_or_default(),
filter_name: name_filter.unwrap_or_default(),
filter_metadata_query: metadata_query_filter.unwrap_or_default(),
filter_tags,
filter_type: type_filter.unwrap_or_default(),
current_page,
total_pages,
@@ -1302,3 +1342,19 @@ pub(super) async fn api_entry_secrets_decrypt(
Ok(Json(json!({ "ok": true, "secrets": secrets })))
}
#[cfg(test)]
mod tags_filter_tests {
use super::parse_tags_filter;
#[test]
fn parse_tags_comma_trim_skip_empty() {
let v = parse_tags_filter(Some(" prod , aliyun ,, "));
assert_eq!(v, vec!["prod".to_string(), "aliyun".to_string()]);
}
#[test]
fn parse_tags_none_empty() {
assert!(parse_tags_filter(None).is_empty());
}
}

View 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!({})),
))
}

View File

@@ -16,7 +16,9 @@ mod account;
mod assets;
mod audit;
mod auth;
mod changelog;
mod entries;
mod local_mcp;
// ── Session keys ──────────────────────────────────────────────────────────────
@@ -253,10 +255,12 @@ pub fn web_router() -> Router<AppState> {
get(assets::oauth_protected_resource_metadata),
)
.route("/", get(auth::home_page))
.route("/changelog", get(changelog::changelog_page))
.route("/login", get(auth::login_page))
.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))
@@ -264,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))

View File

@@ -0,0 +1,185 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="canonical" href="{{ base_url }}/changelog">
<link rel="icon" href="/favicon.svg?v={{ version }}" type="image/svg+xml">
<title data-i18n="docTitle">变更记录 — Secrets</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;600&family=Inter:wght@400;500;600&display=swap');
:root {
--bg: #0d1117; --surface: #161b22;
--border: #30363d; --text: #e6edf3; --text-muted: #8b949e;
--accent: #58a6ff; --accent-hover: #79b8ff;
}
body { background: var(--bg); color: var(--text); font-family: 'Inter', sans-serif; min-height: 100vh; }
.wrap { max-width: 880px; margin: 0 auto; padding: 24px 20px 48px; }
.top {
display: flex; align-items: center; flex-wrap: wrap; gap: 12px 16px;
margin-bottom: 24px; padding-bottom: 16px;
border-bottom: 1px solid rgba(240,246,252,0.08);
}
.brand {
font-size: 18px; font-weight: 700; color: #fff; text-decoration: none;
}
.brand:hover { color: var(--accent); }
.top-actions { margin-left: auto; display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
.lang-bar { display: flex; gap: 2px; background: rgba(240,246,252,0.06); border-radius: 8px; padding: 2px; }
.lang-btn { padding: 4px 10px; border: none; background: none; color: #8b949e;
font-size: 12px; cursor: pointer; border-radius: 6px; }
.lang-btn.active { background: rgba(240,246,252,0.1); color: #fff; }
.link-dash {
font-size: 13px; color: var(--accent); text-decoration: none;
}
.link-dash:hover { text-decoration: underline; }
h1 { font-size: 22px; font-weight: 700; margin-bottom: 16px; color: #fff; }
.card {
background: #111827; border: 1px solid rgba(240,246,252,0.08); border-radius: 18px;
padding: 20px 22px;
}
/* Rendered Markdown (pulldown-cmark) */
.changelog-md {
font-size: 14px;
line-height: 1.65;
color: #c9d1d9;
}
.changelog-md > :first-child { margin-top: 0; }
.changelog-md > :last-child { margin-bottom: 0; }
.changelog-md h1 {
font-size: 1.5rem; font-weight: 700; color: #fff;
margin: 1.25em 0 0.5em; padding-bottom: 0.35em;
border-bottom: 1px solid rgba(240,246,252,0.1);
}
.changelog-md h2 {
font-size: 1.2rem; font-weight: 650; color: #f0f6fc;
margin: 1.35em 0 0.5em;
}
.changelog-md h3 { font-size: 1.05rem; font-weight: 600; color: #e6edf3; margin: 1.1em 0 0.45em; }
.changelog-md h4, .changelog-md h5, .changelog-md h6 { font-size: 1rem; font-weight: 600; color: #e6edf3; margin: 1em 0 0.4em; }
.changelog-md p { margin: 0.65em 0; }
.changelog-md ul, .changelog-md ol { margin: 0.65em 0; padding-left: 1.35em; }
.changelog-md li { margin: 0.3em 0; }
.changelog-md li > p { margin: 0.35em 0; }
.changelog-md a { color: var(--accent); text-decoration: none; }
.changelog-md a:hover { text-decoration: underline; }
.changelog-md code {
font-family: 'JetBrains Mono', ui-monospace, monospace;
font-size: 0.88em;
background: rgba(240,246,252,0.08);
padding: 0.12em 0.4em;
border-radius: 5px;
}
.changelog-md pre {
margin: 0.85em 0;
padding: 12px 14px;
overflow-x: auto;
background: #0d1117;
border: 1px solid rgba(240,246,252,0.1);
border-radius: 10px;
font-size: 12px;
line-height: 1.5;
}
.changelog-md pre code {
background: none;
padding: 0;
font-size: inherit;
border-radius: 0;
}
.changelog-md blockquote {
margin: 0.75em 0;
padding-left: 1em;
border-left: 3px solid rgba(56,139,253,0.45);
color: var(--text-muted);
}
.changelog-md hr {
margin: 1.25em 0;
border: none;
border-top: 1px solid rgba(240,246,252,0.1);
}
.changelog-md table {
width: 100%;
border-collapse: collapse;
margin: 0.85em 0;
font-size: 13px;
}
.changelog-md th, .changelog-md td {
border: 1px solid var(--border);
padding: 8px 10px;
text-align: left;
}
.changelog-md th { background: rgba(240,246,252,0.06); color: #f0f6fc; }
.changelog-md input[type="checkbox"] { margin-right: 0.35em; vertical-align: middle; }
.foot {
margin-top: 28px; text-align: center; font-size: 11px; color: var(--text-muted);
font-family: 'JetBrains Mono', monospace;
}
.foot a { color: var(--accent); text-decoration: none; }
.foot a:hover { text-decoration: underline; }
</style>
</head>
<body>
<div class="wrap">
<header class="top">
<a href="/" class="brand">secrets</a>
<div class="top-actions">
<a href="/dashboard" class="link-dash" data-i18n="backDash">控制台</a>
<div class="lang-bar" role="group" aria-label="Language">
<button type="button" class="lang-btn" onclick="setLang('zh-CN')"></button>
<button type="button" class="lang-btn" onclick="setLang('zh-TW')"></button>
<button type="button" class="lang-btn" onclick="setLang('en')">EN</button>
</div>
</div>
</header>
<h1 data-i18n="pageTitle">变更记录</h1>
<div class="card changelog-md">
{{ changelog_html|safe }}
</div>
<footer class="foot">
<span data-i18n="versionLabel">版本</span> {{ version }}
</footer>
</div>
<script>
const T = {
'zh-CN': {
docTitle: '变更记录 — Secrets',
pageTitle: '变更记录',
backDash: '控制台',
versionLabel: '版本',
},
'zh-TW': {
docTitle: '變更記錄 — Secrets',
pageTitle: '變更記錄',
backDash: '控制台',
versionLabel: '版本',
},
'en': {
docTitle: 'Changelog — Secrets',
pageTitle: 'Changelog',
backDash: 'Dashboard',
versionLabel: 'Version',
}
};
let currentLang = localStorage.getItem('lang') || 'zh-CN';
function t(key) { return (T[currentLang] && T[currentLang][key]) || T['en'][key] || key; }
function applyLang() {
document.documentElement.lang = currentLang;
document.title = t('docTitle');
document.querySelectorAll('[data-i18n]').forEach(el => {
el.textContent = t(el.getAttribute('data-i18n'));
});
document.querySelectorAll('.lang-btn').forEach(btn => {
const map = { 'zh-CN': '简', 'zh-TW': '繁', 'en': 'EN' };
btn.classList.toggle('active', btn.textContent === map[currentLang]);
});
}
function setLang(lang) {
currentLang = lang;
localStorage.setItem('lang', lang);
applyLang();
}
applyLang();
</script>
</body>
</html>

View File

@@ -57,6 +57,8 @@
font-family: 'JetBrains Mono', monospace;
margin-top: auto;
}
.app-footer a { color: var(--accent); text-decoration: none; }
.app-footer a:hover { text-decoration: underline; }
.card { background: #111827; border: 1px solid rgba(240,246,252,0.08); border-radius: 18px;
padding: 20px; width: 100%; }
.card-title { font-size: 22px; font-weight: 700; margin-bottom: 24px; color: #fff; }
@@ -288,7 +290,7 @@
</div>
</div>
</div>
<footer class="app-footer">{{ version }}</footer>
<footer class="app-footer">{{ version }} · <a href="/changelog" data-i18n="changelogLink">变更记录</a></footer>
</div><!-- /main -->
</div><!-- /content-shell -->
</div><!-- /layout -->
@@ -379,6 +381,7 @@ const T = {
regenFailed: '重置失败,请刷新页面重试。',
ariaShowPw: '显示密码',
ariaHidePw: '隐藏密码',
changelogLink: '变更记录',
},
'zh-TW': {
navMcp: 'MCP', navEntries: '條目', navTrash: '回收站', navAudit: '審計',
@@ -417,6 +420,7 @@ const T = {
regenFailed: '重置失敗,請重新整理頁面再試。',
ariaShowPw: '顯示密碼',
ariaHidePw: '隱藏密碼',
changelogLink: '變更記錄',
},
'en': {
navMcp: 'MCP', navEntries: 'Entries', navTrash: 'Trash', navAudit: 'Audit',
@@ -455,6 +459,7 @@ const T = {
regenFailed: 'Reset failed. Please refresh and try again.',
ariaShowPw: 'Show password',
ariaHidePw: 'Hide password',
changelogLink: 'Changelog',
}
};

View File

@@ -543,6 +543,10 @@
<label for="filter-name" data-i18n="filterNameLabel">名称</label>
<input id="filter-name" name="name" type="text" value="{{ filter_name }}" data-i18n-ph="filterNamePlaceholder" placeholder="输入关键字" autocomplete="off">
</div>
<div class="filter-field">
<label for="filter-tags" data-i18n="filterTagsLabel">标签</label>
<input id="filter-tags" name="tags" type="text" value="{{ filter_tags }}" data-i18n-ph="filterTagsPlaceholder" placeholder="多个标签用逗号分隔" autocomplete="off">
</div>
<div class="filter-field">
<label for="filter-metadata-query" data-i18n="filterMetadataLabel">元数据值</label>
<input id="filter-metadata-query" name="metadata_query" type="text" value="{{ filter_metadata_query }}" data-i18n-ph="filterMetadataPlaceholder" placeholder="搜索元数据值" autocomplete="off">
@@ -643,13 +647,13 @@
{% if total_count > 0 %}
<div class="pagination">
{% if current_page > 1 %}
<a href="?{% if !filter_folder.is_empty() %}folder={{ filter_folder | urlencode }}&{% endif %}{% if !filter_type.is_empty() %}type={{ filter_type | urlencode }}&{% endif %}{% if !filter_name.is_empty() %}name={{ filter_name | urlencode }}&{% endif %}{% if !filter_metadata_query.is_empty() %}metadata_query={{ filter_metadata_query | urlencode }}&{% endif %}page={{ current_page - 1 }}" class="page-btn" data-i18n="prevPage">上一页</a>
<a href="?{% if !filter_folder.is_empty() %}folder={{ filter_folder | urlencode }}&{% endif %}{% if !filter_type.is_empty() %}type={{ filter_type | urlencode }}&{% endif %}{% if !filter_name.is_empty() %}name={{ filter_name | urlencode }}&{% endif %}{% if !filter_tags.is_empty() %}tags={{ filter_tags | urlencode }}&{% endif %}{% if !filter_metadata_query.is_empty() %}metadata_query={{ filter_metadata_query | urlencode }}&{% endif %}page={{ current_page - 1 }}" class="page-btn" data-i18n="prevPage">上一页</a>
{% else %}
<span class="page-btn page-btn-disabled" data-i18n="prevPage">上一页</span>
{% endif %}
<span class="page-info">{{ current_page }} / {{ total_pages }}</span>
{% if current_page < total_pages %}
<a href="?{% if !filter_folder.is_empty() %}folder={{ filter_folder | urlencode }}&{% endif %}{% if !filter_type.is_empty() %}type={{ filter_type | urlencode }}&{% endif %}{% if !filter_name.is_empty() %}name={{ filter_name | urlencode }}&{% endif %}{% if !filter_metadata_query.is_empty() %}metadata_query={{ filter_metadata_query | urlencode }}&{% endif %}page={{ current_page + 1 }}" class="page-btn" data-i18n="nextPage">下一页</a>
<a href="?{% if !filter_folder.is_empty() %}folder={{ filter_folder | urlencode }}&{% endif %}{% if !filter_type.is_empty() %}type={{ filter_type | urlencode }}&{% endif %}{% if !filter_name.is_empty() %}name={{ filter_name | urlencode }}&{% endif %}{% if !filter_tags.is_empty() %}tags={{ filter_tags | urlencode }}&{% endif %}{% if !filter_metadata_query.is_empty() %}metadata_query={{ filter_metadata_query | urlencode }}&{% endif %}page={{ current_page + 1 }}" class="page-btn" data-i18n="nextPage">下一页</a>
{% else %}
<span class="page-btn page-btn-disabled" data-i18n="nextPage">下一页</span>
{% endif %}
@@ -713,6 +717,8 @@ var SECRET_TYPE_OPTIONS = JSON.parse(document.getElementById('secret-type-option
allTab: '全部',
filterNameLabel: '名称',
filterNamePlaceholder: '输入关键字',
filterTagsLabel: '标签',
filterTagsPlaceholder: '多个标签用逗号分隔',
filterMetadataLabel: '元数据值',
filterMetadataPlaceholder: '搜索元数据值',
filterTypeLabel: '类型',
@@ -799,6 +805,8 @@ var SECRET_TYPE_OPTIONS = JSON.parse(document.getElementById('secret-type-option
allTab: '全部',
filterNameLabel: '名稱',
filterNamePlaceholder: '輸入關鍵字',
filterTagsLabel: '標籤',
filterTagsPlaceholder: '多個標籤用逗號分隔',
filterMetadataLabel: '中繼資料值',
filterMetadataPlaceholder: '搜尋中繼資料值',
filterTypeLabel: '類型',
@@ -885,6 +893,8 @@ var SECRET_TYPE_OPTIONS = JSON.parse(document.getElementById('secret-type-option
allTab: 'All',
filterNameLabel: 'Name',
filterNamePlaceholder: 'Enter keywords',
filterTagsLabel: 'Tags',
filterTagsPlaceholder: 'Comma-separated tags',
filterMetadataLabel: 'Metadata value',
filterMetadataPlaceholder: 'Search metadata values',
filterTypeLabel: 'Type',

View File

@@ -178,10 +178,8 @@
<a href="/llms.txt">llms.txt</a>
<span data-i18n="sep"> · </span>
<a href="https://gitea.refining.dev/refining/secrets" target="_blank" rel="noopener noreferrer" data-i18n="footRepo">源码仓库</a>
{% if !is_logged_in %}
<span data-i18n="sep"> · </span>
<a href="/login" data-i18n="footLogin"></a>
{% endif %}
<a href="/changelog" data-i18n="footChangelog">变更记</a>
</footer>
<script>
const T = {
@@ -200,7 +198,7 @@
versionLabel: '版本',
sep: ' · ',
footRepo: '源码仓库',
footLogin: '录',
footChangelog: '变更记录',
},
'zh-TW': {
docTitle: 'Secrets MCP — 端到端加密的金鑰管理',
@@ -217,7 +215,7 @@
versionLabel: '版本',
sep: ' · ',
footRepo: '原始碼倉庫',
footLogin: '登入',
footChangelog: '變更記錄',
},
'en': {
docTitle: 'Secrets MCP — End-to-end encrypted secrets',
@@ -234,7 +232,7 @@
versionLabel: 'Version',
sep: ' · ',
footRepo: 'Source repository',
footLogin: 'Sign in',
footChangelog: 'Changelog',
}
};

View File

@@ -20,9 +20,14 @@ BASE_URL=https://secrets.example.com
# ─── Google OAuth ─────────────────────────────────────────────────────
# Google Cloud Console → APIs & Services → Credentials
# 授权回调 URI 须配置为${BASE_URL}/auth/google/callback
# 授权回调 URI 须与 BASE_URL 完全一致${BASE_URL}/auth/google/callback(含 http/https、主机名、端口
# 运行 secrets-mcp 的机器须能访问 Googleoauth2.googleapis.com。若本机用 Clash/Surge「系统代理」上网
# 构建时已启用 reqwest 的 system-proxy进程会跟随系统代理仍失败时可设 HTTPS_PROXY见下方
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
# 若仍无法换 token仅提供端口代理、无系统代理可取消注释并改为本机代理地址
# HTTPS_PROXY=http://127.0.0.1:7890
# NO_PROXY=localhost,127.0.0.1
# ─── 微信登录(暂未开放,预留)───────────────────────────────────────
# WECHAT_APP_CLIENT_ID=
@@ -51,3 +56,11 @@ GOOGLE_CLIENT_SECRET=
# 设为 1/true/yes 时从 X-Forwarded-For / X-Real-IP 提取客户端 IP
# 仅在反代环境下启用,否则客户端可伪造 IP 绕过限流
# TRUST_PROXY=1
# ─── 本机 MCP gatewaysecrets-mcp-local可选────────────────────────
# 在开发者机器上运行,与上方服务端 .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_UNLOCK_TTL_SECS=3600
# SECRETS_LOCAL_EXEC_CONTEXT_TTL_SECS=3600

View File

@@ -3,6 +3,7 @@
**日期**: 2026-04-11
**来源**: 两个 AI 实现对比评估
**比较对象**:
- `d7720662` (`/Users/voson/work/refining/secrets-cr-fixes-ws`)
- `9f8a68cd` (`/Users/voson/work/refining/secrets/plan-impl`)
@@ -10,9 +11,10 @@
## 结论
**`d7720662`** 为主线采纳。
`**d7720662`** 为主线采纳。
**原因**:
1. `rollback` 的 live row 加锁与 snapshot 读取都在事务内完成,更符合原计划里对 TOCTOU 的修复要求。
2. Web JSON API 的 session 校验保留了按 `UiLang` 返回错误信息的行为,没有把错误消息退化成固定英文。
3. `svc_add` 返回 `entry_id`MCP 层直接使用返回值建立 parent relation和计划第 5 项更一致。
@@ -35,10 +37,9 @@
仅吸收下面两处,手动改写,不直接整文件 cherry-pick
1. `crates/secrets-mcp/src/web/entries.rs`
- 把长度校验报错文案改成基于 `crate::validation::*` 常量拼接,避免上限数字硬编码在文案里。
- 把长度校验报错文案改成基于 `crate::validation::`* 常量拼接,避免上限数字硬编码在文案里。
2. `crates/secrets-core/src/service/env_map.rs`
-`env_prefix_with_and_without_prefix` 单测。
-`env_prefix_with_and_without_prefix` 单测。
---
@@ -47,18 +48,21 @@
### 不采纳 `9f8a68cd` 的 `rollback.rs`
原因:
- 它仍然先在事务外读取 `entries_history`,再开启事务并锁 live row。
- 对“回滚到最近快照”的路径仍存在先读后锁的时间窗口。
### 不采纳 `9f8a68cd` 的 `web/mod.rs`
原因:
- `load_session_user_strict()` / `require_valid_user_json()` 返回固定英文 JSON 错误。
- 会丢失现有多语言错误语义。
### 不采纳 `9f8a68cd` 的 `AddResult.id`
原因:
- 本轮计划里明确要求 `svc_add` 返回 `entry_id`
- `d7720662` 的字段命名与 MCP 使用方式更贴近计划要求。
@@ -82,32 +86,26 @@
### 尚需补齐的验证
1. export/import round-trip 测试
- `password` / `key` / `text` 三种类型导出再导入后保持不变
- `password` / `key` / `text` 三种类型导出再导入后保持不变
2. legacy import 测试
- 老格式缺失 `secret_types` 时默认回落到 `text`
- 老格式缺失 `secret_types` 时默认回落到 `text`
3. env map 测试
- `db.password` vs `db_password`
-`prefix`
- 多 entry 合并冲突
- `db.password` vs `db_password`
-`prefix`
- 多 entry 合并冲突
4. rollback 测试
- 恢复字段是否符合预期
- 并发更新 + 回滚不依赖过期值
- 恢复字段是否符合预期
- 并发更新 + 回滚不依赖过期值
5. `regenerate_api_key` 测试
- 正常用户返回新 key
- 不存在用户返回错误
- 正常用户返回新 key
- 不存在用户返回错误
6. MCP tool 测试
- `secrets_find` count 失败路径
- `secrets_rollback` 无 encryption key 也可执行
- `secrets_find` count 失败路径
- `secrets_rollback` 无 encryption key 也可执行
7. Web session / validation 测试
- `key_version` mismatch -> `401`
- 用户不存在 / session 损坏 -> 正确错误
- `folder/type/name/notes` 超长 -> `400`
- `key_version` mismatch -> `401`
- 用户不存在 / session 损坏 -> 正确错误
- `folder/type/name/notes` 超长 -> `400`
---
@@ -126,18 +124,18 @@ cargo clippy --locked -- -D warnings
cargo test --locked
```
7. 跑发布前检查:
1. 跑发布前检查:
```bash
./scripts/release-check.sh
```
8. 确认版本和 tag
- `crates/secrets-mcp/Cargo.toml` 已 bump合并执行时为 `0.5.21`,因 `crates/**` 有变更)
- `jj tag list`
1. 确认版本和 tag
- `crates/secrets-mcp/Cargo.toml` 已 bump合并执行时为 `0.5.21`,因 `crates/**` 有变更)
- `jj tag list`
---
## 备注
如果后续要做最终合并,建议以 `d7720662` 为基础继续补测试,而不是尝试把两份实现整合成第三套逻辑。这样改动面最小,风险也最低。
如果后续要做最终合并,建议以 `d7720662` 为基础继续补测试,而不是尝试把两份实现整合成第三套逻辑。这样改动面最小,风险也最低。

View File

@@ -23,7 +23,7 @@ Add a new `metadata_query` filter to `SearchParams` that uses PostgreSQL `jsonb_
#### secrets-core
**`crates/secrets-core/src/service/search.rs`**
`**crates/secrets-core/src/service/search.rs`**
- Add `metadata_query: Option<&'a str>` field to `SearchParams`
- In `entry_where_clause_and_next_idx`, when `metadata_query` is set, add:
@@ -42,7 +42,7 @@ EXISTS (
#### secrets-mcp (MCP tools)
**`crates/secrets-mcp/src/tools.rs`**
`**crates/secrets-mcp/src/tools.rs**`
- Add `metadata_query` field to `FindInput`:
@@ -56,7 +56,7 @@ metadata_query: Option<String>,
#### secrets-mcp (Web)
**`crates/secrets-mcp/src/web/entries.rs`**
`**crates/secrets-mcp/src/web/entries.rs**`
- Add `metadata_query: Option<String>` to `EntriesQuery`
- Thread it into all `SearchParams` usages (count, list, folder counts)
@@ -64,17 +64,19 @@ metadata_query: Option<String>,
- Add `metadata_query` to `EntriesPageTemplate` and filter form hidden fields
- Include `metadata_query` in pagination `href` links
**`crates/secrets-mcp/templates/entries.html`**
`**crates/secrets-mcp/templates/entries.html**`
- Add a "metadata 值" text input to the filter bar (after name, before type)
- Preserve value in the input on re-render
### i18n Keys
| Key | zh | zh-Hant | en |
|-----|-----|---------|-----|
| `filterMetaLabel` | 元数据值 | 元数据值 | Metadata value |
| `filterMetaPlaceholder` | 搜索元数据值 | 搜尋元資料值 | Search metadata values |
| Key | zh | zh-Hant | en |
| ----------------------- | ------ | ------- | ---------------------- |
| `filterMetaLabel` | 元数据值 | 元数据值 | Metadata value |
| `filterMetaPlaceholder` | 搜索元数据值 | 搜尋元資料值 | Search metadata values |
### Performance Notes
@@ -191,81 +193,76 @@ pub async fn get_relations_for_entries(
) -> Result<HashMap<Uuid, Vec<RelationSummary>>>
```
**`crates/secrets-core/src/service/mod.rs`** — add `pub mod relations;`
`**crates/secrets-core/src/service/mod.rs**` — add `pub mod relations;`
**`crates/secrets-core/src/db.rs`** — add entry_relations table creation in `migrate()`
`**crates/secrets-core/src/db.rs**` — add entry_relations table creation in `migrate()`
**`crates/secrets-core/src/error.rs`** — no new error variant needed; use `AppError::Validation { message }` for cycle detection and permission errors
`**crates/secrets-core/src/error.rs**` — no new error variant needed; use `AppError::Validation { message }` for cycle detection and permission errors
### MCP Tool Changes
**`crates/secrets-mcp/src/tools.rs`**
`**crates/secrets-mcp/src/tools.rs**`
1. **`secrets_add`** (`AddInput`): add optional `parent_ids: Option<Vec<String>>` field
- Description: "UUIDs of parent entries to link. Creates parent→child relations."
- After creating the entry, call `relations::add_relation` for each parent
2. **`secrets_update`** (`UpdateInput`): add two fields:
- `add_parent_ids: Option<Vec<String>>` — "UUIDs of parent entries to link"
- `remove_parent_ids: Option<Vec<String>>` — "UUIDs of parent entries to unlink"
3. **`secrets_find`** and `secrets_search` output: add `parents` and `children` arrays to each entry result:
```json
1. `**secrets_add**` (`AddInput`): add optional `parent_ids: Option<Vec<String>>` field
- Description: "UUIDs of parent entries to link. Creates parent→child relations."
- After creating the entry, call `relations::add_relation` for each parent
2. `**secrets_update**` (`UpdateInput`): add two fields:
- `add_parent_ids: Option<Vec<String>>` — "UUIDs of parent entries to link"
- `remove_parent_ids: Option<Vec<String>>` — "UUIDs of parent entries to unlink"
3. `**secrets_find**` and `secrets_search` output: add `parents` and `children` arrays to each entry result:
```json
{
"id": "...",
"name": "...",
"parents": [{"id": "...", "name": "...", "folder": "...", "type": "..."}],
"children": [{"id": "...", "name": "...", "folder": "...", "type": "..."}]
}
```
- Fetch relations for all returned entry IDs in a single batch query
```
- Fetch relations for all returned entry IDs in a single batch query
### Web Changes
**`crates/secrets-mcp/src/web/entries.rs`**
`**crates/secrets-mcp/src/web/entries.rs**`
1. **New API endpoints:**
- `POST /api/entries/{id}/relations` — add parent relation
- Body: `{ "parent_id": "uuid" }`
- Validates same-user ownership and cycle detection
- `DELETE /api/entries/{id}/relations/{parent_id}` — remove parent relation
- `GET /api/entries/options?q=xxx` — lightweight search for parent selection modal
- Returns `[{ "id": "...", "name": "...", "folder": "...", "type": "..." }]`
- Used by the edit dialog's parent selection autocomplete
- `POST /api/entries/{id}/relations` — add parent relation
- Body: `{ "parent_id": "uuid" }`
- Validates same-user ownership and cycle detection
- `DELETE /api/entries/{id}/relations/{parent_id}` — remove parent relation
- `GET /api/entries/options?q=xxx` — lightweight search for parent selection modal
- Returns `[{ "id": "...", "name": "...", "folder": "...", "type": "..." }]`
- Used by the edit dialog's parent selection autocomplete
2. **Entry list template data** — include parent/child counts per entry row
3. `**api_entry_patch`** — extend `EntryPatchBody` with optional `parent_ids: Option<Vec<Uuid>>`
- When present, replace all parent relations for this entry with the given list
- This is simpler than incremental add/remove in the Web UI context
3. **`api_entry_patch`** — extend `EntryPatchBody` with optional `parent_ids: Option<Vec<Uuid>>`
- When present, replace all parent relations for this entry with the given list
- This is simpler than incremental add/remove in the Web UI context
**`crates/secrets-mcp/templates/entries.html`**
`**crates/secrets-mcp/templates/entries.html**`
1. **List table**: add a "关联" (relations) column showing parent/child counts as clickable chips
2. **Edit dialog**: add "上级条目" (parent entries) section
- Show current parents as removable chips
- Add a search-as-you-type input that queries `/api/entries/options`
- Click a search result to add it as parent
- On save, send `parent_ids` in the PATCH body
- Show current parents as removable chips
- Add a search-as-you-type input that queries `/api/entries/options`
- Click a search result to add it as parent
- On save, send `parent_ids` in the PATCH body
3. **View dialog / detail**: show "下级条目" (children) list with clickable links that navigate to the child entry
4. **i18n**: add keys for all new UI elements
### i18n Keys (Entry Relations)
| Key | zh | zh-Hant | en |
|-----|-----|---------|-----|
| `colRelations` | 关联 | 關聯 | Relations |
| `parentEntriesLabel` | 上级条目 | 上級條目 | Parent entries |
| `childrenEntriesLabel` | 级条目 | 級條目 | Child entries |
| `addParentLabel` | 添加上级 | 新增上級 | Add parent |
| `removeParentLabel` | 移除上级 | 移除上級 | Remove parent |
| `searchEntriesPlaceholder` | 搜索条目… | 搜尋條目… | Search entries… |
| `noParents` | 无上级 | 無上級 | No parents |
| `noChildren` | 无级 | 無| No children |
| `relationCycleError` | 无法添加:会形成循环引用 | 無法新增:會形成循環引用 | Cannot add: would create a cycle |
| Key | zh | zh-Hant | en |
| -------------------------- | ------------ | ------------ | -------------------------------- |
| `colRelations` | 关联 | 關聯 | Relations |
| `parentEntriesLabel` | 级条目 | 級條目 | Parent entries |
| `childrenEntriesLabel` | 下级条目 | 下級條目 | Child entries |
| `addParentLabel` | 添加上级 | 新增上級 | Add parent |
| `removeParentLabel` | 移除上级 | 移除上級 | Remove parent |
| `searchEntriesPlaceholder` | 搜索条目… | 搜尋條目… | Search entries… |
| `noParents` | 无 | 無 | No parents |
| `noChildren` | 无下级 | 無下級 | No children |
| `relationCycleError` | 无法添加:会形成循环引用 | 無法新增:會形成循環引用 | Cannot add: would create a cycle |
### Audit Logging
@@ -276,7 +273,7 @@ Log relation changes in the existing `audit::log_tx` system:
### Export / Import
**`ExportEntry`** — add optional `parents: Vec<ParentRef>` where:
`**ExportEntry`** — add optional `parents: Vec<ParentRef>` where:
```rust
pub struct ParentRef {
@@ -368,25 +365,28 @@ This is idempotent (uses `IF NOT EXISTS`) and will run automatically on next sta
## Testing Checklist
### Metadata Search
- [ ] `metadata_query=1.2.3.4` matches entries where any metadata value contains "1.2.3.4"
- [ ] `metadata_query=1.2.3.4` does NOT match entries where only the key contains "1.2.3.4"
- [ ] `metadata_query` works with nested metadata (e.g. `{"server": {"ip": "1.2.3.4"}}`)
- [ ] `metadata_query` combined with `folder`/`type`/`tags` filters works correctly
- [ ] `metadata_query` with special characters (`%`, `_`) is properly escaped
- [ ] Existing `query` parameter behavior is unchanged
- [ ] Web filter bar preserves `metadata_query` across pagination and folder tab clicks
- `metadata_query=1.2.3.4` matches entries where any metadata value contains "1.2.3.4"
- `metadata_query=1.2.3.4` does NOT match entries where only the key contains "1.2.3.4"
- `metadata_query` works with nested metadata (e.g. `{"server": {"ip": "1.2.3.4"}}`)
- `metadata_query` combined with `folder`/`type`/`tags` filters works correctly
- `metadata_query` with special characters (`%`, `_`) is properly escaped
- Existing `query` parameter behavior is unchanged
- Web filter bar preserves `metadata_query` across pagination and folder tab clicks
### Entry Relations
- [ ] Can add a parent→child relation between two entries
- [ ] Can add multiple parents to a single entry
- [ ] Cannot add self-referencing relation (CHECK constraint)
- [ ] Cannot create a direct cycle (A→B→A)
- [ ] Cannot create an indirect cycle (A→B→C→A)
- [ ] Cannot link entries from different users
- [ ] Deleting an entry removes all its relation edges but leaves related entries intact
- [ ] MCP `secrets_add` with `parent_ids` creates relations
- [ ] MCP `secrets_update` with `add_parent_ids`/`remove_parent_ids` modifies relations
- [ ] MCP `secrets_find`/`secrets_search` output includes `parents` and `children`
- [ ] Web entry list shows relation counts
- [ ] Web edit dialog allows adding/removing parents
- [ ] Web entry view shows children with navigation links
- Can add a parent→child relation between two entries
- Can add multiple parents to a single entry
- Cannot add self-referencing relation (CHECK constraint)
- Cannot create a direct cycle (A→B→A)
- Cannot create an indirect cycle (A→B→C→A)
- Cannot link entries from different users
- Deleting an entry removes all its relation edges but leaves related entries intact
- MCP `secrets_add` with `parent_ids` creates relations
- MCP `secrets_update` with `add_parent_ids`/`remove_parent_ids` modifies relations
- MCP `secrets_find`/`secrets_search` output includes `parents` and `children`
- Web entry list shows relation counts
- Web edit dialog allows adding/removing parents
- Web entry view shows children with navigation links

View File

@@ -20,6 +20,7 @@
### 2. 查看密文弹窗 — 增加管理功能
在每个解密字段行中增加:
- **重命名输入框**inline edit带 debounce 校验)
- **类型下拉选择**
- **解绑按钮**
@@ -51,4 +52,4 @@
## 涉及文件
- `crates/secrets-mcp/templates/entries.html`HTML + JS + CSS
- `crates/secrets-mcp/src/web/entries.rs`(无需修改,复用现有 API
- `crates/secrets-mcp/src/web/entries.rs`(无需修改,复用现有 API

178
plans/web-tags-filter.md Normal file
View File

@@ -0,0 +1,178 @@
# Web 条目页 tags 筛选计划
## 目标
在 Web `/entries` 页面补齐 `tags` 筛选能力,使现有 `tags` 字段在 Web、MCP、数据层三者之间保持一致。
本次只做最小实现:支持用户在 Web 上输入 tags 条件并筛选条目,不改动数据库结构,不新增 MCP tool 参数,不改动条目编辑语义。
## 当前状态
- 数据层已支持 `tags` 过滤:`crates/secrets-core/src/service/search.rs`
- MCP 已支持 `tags` 参数:`crates/secrets-mcp/src/tools.rs`
- Web `/entries` 仅展示 tags 列与编辑字段,没有筛选入口:
- 查询参数缺少 `tags``crates/secrets-mcp/src/web/entries.rs`
- 模板筛选栏缺少 tags 输入:`crates/secrets-mcp/templates/entries.html`
- Web 查询当前固定传 `tags: &[]`
## 范围
### 包含
- `/entries` 页面增加 tags 筛选输入
- 后端将 tags 解析并传入 `SearchParams`
- 分页、folder tabs、筛选重置后的 URL 状态保持一致
- i18n 文案补齐
### 不包含
- MCP 工具改造
- 数据库迁移或索引变更
- `/trash` 页面筛选增强
- 新增 tags 自动补全、标签选择器、标签管理页
## 交互定义
### 输入方式
-`/entries` 筛选栏增加一个 `tags` 文本输入框
- 输入格式采用逗号分隔,例如:`prod, aliyun`
- 服务端按逗号拆分、`trim`、去掉空字符串
### 匹配语义
- 继续复用现有搜索层语义:`tags @> ARRAY[...]::text[]`
- 即:用户输入多个 tags 时,要求条目同时包含这些 tagsAND 语义)
### 状态保持
- 筛选提交后,输入框保留原值
- 分页上一页/下一页链接保留 `tags`
- folder tabs 切换时保留 `tags`
- `重置` 仍回到 `/entries`,清空所有筛选条件
## 实施步骤
### 1. 扩展 Web 查询参数与模板上下文
文件:`crates/secrets-mcp/src/web/entries.rs`
-`EntriesQuery` 中增加 `tags: Option<String>`
-`EntriesPageTemplate` 中增加 `filter_tags: String`
-`entries_page` 中读取原始 tags 字符串,用于模板回填
- 将原始字符串解析为 `Vec<String>`,供 `SearchParams.tags` 使用
建议新增一个局部辅助函数,职责仅限于:
- 接收 `Option<&str>`
- 按逗号分割
- `trim`
- 过滤空值
- 返回 `Vec<String>`
保持逻辑局部化,避免把 tags 解析散落到多个位置。
### 2. 将 tags 传入条目查询与计数
文件:`crates/secrets-mcp/src/web/entries.rs`
- 更新 `count_params`,不再使用 `tags: &[]`
- 更新 `list_params`,复用相同 tags 切片
- 确保总数统计、分页列表与实际筛选条件一致
## 3. 让 folder tabs 计数遵循相同 tags 条件
文件:`crates/secrets-mcp/src/web/entries.rs`
当前 folder tabs 使用手写 SQL 统计各 folder 数量,需要同步加入 tags 条件,否则会出现:
- 列表已按 tags 过滤
- 但 folder tab 数量仍是未过滤结果
实现方式:
- 在构建 `folder_sql` 时,当 tags 非空,追加 `tags @> ARRAY[...]::text[]`
- 对应补齐 bind 参数
- 保持与 `SearchParams` 的过滤语义完全一致
## 4. 让 URL 生成函数保留 tags
文件:`crates/secrets-mcp/src/web/entries.rs`
- 扩展 `entries_href(...)` 参数,加入 `tags: Option<&str>`
- 在 folder tabs 链接中传入当前 tags
- 在需要保留筛选状态的地方一并传递 tags
## 5. 更新模板筛选栏与分页链接
文件:`crates/secrets-mcp/templates/entries.html`
- 在筛选表单中新增 tags 输入框
- 输入框 value 绑定 `filter_tags`
- 为 tags 输入框增加 i18n label / placeholder
- 分页链接 `上一页/下一页` 补充 `tags` query 参数
建议放置位置:名称与元数据值之间或元数据值与类型之间,保持现有筛选栏布局最小改动。
## 6. 补齐前端文案
文件:`crates/secrets-mcp/templates/entries.html`
新增 i18n key
- `filterTagsLabel`
- `filterTagsPlaceholder`
建议文案:
- zh-CN: `标签` / `多个标签用逗号分隔`
- zh-Hant: `標籤` / `多個標籤用逗號分隔`
- en: `Tags` / `Comma-separated tags`
## 验收标准
### 功能验收
- 访问 `/entries?tags=prod` 时,只返回包含 `prod` 的条目
- 访问 `/entries?tags=prod,aliyun` 时,只返回同时包含 `prod``aliyun` 的条目
- tags 两侧空格不影响结果,例如 `prod, aliyun`
- 空字符串、重复逗号不会报错,例如 `prod,,aliyun`
- 分页后 `tags` 不丢失
- 切换 folder tab 后 `tags` 不丢失
- 重置后清空 `tags`
### 一致性验收
- 页面总数、列表内容、folder tab 数量使用同一组 tags 条件
- Web 语义与 MCP / `SearchParams` 语义一致,均为 AND 匹配
## 风险与注意点
- folder tabs 计数 SQL 是手写的,最容易漏掉 tags 绑定顺序
- `list_params` 基于 `count_params` 结构展开,注意借用生命周期不要引入临时值悬垂
- 分页链接和 `entries_href` 若漏传 `tags`,用户会感觉筛选“偶尔失效”
- 现阶段不做 tags 规范化;输入 `Prod` 与存储 `prod` 是否匹配,取决于数组元素本身是否完全一致
## 可选后续
如果上线后确认 `tags` 仍被频繁使用,可以继续做:
1. tags chip UI而不是纯文本输入
2. 常用 tags 自动补全
3. 在 Web 过滤栏里明确提示“多个标签为同时匹配”
4. 评估是否需要大小写规范化策略
## 验证建议
实现后至少执行:
```bash
cargo fmt -- --check
cargo test --locked
```
如果本次提交涉及 `crates/**`,按仓库规则在提交前再执行:
```bash
./scripts/release-check.sh
```