Compare commits

...

88 Commits

Author SHA1 Message Date
df701f21b9 feat(secrets-mcp): 共享 key 删除时自动迁移并重定向 (v0.3.7)
All checks were successful
Secrets MCP — Build & Release / 检查 / 构建 / 发版 (push) Successful in 4m4s
Secrets MCP — Build & Release / 部署 secrets-mcp (push) Successful in 6s
删除仍被 metadata.key_ref 引用的 key 条目时,在同一事务内将密文复制到首个引用方,
其余引用方的 key_ref 重定向到新 owner;env_map 解析 key_ref 时不再限定 type=key。
Web 删除 API 返回 migrated;Dashboard 删除成功后提示迁移。

Bump secrets-mcp to 0.3.7;补充删除迁移相关单测(需 SECRETS_DATABASE_URL)。

Made-with: Cursor
2026-04-03 09:27:20 +08:00
c3c536200e feat(secrets-mcp): Web 条目编辑 API 与 Notes 列表展示优化(0.3.6)
All checks were successful
Secrets MCP — Build & Release / 检查 / 构建 / 发版 (push) Successful in 3m55s
Secrets MCP — Build & Release / 部署 secrets-mcp (push) Successful in 6s
- secrets-core: EntryWriteRow;按 id 更新/删除(含并发冲突与唯一键)
- Web: PATCH/DELETE /api/entries/{id};列表编辑/删除与错误映射
- entries 模板:Notes 限高滚动;空 Notes 不显示占位框
- 版本 0.3.5 → 0.3.6,同步 Cargo.lock

Made-with: Cursor
2026-04-02 14:58:10 +08:00
7909f7102d feat(secrets-mcp): 条目页按 folder/type 筛选并发版 0.3.5
- entries 路由支持 ?folder=&type= 查询,与搜索层 SearchParams 对齐
- 条目列表页增加筛选表单与说明文案
- 版本 0.3.4 → 0.3.5,同步 Cargo.lock

Made-with: Cursor
2026-04-02 14:37:36 +08:00
87a29af82d feat(web): 条目列表页 /entries 与总条数统计
All checks were successful
Secrets MCP — Build & Release / 检查 / 构建 / 发版 (push) Successful in 3m56s
Secrets MCP — Build & Release / 部署 secrets-mcp (push) Successful in 6s
- 新增受会话保护的 GET /entries,仅展示 entries 非敏感列
- search: list_entries、count_entries 共享筛选条件;分页与计数不读 secrets
- 侧边栏在 dashboard/audit 增加「条目」入口
- secrets-mcp 0.3.4(tag 尚未存在)

Made-with: Cursor
2026-04-02 11:26:51 +08:00
1b11f7e976 release(secrets-mcp): v0.3.3 — 强制 PostgreSQL TLS 校验
Some checks failed
Secrets MCP — Build & Release / 检查 / 构建 / 发版 (push) Successful in 3m54s
Secrets MCP — Build & Release / 部署 secrets-mcp (push) Failing after 7s
显式引入数据库 TLS 配置并在生产环境拒绝弱 sslmode,避免连接静默降级。同步更新 deploy/README 与运维 runbook,落地 db.refining.ltd 的证书与服务器配置流程。

Made-with: Cursor
2026-04-01 15:18:14 +08:00
08e81363c9 release(secrets-mcp): v0.3.2 — 修复 key_ref 多租户与歧义
All checks were successful
Secrets MCP — Build & Release / 检查 / 构建 / 发版 (push) Successful in 3m41s
Secrets MCP — Build & Release / 部署 secrets-mcp (push) Successful in 6s
- env_map:key_ref 解析传入 user_id;支持 folder/name;多条匹配时报错
- 文档同步 key_ref 说明
- bump secrets-mcp 0.3.1 → 0.3.2,更新 Cargo.lock

Made-with: Cursor
2026-03-27 10:45:12 +08:00
voson
beade4503d release(secrets-mcp): v0.3.1
All checks were successful
Secrets MCP — Build & Release / 检查 / 构建 / 发版 (push) Successful in 3m45s
Secrets MCP — Build & Release / 部署 secrets-mcp (push) Successful in 5s
- MCP: secrets_find, secrets_overview; secrets_get id-only; id on update/delete/history/rollback
- Add meta_obj/secrets_obj; delete guard; env_map/instructions updates
- Core: resolve_entry_by_id; get_*_by_id validates entry + tenant before decrypt

Made-with: Cursor
2026-03-26 17:35:56 +08:00
voson
409fd78a35 Release secrets-mcp 0.3.0: folder/type schema and MCP folder disambiguation
All checks were successful
Secrets MCP — Build & Release / 检查 / 构建 / 发版 (push) Successful in 3m39s
Secrets MCP — Build & Release / 部署 secrets-mcp (push) Successful in 5s
- Rename namespace/kind to folder/type on entries, audit_log, and history tables;
  add notes. Unique key is (user_id, folder, name).
- Service layer and MCP tools support name-first lookup with optional folder when
  multiple entries share the same name.
- secrets_delete dry_run uses the same disambiguation as real deletes.
- Add scripts/migrate-v0.3.0.sql for manual DB migration. Refresh README and
  AGENTS.md.

Made-with: Cursor
2026-03-26 15:12:28 +08:00
voson
f7afd7f819 docs: 同步 CI 触发路径、覆盖式 tag/Release 说明与 RUST_LOG 示例
All checks were successful
Secrets MCP — Build & Release / 检查 / 构建 / 发版 (push) Successful in 2m11s
Secrets MCP — Build & Release / 部署 secrets-mcp (push) Successful in 5s
- AGENTS.md / README:与 workflow 变更路径、远端 tag 覆盖及非 draft Release 行为一致
- deploy/.env.example:补充可选 RUST_LOG 注释

Made-with: Cursor
2026-03-22 16:15:29 +08:00
voson
719bdd7e08 feat(secrets-mcp): public home at /, login at /login (0.2.2)
All checks were successful
Secrets MCP — Build & Release / 检查 / 构建 / 发版 (push) Successful in 3m17s
Secrets MCP — Build & Release / 部署 secrets-mcp (push) Successful in 5s
Bump secrets-mcp to 0.2.2 and sync Cargo.lock.

Add home.html landing with SEO and footer link to the refining/secrets
repository; serve it at / and expose /login for sign-in.

Update OAuth error redirects and dashboard unauthenticated redirects to
/login. Improve login page meta tags, back-home link, and OAuth error
alert. Refresh llms.txt and robots.txt.

Made-with: Cursor
2026-03-22 16:11:59 +08:00
voson
1e597559a2 feat(core): FK for user_id columns; MCP search requires user
All checks were successful
Secrets MCP — Build & Release / 检查 / 构建 / 发版 (push) Successful in 3m10s
Secrets MCP — Build & Release / 部署 secrets-mcp (push) Successful in 5s
- Add fk_entries_user_id, fk_entries_history_user_id, fk_audit_log_user_id (ON DELETE SET NULL)
- Add scripts/cleanup-orphan-user-ids.sql for pre-deploy orphan user_id cleanup
- Remove deprecated SERVER_MASTER_KEY / per-user key wrap helpers from secrets-core
- secrets-mcp: require authenticated user for secrets_search; improve body-read failure response
- Bump secrets-mcp to 0.2.1

Made-with: Cursor
2026-03-22 15:40:02 +08:00
voson
e3ca43ca3f release(secrets-mcp): 0.2.0
All checks were successful
Secrets MCP — Build & Release / 检查 / 构建 / 发版 (push) Successful in 3m12s
Secrets MCP — Build & Release / 部署 secrets-mcp (push) Successful in 5s
- 日志时间戳使用本地时区(chrono RFC3339 + 偏移)
- MCP tools / Web 路由与行为调整
- 新增 static/llms.txt、robots.txt;文档与 deploy 示例同步

Made-with: Cursor
2026-03-22 14:44:00 +08:00
voson
0b57605103 feat(secrets-mcp): MCP 请求日志、探测 404 与资源元数据
All checks were successful
Secrets MCP — Build & Release / 检查 / 构建 / 发版 (push) Successful in 3m10s
Secrets MCP — Build & Release / 部署 secrets-mcp (push) Successful in 5s
- 新增 logging 中间件:记录 client_ip、ua、JSON-RPC、tool 等
- tools 各入口/出口结构化日志
- 探测型 404(/.well-known、GET /mcp)降为 debug
- /.well-known/oauth-protected-resource 最小元数据
- secrets-mcp 0.1.11

Made-with: Cursor
2026-03-21 17:57:10 +08:00
voson
8b191937cd docs(AGENTS): 精简提交/推送规则第4条
Made-with: Cursor
2026-03-21 16:56:06 +08:00
voson
11c936a5b8 docs(AGENTS): 明确提交/推送前必须检查版本号与运行 fmt/clippy/test
Made-with: Cursor
2026-03-21 16:48:47 +08:00
voson
b6349dd1c8 chore(secrets-mcp): bump version to 0.1.10
All checks were successful
Secrets MCP — Build & Release / 检查 / 构建 / 发版 (push) Successful in 2m57s
Secrets MCP — Build & Release / 部署 secrets-mcp (push) Successful in 5s
Made-with: Cursor
2026-03-21 16:46:33 +08:00
voson
f720983328 refactor(db): 移除无意义 actor,修复 history 多租户与模型
Some checks failed
Secrets MCP — Build & Release / 部署 secrets-mcp (push) Has been cancelled
Secrets MCP — Build & Release / 检查 / 构建 / 发版 (push) Has started running
- 删除 entries_history / audit_log / secrets_history 的 actor 列及写入逻辑
- MCP secrets_history 透传当前 user_id
- Entry 增加 user_id,search 查询不再用伪 UUID
- 迁移:保留 users.api_key,从 api_keys 表回退时生成新明文 key 并删表
- 文档:audit_log auth 语义、API Key 存储说明

Made-with: Cursor
2026-03-21 16:45:50 +08:00
voson
7bd0603dc6 chore(secrets-mcp): bump version to 0.1.9
All checks were successful
Secrets MCP — Build & Release / 检查 / 构建 / 发版 (push) Successful in 2m47s
Secrets MCP — Build & Release / 部署 secrets-mcp (push) Successful in 5s
Made-with: Cursor
2026-03-21 12:25:38 +08:00
voson
17a95bea5b refactor(audit): 移除旧格式兼容,user_id 统一走列字段
Some checks failed
Secrets MCP — Build & Release / 部署 secrets-mcp (push) Has been cancelled
Secrets MCP — Build & Release / 检查 / 构建 / 发版 (push) Has been cancelled
- audit_log 查询去掉 detail->>'user_id' 回退分支
- login_detail 不再冗余写入 user_id 到 detail JSON
- 迁移 SQL 去掉多余的 ALTER TABLE ADD COLUMN

Made-with: Cursor
2026-03-21 12:24:00 +08:00
voson
a42db62702 style(secrets-mcp): rustfmt web.rs audit mapping
All checks were successful
Secrets MCP — Build & Release / 检查 / 构建 / 发版 (push) Successful in 5m20s
Secrets MCP — Build & Release / 部署 secrets-mcp (push) Successful in 6s
Made-with: Cursor
2026-03-21 12:06:29 +08:00
voson
2edb970cba chore(secrets-mcp): bump version to 0.1.8
Some checks failed
Secrets MCP — Build & Release / 检查 / 构建 / 发版 (push) Failing after 19s
Secrets MCP — Build & Release / 部署 secrets-mcp (push) Has been skipped
Made-with: Cursor
2026-03-21 12:05:22 +08:00
voson
17f8ac0dbc web: 审计页时间按浏览器本地时区显示
Some checks failed
Secrets MCP — Build & Release / 检查 / 构建 / 发版 (push) Failing after 25s
Secrets MCP — Build & Release / 部署 secrets-mcp (push) Has been skipped
Made-with: Cursor
2026-03-21 12:03:44 +08:00
voson
259fbe10a6 ci: 精简 Release upsert 逻辑
All checks were successful
Secrets MCP — Build & Release / 检查 / 构建 / 发版 (push) Successful in 4m36s
Secrets MCP — Build & Release / 部署 secrets-mcp (push) Successful in 5s
提取 auth/api 公共变量避免重复;用 xargs 单行替换 while 循环清理
旧 assets;POST 分支用管道直接取 id 省去临时文件。
279 行 → 248 行。

Made-with: Cursor
2026-03-21 11:36:43 +08:00
voson
c815fb4cc8 ci: 修复覆盖重发时 Release 唯一约束冲突
DELETE + POST 同名 release 会触发 Gitea 的 UQE_release_n 约束。
改为:已有 release → PATCH 更新 name/body,再逐个删除旧 assets 后重传;
      无 release → 正常 POST 新建。

Made-with: Cursor
2026-03-21 11:33:45 +08:00
voson
90cd1eca15 ci: 允许对同版本覆盖重发版
Some checks failed
Secrets MCP — Build & Release / 检查 / 构建 / 发版 (push) Failing after 4m33s
Secrets MCP — Build & Release / 部署 secrets-mcp (push) Has been skipped
- 解析版本时不再 exit 1,改为记录 tag_exists=true 并打印警告
- 创建 Tag 步骤:若 tag 已存在则先本地删除再远端删除,再重新打带注释的 tag
- 创建 Release 步骤:先查询同名 Release,若存在则 DELETE 旧 Release,再 POST 新建

Made-with: Cursor
2026-03-21 11:22:24 +08:00
voson
da007348ea ci: 合并为 ci + deploy 两个 job,check 先于 build
Some checks failed
Secrets MCP — Build & Release / 检查 / 构建 / 发版 (push) Failing after 7s
Secrets MCP — Build & Release / 部署 secrets-mcp (push) Has been skipped
单台 self-hosted runner 下并行 job 只是排队,多 job 拆分带来的
artifact 传递、重复 checkout、调度延迟反而更慢。

改动:
- 原 version/check/build-linux/publish-release 四个 job 合并为单个 ci job
- 步骤顺序:版本拦截 → fmt/clippy/test → build → 打 tag → 发 Release
- tag 在构建成功后才创建,避免失败提交留下脏 tag
- Release 创建+上传+发布合并为单步,去掉草稿中转
- deploy job 仅保留 artifact 下载 + SSH 部署逻辑,不再重复编译
- 整体从 400 行缩减至 244 行

Made-with: Cursor
2026-03-21 11:18:10 +08:00
voson
f2344b7543 feat(secrets-mcp): 审计页、audit_log user_id、OAuth 登录与仪表盘 footer
All checks were successful
Secrets MCP — Build & Release / 版本 & Release (push) Successful in 3s
Secrets MCP — Build & Release / 质量检查 (fmt / clippy / test) (push) Successful in 7m20s
Secrets MCP — Build & Release / Build Linux (musl) (push) Successful in 8m23s
Secrets MCP — Build & Release / 发布草稿 Release (push) Successful in 1s
Secrets MCP — Build & Release / 部署 secrets-mcp (push) Successful in 6s
- audit_log 增加 user_id;业务写审计透传 user_id
- Web /audit 与侧边栏;Dashboard 版本 footer 贴底(margin-top: auto)
- 停止 API Key 鉴权成功写入登录审计
- 文档、CI、release-check 配套更新

Made-with: Cursor
2026-03-21 11:12:11 +08:00
voson
ee028d45c3 ci: 优化 workflow 并行度与产物传递
- check 与 build-linux 改为并行执行,节省约 10min
- 新增 upload-artifact / download-artifact,deploy-mcp 直接复用二进制,免重复编译(节省约 15min)
- check / build 缓存加入 target/ 目录,加速增量编译
- 提取 MUSL_TARGET 全局变量,消除 x86_64-unknown-linux-musl 硬编码
- publish-release 增加 check 结果检查,质量失败时不发布 Release
- 移除 build-linux 冗余飞书通知,publish-release 汇总已覆盖

Made-with: Cursor
2026-03-21 10:07:29 +08:00
voson
a44c8ebf08 feat(mcp): persist login audit for OAuth and API key
All checks were successful
Secrets MCP — Build & Release / 版本 & Release (push) Successful in 3s
Secrets MCP — Build & Release / 质量检查 (fmt / clippy / test) (push) Successful in 3m16s
Secrets MCP — Build & Release / Build Linux (secrets-mcp, musl) (push) Successful in 4m32s
Secrets MCP — Build & Release / 发布草稿 Release (push) Successful in 3s
Secrets MCP — Build & Release / 部署 secrets-mcp (push) Successful in 4m33s
- Add audit::log_login in secrets-core (audit_log detail: user_id, provider, client_ip, user_agent)
- Log web Google OAuth success after session established
- Log MCP Bearer API key auth success in middleware
- Bump secrets-mcp to 0.1.6 (tag 0.1.5 existed)

Made-with: Cursor
2026-03-21 09:48:52 +08:00
voson
a595081c4c fix(dashboard): OpenCode 配置顶层 mcp 包裹;bump secrets-mcp 0.1.5
All checks were successful
Secrets MCP — Build & Release / 版本 & Release (push) Successful in 3s
Secrets MCP — Build & Release / 质量检查 (fmt / clippy / test) (push) Successful in 3m15s
Secrets MCP — Build & Release / Build Linux (secrets-mcp, musl) (push) Successful in 4m36s
Secrets MCP — Build & Release / 发布草稿 Release (push) Successful in 5s
Secrets MCP — Build & Release / 部署 secrets-mcp (push) Successful in 4m34s
Made-with: Cursor
2026-03-21 09:23:51 +08:00
voson
0a8b14211a ci: 恢复 secrets workflow 为标准发版流程
Some checks failed
Secrets MCP — Build & Release / 版本 & Release (push) Failing after 2s
Secrets MCP — Build & Release / 质量检查 (fmt / clippy / test) (push) Has been skipped
Secrets MCP — Build & Release / Build Linux (secrets-mcp, musl) (push) Has been skipped
Secrets MCP — Build & Release / 部署 secrets-mcp (push) Has been skipped
Secrets MCP — Build & Release / 发布草稿 Release (push) Has been skipped
Made-with: Cursor
2026-03-21 09:17:35 +08:00
voson
9cebbd7587 ci: 支持构建重跑并跳过重复发版
All checks were successful
Secrets MCP — Build & Release / 检测变更范围 (push) Successful in 3s
Secrets MCP — Build & Release / 版本 & Release (push) Has been skipped
Secrets MCP — Build & Release / 质量检查 (fmt / clippy / test) (push) Has been skipped
Secrets MCP — Build & Release / Build Linux (secrets-mcp, musl) (push) Has been skipped
Secrets MCP — Build & Release / 部署 secrets-mcp (push) Has been skipped
Secrets MCP — Build & Release / 发布草稿 Release (push) Has been skipped
让 workflow 根据变更范围区分发版构建与仅验证构建,并补充手动触发入口,避免已有版本 tag 阻塞缓存恢复后的重跑验证。

Made-with: Cursor
2026-03-21 09:10:05 +08:00
voson
4d136a5a20 ci: 停止缓存 target,避免 runner 磁盘耗尽
All checks were successful
Secrets MCP — Build & Release / 版本 & Release (push) Successful in 3s
Secrets MCP — Build & Release / 质量检查 (fmt / clippy / test) (push) Successful in 3m16s
Secrets MCP — Build & Release / Build Linux (secrets-mcp, musl) (push) Successful in 4m43s
Secrets MCP — Build & Release / 发布草稿 Release (push) Successful in 3s
Secrets MCP — Build & Release / 部署 secrets-mcp (push) Successful in 4m33s
同时将 secrets-mcp 版本提升到 0.1.4,以触发新的构建与发布流程。

Made-with: Cursor
2026-03-20 22:10:48 +08:00
voson
7ce4aaf835 ci: 缓存键包含 Rust 版本;chore(secrets-mcp): 0.1.3
Some checks failed
Secrets MCP — Build & Release / 版本 & Release (push) Successful in 3s
Secrets MCP — Build & Release / 质量检查 (fmt / clippy / test) (push) Failing after 2m2s
Secrets MCP — Build & Release / Build Linux (secrets-mcp, musl) (push) Has been skipped
Secrets MCP — Build & Release / 部署 secrets-mcp (push) Has been skipped
Secrets MCP — Build & Release / 发布草稿 Release (push) Successful in 2s
Made-with: Cursor
2026-03-20 22:04:40 +08:00
voson
bce01a0f2b chore(secrets-mcp): bump version to 0.1.2
Some checks failed
Secrets MCP — Build & Release / 版本 & Release (push) Successful in 3s
Secrets MCP — Build & Release / 质量检查 (fmt / clippy / test) (push) Failing after 2m21s
Secrets MCP — Build & Release / Build Linux (secrets-mcp, musl) (push) Has been skipped
Secrets MCP — Build & Release / 部署 secrets-mcp (push) Has been skipped
Secrets MCP — Build & Release / 发布草稿 Release (push) Successful in 8s
Made-with: Cursor
2026-03-20 21:56:57 +08:00
voson
8cd4dbf592 ci: 固定 Rust 1.94.0(rust-toolchain + Gitea Actions)
Made-with: Cursor
2026-03-20 21:54:13 +08:00
voson
ad3c8d1672 chore(secrets-mcp): bump version to 0.1.1
Some checks failed
Secrets MCP — Build & Release / 版本 & Release (push) Successful in 3s
Secrets MCP — Build & Release / 质量检查 (fmt / clippy / test) (push) Failing after 2m12s
Secrets MCP — Build & Release / Build Linux (secrets-mcp, musl) (push) Has been skipped
Secrets MCP — Build & Release / 部署 secrets-mcp (push) Has been skipped
Secrets MCP — Build & Release / 发布草稿 Release (push) Successful in 2s
Made-with: Cursor
2026-03-20 21:37:20 +08:00
voson
8d6b9f0368 ci: 质量检查依赖版本 job,重复 tag 时提前失败
Made-with: Cursor
2026-03-20 21:35:00 +08:00
voson
ce9e089348 chore: CI 微调、文档与 dashboard 更新、精简 Gitea Actions 安装脚本
Some checks failed
Secrets MCP — Build & Release / 版本 & Release (push) Failing after 2s
Secrets MCP — Build & Release / 质量检查 (fmt / clippy / test) (push) Failing after 2m8s
Secrets MCP — Build & Release / Build Linux (secrets-mcp, musl) (push) Has been skipped
Secrets MCP — Build & Release / 部署 secrets-mcp (push) Has been skipped
Secrets MCP — Build & Release / 发布草稿 Release (push) Has been skipped
Made-with: Cursor
2026-03-20 21:31:43 +08:00
voson
786675ce42 ci: allow secrets-mcp workflow on mcp branch
Enable build and deploy jobs when pushing the current mcp branch so CI artifacts can be used for deployment without waiting for manual server compilation.

Made-with: Cursor
2026-03-20 20:33:47 +08:00
voson
5df4141935 feat: user-scoped history/delete/rollback, dashboard & login UI, ignore *.pem
- Filter history/rollback/delete by user_id in secrets-core
- MCP tools/web pass user context; dashboard refresh; favicon static
- .gitignore *.pem; vscode tasks tweaks
- clippy: collapse else-if in rollback latest-history branch

Made-with: Cursor
2026-03-20 20:11:19 +08:00
voson
49fb7430a8 refactor: workspace secrets-core + secrets-mcp MCP SaaS
- Split library (db/crypto/service) and MCP/Web/OAuth binary
- Add deploy examples and CI/docs updates

Made-with: Cursor
2026-03-20 17:36:00 +08:00
voson
ff9767ff95 chore(ci): 精简 publish-release job,移除多余 checkout
Made-with: Cursor
2026-03-19 20:26:45 +08:00
voson
955acfe9ec feat(run): 选择性字段注入、dry-run 预览、默认 JSON 输出
Some checks failed
Secrets CLI - Build & Release / 版本 & Release (push) Successful in 3s
Secrets CLI - Build & Release / 质量检查 (fmt / clippy / test) (push) Successful in 2m20s
Secrets CLI - Build & Release / Build (macOS aarch64 + x86_64) (push) Successful in 1m4s
Secrets CLI - Build & Release / Build (x86_64-unknown-linux-musl) (push) Successful in 1m13s
Secrets CLI - Build & Release / Build (x86_64-pc-windows-msvc) (push) Has been cancelled
Secrets CLI - Build & Release / 发布草稿 Release (push) Has been cancelled
- run 新增 -s/--secret 字段过滤,只注入指定字段到子进程(最小权限)
- run 新增 --dry-run 模式,输出变量名与来源映射,不执行命令、不暴露值
- run 新增 -o 参数,dry-run 默认 JSON 输出
- 默认输出格式改为始终 json,移除 TTY 自动切换逻辑,-o text 供人类使用
- build_injected_env_map 签名从 &[SecretField] 改为 &[&SecretField]
- 更新 AGENTS.md、README.md、.vscode/tasks.json
- version: 0.9.5 → 0.9.6

Made-with: Cursor
2026-03-19 17:39:09 +08:00
voson
3a5ec92bf0 fix: inject/run 仅注入 secrets 字段,不含 metadata
Some checks failed
Secrets CLI - Build & Release / 版本 & Release (push) Successful in 3s
Secrets CLI - Build & Release / 质量检查 (fmt / clippy / test) (push) Successful in 2m36s
Secrets CLI - Build & Release / Build (macOS aarch64 + x86_64) (push) Successful in 1m3s
Secrets CLI - Build & Release / Build (x86_64-unknown-linux-musl) (push) Successful in 1m15s
Secrets CLI - Build & Release / 发布草稿 Release (push) Has been cancelled
Secrets CLI - Build & Release / Build (x86_64-pc-windows-msvc) (push) Has been cancelled
- build_injected_env_map 不再合并 metadata
- 删除 build_metadata_env_map 及其测试
- 更新 README、AGENTS.md 文档
- bump 版本至 0.9.5

Made-with: Cursor
2026-03-19 17:03:01 +08:00
voson
854720f10c chore: remove field_type and value_len from secrets schema
Some checks failed
Secrets CLI - Build & Release / 版本 & Release (push) Successful in 3s
Secrets CLI - Build & Release / 质量检查 (fmt / clippy / test) (push) Successful in 2m34s
Secrets CLI - Build & Release / Build (macOS aarch64 + x86_64) (push) Successful in 1m3s
Secrets CLI - Build & Release / Build (x86_64-unknown-linux-musl) (push) Successful in 1m15s
Secrets CLI - Build & Release / 发布草稿 Release (push) Has been cancelled
Secrets CLI - Build & Release / Build (x86_64-pc-windows-msvc) (push) Has been cancelled
- Drop field_type, value_len from secrets and secrets_history tables
- Remove infer_field_type, compute_value_len from add.rs
- Simplify search output to field names only
- Update AGENTS.md, README.md documentation

Bump version to 0.9.4

Made-with: Cursor
2026-03-19 16:48:23 +08:00
voson
62a1df316b docs: README 补充 delete 批量删除与 --dry-run 示例
Some checks failed
Secrets CLI - Build & Release / 版本 & Release (push) Successful in 3s
Secrets CLI - Build & Release / 质量检查 (fmt / clippy / test) (push) Successful in 2m30s
Secrets CLI - Build & Release / Build (macOS aarch64 + x86_64) (push) Successful in 1m1s
Secrets CLI - Build & Release / Build (x86_64-unknown-linux-musl) (push) Successful in 1m17s
Secrets CLI - Build & Release / 发布草稿 Release (push) Has been cancelled
Secrets CLI - Build & Release / Build (x86_64-pc-windows-msvc) (push) Has been cancelled
Made-with: Cursor
2026-03-19 16:32:20 +08:00
voson
d0796e9c9a feat: delete 命令支持批量删除,--name 改为可选
省略 --name 时按 namespace(+ 可选 --kind)批量删除所有匹配记录;
支持 --dry-run 预览;删除前自动快照历史并写入审计日志。
移除独立的 delete-ns 子命令,合并为统一的 delete 入口。
更新 AGENTS.md 文档,版本 bump 至 0.9.3。

Made-with: Cursor
2026-03-19 16:31:18 +08:00
voson
66b6417faa feat: 开源准备与 upgrade URL 构建时配置
- upgrade: SECRETS_UPGRADE_URL 改为构建时优先(option_env!),CI 自动注入
- upgrade: 支持运行时回退(.env/export),添加 dotenvy 加载 .env
- 泛化示例:IP/实例 ID/域名/密钥名改为示例值(10.0.0.1、example.com 等)
- tasks.json: 文件 secret 测试改用 test-fixtures/example-key.pem
- 文档更新:AGENTS.md、README.md

Made-with: Cursor
2026-03-19 16:08:27 +08:00
voson
56a28e8cf7 refactor: 消除冗余、统一设计,bump 0.9.1
Some checks failed
Secrets CLI - Build & Release / 版本 & Release (push) Successful in 3s
Secrets CLI - Build & Release / 质量检查 (fmt / clippy / test) (push) Successful in 2m46s
Secrets CLI - Build & Release / Build (macOS aarch64 + x86_64) (push) Successful in 1m27s
Secrets CLI - Build & Release / Build (x86_64-unknown-linux-musl) (push) Successful in 2m0s
Secrets CLI - Build & Release / 发布草稿 Release (push) Has been cancelled
Secrets CLI - Build & Release / Build (x86_64-pc-windows-msvc) (push) Has been cancelled
- 提取 EntryRow/SecretFieldRow 到 models.rs
- 提取 current_actor()、print_json() 公共函数
- ExportFormat::from_extension 复用 from_str
- fetch_entries 默认 limit 100k(export/inject/run 不再截断)
- history 独立为 history.rs 模块
- delete 改用 DeleteArgs 结构体
- config_dir 改为 Result,Argon2id 参数提取常量
- Cargo 依赖 ^ 前缀、tokio 精简 features
- 更新 AGENTS.md 项目结构

Made-with: Cursor
2026-03-19 15:46:57 +08:00
voson
12aec6675a feat: add export/import commands for batch backup (JSON/TOML/YAML)
Some checks failed
Secrets CLI - Build & Release / 发布草稿 Release (push) Has been cancelled
Secrets CLI - Build & Release / Build (x86_64-pc-windows-msvc) (push) Has been cancelled
Secrets CLI - Build & Release / 版本 & Release (push) Successful in 3s
Secrets CLI - Build & Release / 质量检查 (fmt / clippy / test) (push) Successful in 2m14s
Secrets CLI - Build & Release / Build (macOS aarch64 + x86_64) (push) Successful in 1m3s
Secrets CLI - Build & Release / Build (x86_64-unknown-linux-musl) (push) Successful in 1m15s
- export: filter by namespace/kind/name/tag/query, decrypt secrets, write to file or stdout
- import: parse file, conflict check (error by default, --force to overwrite), --dry-run preview
- Add ExportFormat enum, ExportData/ExportEntry in models.rs with TOML↔JSON conversion
- Bump version to 0.9.0

Made-with: Cursor
2026-03-19 15:29:26 +08:00
voson
e1cd6e736c refactor: entries + secrets 双表,search 展示 field schema,key_ref PEM 共享
Some checks failed
Secrets CLI - Build & Release / 质量检查 (fmt / clippy / test) (push) Successful in 1m57s
Secrets CLI - Build & Release / 版本 & Release (push) Successful in 3s
Secrets CLI - Build & Release / Build (macOS aarch64 + x86_64) (push) Successful in 51s
Secrets CLI - Build & Release / Build (x86_64-unknown-linux-musl) (push) Successful in 1m6s
Secrets CLI - Build & Release / 发布草稿 Release (push) Has been cancelled
Secrets CLI - Build & Release / Build (x86_64-pc-windows-msvc) (push) Has been cancelled
- secrets 表拆为 entries(主表)+ secrets(每字段一行)
- search 无需 master_key 即可展示 secrets 字段名、类型、长度
- inject/run 支持 metadata.key_ref 引用 kind=key 记录,PEM 轮换 O(1)
- entries_history + secrets_history 字段级历史,rollback 按 version 恢复
- 移除迁移用 DROP 语句,migrate 幂等
- v0.8.0

Made-with: Cursor
2026-03-19 15:18:12 +08:00
voson
0a5317e477 feat: remove -o env from search command
Some checks failed
Secrets CLI - Build & Release / 版本 & Release (push) Successful in 3s
Secrets CLI - Build & Release / 质量检查 (fmt / clippy / test) (push) Successful in 1m58s
Secrets CLI - Build & Release / Build (macOS aarch64 + x86_64) (push) Successful in 1m1s
Secrets CLI - Build & Release / Build (x86_64-unknown-linux-musl) (push) Successful in 1m2s
Secrets CLI - Build & Release / 发布草稿 Release (push) Has been cancelled
Secrets CLI - Build & Release / Build (x86_64-pc-windows-msvc) (push) Has been cancelled
- Remove OutputMode::Env from output.rs
- Remove env output branch and shell_quote from search.rs
- Update docs (AGENTS.md, README.md, main.rs help)

Bump version to 0.7.5

Made-with: Cursor
2026-03-19 14:33:38 +08:00
voson
efa76cae55 feat(add,update): key:=json typed values, nested path for meta/secrets, bump 0.7.4
Some checks failed
Secrets CLI - Build & Release / 质量检查 (fmt / clippy / test) (push) Successful in 1m53s
Secrets CLI - Build & Release / Build (x86_64-unknown-linux-musl) (push) Successful in 1m3s
Secrets CLI - Build & Release / 版本 & Release (push) Successful in 3s
Secrets CLI - Build & Release / Build (macOS aarch64 + x86_64) (push) Successful in 49s
Secrets CLI - Build & Release / 发布草稿 Release (push) Has been cancelled
Secrets CLI - Build & Release / Build (x86_64-pc-windows-msvc) (push) Has been cancelled
Made-with: Cursor
2026-03-19 14:27:04 +08:00
voson
5a5867adc1 chore: local timezone in text output, search metadata-only, bump 0.7.3
Some checks failed
Secrets CLI - Build & Release / 版本 & Release (push) Successful in 3s
Secrets CLI - Build & Release / Build (x86_64-unknown-linux-musl) (push) Successful in 2m15s
Secrets CLI - Build & Release / 质量检查 (fmt / clippy / test) (push) Successful in 1m50s
Secrets CLI - Build & Release / Build (macOS aarch64 + x86_64) (push) Successful in 44s
Secrets CLI - Build & Release / Build (x86_64-pc-windows-msvc) (push) Has been cancelled
Secrets CLI - Build & Release / 发布草稿 Release (push) Has been cancelled
Made-with: Cursor
2026-03-19 12:24:20 +08:00
voson
4ddafbe4b6 chore: remove dead code, bump to 0.7.2
Some checks failed
Secrets CLI - Build & Release / 版本 & Release (push) Successful in 3s
Secrets CLI - Build & Release / 质量检查 (fmt / clippy / test) (push) Successful in 1m49s
Secrets CLI - Build & Release / Build (macOS aarch64 + x86_64) (push) Successful in 43s
Secrets CLI - Build & Release / Build (x86_64-unknown-linux-musl) (push) Successful in 1m2s
Secrets CLI - Build & Release / 发布草稿 Release (push) Has been cancelled
Secrets CLI - Build & Release / Build (x86_64-pc-windows-msvc) (push) Has been cancelled
- Remove unused delete_master_key from crypto.rs
- Remove unused audit::log from audit.rs
- Simplify HistoryRow in rollback.rs (drop unused namespace/kind/name)
- Update AGENTS.md: audit::log → audit::log_tx

Made-with: Cursor
2026-03-19 11:43:01 +08:00
voson
6ea9f0861b chore: bump to 0.7.1, workflow/readme/init/upgrade updates, fix clippy needless_borrows
Some checks failed
Secrets CLI - Build & Release / 版本 & Release (push) Successful in 3s
Secrets CLI - Build & Release / 质量检查 (fmt / clippy / test) (push) Successful in 1m47s
Secrets CLI - Build & Release / Build (macOS aarch64 + x86_64) (push) Successful in 48s
Secrets CLI - Build & Release / Build (x86_64-unknown-linux-musl) (push) Successful in 1m2s
Secrets CLI - Build & Release / 发布草稿 Release (push) Has been cancelled
Secrets CLI - Build & Release / Build (x86_64-pc-windows-msvc) (push) Has been cancelled
Made-with: Cursor
2026-03-19 11:34:10 +08:00
voson
3973295d6a chore(release): enforce version bump checks
Fail fast when a release tag already exists, and add a local release-check script so version mistakes are caught before commit and publish.

Made-with: Cursor
2026-03-19 11:17:23 +08:00
voson
c371da95c3 chore: bump version to 0.7.0 for upgrade feature
Some checks failed
Secrets CLI - Build & Release / 版本 & Release (push) Successful in 3s
Secrets CLI - Build & Release / 质量检查 (fmt / clippy / test) (push) Successful in 2m39s
Secrets CLI - Build & Release / Build (macOS aarch64 + x86_64) (push) Successful in 2m11s
Secrets CLI - Build & Release / Build (x86_64-unknown-linux-musl) (push) Successful in 2m17s
Secrets CLI - Build & Release / 发布草稿 Release (push) Has been cancelled
Secrets CLI - Build & Release / Build (x86_64-pc-windows-msvc) (push) Has been cancelled
Made-with: Cursor
2026-03-19 11:06:59 +08:00
voson
baad623efe feat(upgrade): SHA-256校验、Intel mac 交叉编译、全平台后发布
Some checks failed
Secrets CLI - Build & Release / 版本 & Release (push) Successful in 2s
Secrets CLI - Build & Release / Build (x86_64-unknown-linux-musl) (push) Has been cancelled
Secrets CLI - Build & Release / Build (macOS aarch64 + x86_64) (push) Has been cancelled
Secrets CLI - Build & Release / Build (x86_64-pc-windows-msvc) (push) Has been cancelled
Secrets CLI - Build & Release / 发布草稿 Release (push) Has been cancelled
Secrets CLI - Build & Release / 质量检查 (fmt / clippy / test) (push) Has been cancelled
- upgrade: 下载后校验 .sha256 摘要再安装
- workflow: ARM mac 同时产出 aarch64/x86_64 双架构,补全 Intel mac 产物
- workflow: 各平台上传主资产及 .sha256,Linux/macOS/Windows 全成功才发布 Release
- upgrade: 补充 parse_tag_version、parse_checksum_file、extract_from_targz 单元测试
- docs: README/AGENTS 同步 upgrade 与平台说明

Made-with: Cursor
2026-03-19 11:06:10 +08:00
voson
2da7aab3e5 feat(upgrade): add self-update command from Gitea Release
Some checks failed
Secrets CLI - Build & Release / Build (x86_64-unknown-linux-musl) (push) Has been cancelled
Secrets CLI - Build & Release / Build (aarch64-apple-darwin) (push) Has been cancelled
Secrets CLI - Build & Release / Build (x86_64-pc-windows-msvc) (push) Has been cancelled
Secrets CLI - Build & Release / 发布草稿 Release (push) Has been cancelled
Secrets CLI - Build & Release / 版本 & Release (push) Successful in 2s
Secrets CLI - Build & Release / 质量检查 (fmt / clippy / test) (push) Has been cancelled
- Add secrets upgrade command: --check to verify, default to download and replace binary
- No database or master key required
- Support tar.gz and zip artifacts from Gitea Release

Made-with: Cursor
2026-03-19 11:01:43 +08:00
voson
fcac14a8c4 docs(AGENTS): clarify version bump must update Cargo.lock too
Made-with: Cursor
2026-03-19 10:41:49 +08:00
voson
ff79a3a9cc chore: sync Cargo.lock for 0.6.1
Some checks failed
Secrets CLI - Build & Release / 版本 & Release (push) Successful in 2s
Secrets CLI - Build & Release / 质量检查 (fmt / clippy / test) (push) Successful in 1m40s
Secrets CLI - Build & Release / Build (aarch64-apple-darwin) (push) Successful in 34s
Secrets CLI - Build & Release / Build (x86_64-unknown-linux-musl) (push) Successful in 51s
Secrets CLI - Build & Release / Build (x86_64-pc-windows-msvc) (push) Has been cancelled
Secrets CLI - Build & Release / 发布草稿 Release (push) Successful in 2s
Made-with: Cursor
2026-03-19 10:40:30 +08:00
voson
3c21b3dac1 chore: bump version to 0.6.1
Some checks failed
Secrets CLI - Build & Release / 版本 & Release (push) Successful in 2s
Secrets CLI - Build & Release / 质量检查 (fmt / clippy / test) (push) Failing after 39s
Secrets CLI - Build & Release / Build (x86_64-unknown-linux-musl) (push) Has been skipped
Secrets CLI - Build & Release / Build (aarch64-apple-darwin) (push) Has been skipped
Secrets CLI - Build & Release / Build (x86_64-pc-windows-msvc) (push) Has been skipped
Secrets CLI - Build & Release / 发布草稿 Release (push) Successful in 2s
Made-with: Cursor
2026-03-19 10:39:07 +08:00
voson
3b36d5a3dd feat(config): verify DB connection before saving set-db
Some checks failed
Secrets CLI - Build & Release / 版本 & Release (push) Successful in 2s
Secrets CLI - Build & Release / Build (x86_64-unknown-linux-musl) (push) Has been cancelled
Secrets CLI - Build & Release / Build (aarch64-apple-darwin) (push) Has been cancelled
Secrets CLI - Build & Release / Build (x86_64-pc-windows-msvc) (push) Has been cancelled
Secrets CLI - Build & Release / 发布草稿 Release (push) Has been cancelled
Secrets CLI - Build & Release / 质量检查 (fmt / clippy / test) (push) Has been cancelled
- Check connection with create_pool before writing to config
- Show 'Database connection failed' on error, do not overwrite config
- Update AGENTS.md and README.md

Made-with: Cursor
2026-03-19 10:38:38 +08:00
voson
a765dcc428 feat: 0.6.0 — 事务/版本化/类型化/inject/run
Some checks failed
Secrets CLI - Build & Release / 版本 & Release (push) Successful in 2s
Secrets CLI - Build & Release / 质量检查 (fmt / clippy / test) (push) Successful in 1m37s
Secrets CLI - Build & Release / Build (aarch64-apple-darwin) (push) Successful in 37s
Secrets CLI - Build & Release / Build (x86_64-unknown-linux-musl) (push) Successful in 50s
Secrets CLI - Build & Release / 发布草稿 Release (push) Successful in 2s
Secrets CLI - Build & Release / Build (x86_64-pc-windows-msvc) (push) Has been cancelled
- 写路径事务化:add/update/delete 与 audit 同事务,update CAS 并发保护
- 版本化与回滚:secrets_history 表、version 字段、history/rollback 命令
- 类型化字段:key:=<json> 支持数字、布尔、数组、对象
- 临时 env 模式:inject 输出 KEY=VALUE,run 向子进程注入
- inject/run 至少需一个过滤条件;search -o env 使用 shell_quote;JSON 输出含 version

Made-with: Cursor
2026-03-19 10:30:45 +08:00
voson
31b0ea9bf1 refactor: 代码审阅优化
Some checks failed
Secrets CLI - Build & Release / 版本 & Release (push) Successful in 2s
Secrets CLI - Build & Release / 质量检查 (fmt / clippy / test) (push) Successful in 1m42s
Secrets CLI - Build & Release / Build (x86_64-unknown-linux-musl) (push) Successful in 1m18s
Secrets CLI - Build & Release / 发布草稿 Release (push) Successful in 2s
Secrets CLI - Build & Release / Build (aarch64-apple-darwin) (push) Failing after 7m40s
Secrets CLI - Build & Release / Build (x86_64-pc-windows-msvc) (push) Has been cancelled
P0:
- fix(config): config_dir 使用 home_dir 回退,避免 ~ 不展开
- fix(search): 模糊查询转义 LIKE 通配符 % 和 _

P1:
- chore(db): 连接池添加 acquire_timeout 10s
- refactor(update): 消除 meta_keys/secret_keys 重复计算

P2:
- refactor(config): 合并 ConfigAction 枚举
- chore(deps): 移除 clap/env、uuid/v4 无用 features
- perf(main): delete 命令跳过 master_key 加载
- i18n(config): 统一错误消息为英文
- perf(search): show_secrets=false 时不再解密获取 key_count
- feat(delete,update): 支持 -o json/json-compact 输出

P3:
- feat(search): --tag 支持多值交叉过滤

docs: 将 seed-data.sh 替换为 setup-gitea-actions.sh
Made-with: Cursor
2026-03-19 09:31:53 +08:00
voson
dc0534cbc9 refactor(secrets): remove migrate_encrypt command
Some checks failed
Secrets CLI - Build & Release / 版本 & Release (push) Successful in 3s
Secrets CLI - Build & Release / 质量检查 (fmt / clippy / test) (push) Successful in 1m38s
Secrets CLI - Build & Release / Build (x86_64-unknown-linux-musl) (push) Successful in 1m9s
Secrets CLI - Build & Release / 发布草稿 Release (push) Successful in 5s
Secrets CLI - Build & Release / Build (aarch64-apple-darwin) (push) Failing after 7m27s
Secrets CLI - Build & Release / Build (x86_64-pc-windows-msvc) (push) Has been cancelled
Made-with: Cursor
2026-03-19 09:17:04 +08:00
voson
8fdb6db87b feat: 客户端加密 encrypted 字段,数据库只存密文 (v0.5.0)
Some checks failed
Secrets CLI - Build & Release / 质量检查 (fmt / clippy / test) (push) Successful in 1m27s
Secrets CLI - Build & Release / 版本 & Release (push) Successful in 2s
Secrets CLI - Build & Release / Build (x86_64-unknown-linux-musl) (push) Successful in 1m14s
Secrets CLI - Build & Release / 发布草稿 Release (push) Successful in 2s
Secrets CLI - Build & Release / Build (aarch64-apple-darwin) (push) Failing after 11m1s
Secrets CLI - Build & Release / Build (x86_64-pc-windows-msvc) (push) Has been cancelled
- 新增 src/crypto.rs:AES-256-GCM 加解密 + Argon2id 密钥派生 + OS Keychain 读写
- 新增 `secrets init` 命令:输入 Master Password,派生 Master Key 存入 Keychain
- 新增 `secrets migrate-encrypt` 命令:将旧明文 JSONB 数据批量加密
- 修改 db.rs:encrypted 列 JSONB → BYTEA,新增 kv_config 表(存 Argon2id salt)
- 修改 models.rs:encrypted 字段类型 Value → Vec<u8>
- 修改 add/update:写入前 encrypt_json,update 读取后 decrypt → 合并 → 重新加密
- 修改 search:按需解密,未解密时显示 _encrypted:true/_key_count:N
- 通过 6 个 crypto 单元测试(加解密、JSON roundtrip、Argon2id 确定性)

Made-with: Cursor
2026-03-18 20:10:13 +08:00
voson
1f7984d798 feat: AI 优先的 search 增强与结构化输出 (v0.4.0)
Some checks failed
Secrets CLI - Build & Release / 质量检查 (fmt / clippy / test) (push) Successful in 57s
Secrets CLI - Build & Release / Build (aarch64-apple-darwin) (push) Successful in 33s
Secrets CLI - Build & Release / 版本 & Release (push) Successful in 2s
Secrets CLI - Build & Release / Build (x86_64-unknown-linux-musl) (push) Successful in 44s
Secrets CLI - Build & Release / 发布草稿 Release (push) Successful in 2s
Secrets CLI - Build & Release / Build (x86_64-pc-windows-msvc) (push) Has been cancelled
- search: 新增 --name、-f/--field、-o/--output、--summary、--limit、--offset、--sort
- search: 非 TTY 自动输出 json-compact,便于 AI 解析
- search: -f secret.* 自动解锁 secrets
- add: 支持 -o json/json-compact 输出
- add: 重构为 AddArgs 结构体
- 全局: 各子命令 after_help 补充典型值示例
- output.rs: OutputMode 枚举 + TTY 检测
- 文档: README/AGENTS 面向 AI 的用法,连接串改为 <host>:<port>

Made-with: Cursor
2026-03-18 17:17:43 +08:00
voson
140162f39a ci(secrets): 飞书通知分散到各构建 job,放宽超时与构建条件
Some checks failed
Secrets CLI - Build & Release / 版本 & Release (push) Successful in 2s
Secrets CLI - Build & Release / 质量检查 (fmt / clippy / test) (push) Successful in 29s
Secrets CLI - Build & Release / Build (aarch64-apple-darwin) (push) Successful in 45s
Secrets CLI - Build & Release / Build (x86_64-unknown-linux-musl) (push) Successful in 1m18s
Secrets CLI - Build & Release / 发布草稿 Release (push) Successful in 2s
Secrets CLI - Build & Release / Build (x86_64-pc-windows-msvc) (push) Has been cancelled
- 各 build job 超时 10→15min,publish-release 2→5min
- 移除 build-macos/build-windows 的 if 条件,默认全平台构建
- 删除独立 notify job,在各 build job 内增加飞书单 job 通知
- 汇总通知并入 publish-release,用 needs 取状态不再调 API
- publish-release 增加 if: always() 与 checkout 步骤

Made-with: Cursor
2026-03-18 16:32:45 +08:00
voson
535683b15c feat: 添加结构化日志与审计
Some checks failed
Secrets CLI - Build & Release / 版本 & Release (push) Successful in 3s
Secrets CLI - Build & Release / 质量检查 (fmt / clippy / test) (push) Successful in 1m17s
Secrets CLI - Build & Release / 通知 (push) Successful in 6s
Secrets CLI - Build & Release / 发布草稿 Release (push) Has been cancelled
Secrets CLI - Build & Release / Build (aarch64-apple-darwin) (push) Has started running
Secrets CLI - Build & Release / Build (x86_64-pc-windows-msvc) (push) Has been cancelled
Secrets CLI - Build & Release / Build (x86_64-unknown-linux-musl) (push) Has been cancelled
- tracing + tracing-subscriber,全局 --verbose/-v 与 RUST_LOG 控制
- 新增 audit_log 表,add/update/delete 成功后自动写入审计记录
- 新增 src/audit.rs,审计失败仅 warn 不中断主流程
- 更新 README/AGENTS.md,补充 verbose、audit_log 说明
- .vscode/tasks.json 增加 verbose/update/audit 测试任务

Made-with: Cursor
2026-03-18 16:30:42 +08:00
voson
9620ff1923 feat(config): persist database URL to ~/.config/secrets/config.toml
- Add 'secrets config set-db/show/path' subcommands
- Remove dotenvy and DATABASE_URL env var support
- Config file created with 0600 permission
- Bump version to 0.3.0

Made-with: Cursor
2026-03-18 16:19:11 +08:00
voson
e6db23bd6d fix(ci): 移除 probe-runners,用变量控制 build,解耦 notify
Some checks failed
Secrets CLI - Build & Release / 版本 & Release (push) Successful in 2s
Secrets CLI - Build & Release / 质量检查 (fmt / clippy / test) (push) Successful in 21s
Secrets CLI - Build & Release / 通知 (push) Successful in 7s
Secrets CLI - Build & Release / Build (aarch64-apple-darwin) (push) Successful in 28s
Secrets CLI - Build & Release / Build (x86_64-unknown-linux-musl) (push) Successful in 35s
Secrets CLI - Build & Release / 发布草稿 Release (push) Successful in 0s
Secrets CLI - Build & Release / Build (x86_64-pc-windows-msvc) (push) Has been cancelled
- 删除探测 Runner job(API 解析不可靠,且 always() 导致 job 被错误调度)
- build-linux: 仅 needs version+check,默认执行
- build-macos: if vars.BUILD_MACOS != 'false'(默认开,runner 离线时设 false)
- build-windows: if vars.BUILD_WINDOWS == 'true'(默认关,无 runner)
- publish-release: 仅依赖 build-linux,避免被 macOS/Windows 阻塞
- notify: 仅 needs version+check + always(),失败也能发飞书;build 状态通过 API 查询

Made-with: Cursor
2026-03-18 16:04:16 +08:00
voson
c61c8292aa fix: CI 无 DB 下 clippy 通过 + 失败时也发飞书通知
Some checks failed
Secrets CLI - Build & Release / 探测 Runner (push) Successful in 1s
Secrets CLI - Build & Release / Build (aarch64-apple-darwin) (push) Has been skipped
Secrets CLI - Build & Release / Build (x86_64-unknown-linux-musl) (push) Has been skipped
Secrets CLI - Build & Release / 版本 & Release (push) Successful in 2s
Secrets CLI - Build & Release / 质量检查 (fmt / clippy / test) (push) Successful in 34s
Secrets CLI - Build & Release / 发布草稿 Release (push) Has been cancelled
Secrets CLI - Build & Release / 通知 (push) Has been cancelled
Secrets CLI - Build & Release / Build (x86_64-pc-windows-msvc) (push) Has been cancelled
- update.rs: sqlx::query! 改为 query/query_as,不依赖编译期 DB
- workflow: build job 加 always() 且 check.result==success,失败时 notify 能执行

Made-with: Cursor
2026-03-18 15:50:10 +08:00
voson
c1d86bc96d feat: add update command, bump to 0.2.0, doc version check
Some checks failed
Secrets CLI - Build & Release / 探测 Runner (push) Successful in 1s
Secrets CLI - Build & Release / 版本 & Release (push) Successful in 3s
Secrets CLI - Build & Release / 质量检查 (fmt / clippy / test) (push) Failing after 21s
Secrets CLI - Build & Release / Build (x86_64-unknown-linux-musl) (push) Has been skipped
Secrets CLI - Build & Release / Build (aarch64-apple-darwin) (push) Has been skipped
Secrets CLI - Build & Release / 发布草稿 Release (push) Has been cancelled
Secrets CLI - Build & Release / 通知 (push) Has been cancelled
Secrets CLI - Build & Release / Build (x86_64-pc-windows-msvc) (push) Has been cancelled
- add secrets update: incremental merge for tags/metadata/encrypted
- AGENTS.md: 提交前检查增加版本号与 git tag 说明
- README/AGENTS: update 命令文档与示例
- Cargo.toml 0.1.0 -> 0.2.0 (secrets-0.1.0 已存在)

Made-with: Cursor
2026-03-18 15:40:44 +08:00
voson
f87cf3fd20 fix: store RELEASE_TOKEN as raw value, not base64
Some checks failed
Secrets CLI - Build & Release / 版本 & Release (push) Successful in 2s
Secrets CLI - Build & Release / 质量检查 (fmt / clippy / test) (push) Successful in 23s
Secrets CLI - Build & Release / Build (x86_64-unknown-linux-musl) (push) Has been skipped
Secrets CLI - Build & Release / Build (aarch64-apple-darwin) (push) Has been skipped
Secrets CLI - Build & Release / 探测 Runner (push) Successful in 1s
Secrets CLI - Build & Release / 通知 (push) Has been cancelled
Secrets CLI - Build & Release / Build (x86_64-pc-windows-msvc) (push) Has been cancelled
Gitea Actions secrets API stores the data field as-is, base64 encoding caused CI to use the encoded string as the token, resulting in 401.

Made-with: Cursor
2026-03-18 15:27:05 +08:00
voson
1aef267bbd ci: fix runner probe curl error and re-sync RELEASE_TOKEN
Some checks failed
Secrets CLI - Build & Release / 版本 & Release (push) Successful in 2s
Secrets CLI - Build & Release / 探测 Runner (push) Successful in 1s
Secrets CLI - Build & Release / 质量检查 (fmt / clippy / test) (push) Successful in 23s
Secrets CLI - Build & Release / Build (aarch64-apple-darwin) (push) Successful in 20s
Secrets CLI - Build & Release / Build (x86_64-unknown-linux-musl) (push) Successful in 27s
Secrets CLI - Build & Release / 通知 (push) Has been cancelled
Secrets CLI - Build & Release / Build (x86_64-pc-windows-msvc) (push) Has been cancelled
- Replace curl -fsS with HTTP status code check in probe-runners to avoid ugly 401 errors
- Graceful fallback: API failure defaults to trying all platforms
- RELEASE_TOKEN re-synced with correct PAT value

Made-with: Cursor
2026-03-18 15:18:03 +08:00
voson
2ad1abe846 ci: fix release 401 handling and notify based on actual results
Some checks failed
Secrets CLI - Build & Release / 版本 & Release (push) Successful in 2s
Secrets CLI - Build & Release / 探测 Runner (push) Successful in 1s
Secrets CLI - Build & Release / 质量检查 (fmt / clippy / test) (push) Successful in 22s
Secrets CLI - Build & Release / Build (aarch64-apple-darwin) (push) Successful in 18s
Secrets CLI - Build & Release / Build (x86_64-unknown-linux-musl) (push) Successful in 27s
Secrets CLI - Build & Release / 通知 (push) Has been cancelled
Secrets CLI - Build & Release / Build (x86_64-pc-windows-msvc) (push) Has been cancelled
- Replace curl -fsS with HTTP status code checking to avoid hard failures on 401/404
- Release creation failure no longer blocks the entire workflow, just skips asset upload
- Notification now depends on all jobs and reports actual success/failure per platform

Made-with: Cursor
2026-03-18 15:04:07 +08:00
voson
010001a4f4 ci: fix version parsing and release backfill
Some checks failed
Secrets CLI - Build & Release / 通知 (push) Successful in 2s
Secrets CLI - Build & Release / Build (x86_64-pc-windows-msvc) (push) Has been cancelled
Secrets CLI - Build & Release / 版本 & Release (push) Failing after 1s
Secrets CLI - Build & Release / 探测 Runner (push) Successful in 0s
Secrets CLI - Build & Release / 质量检查 (fmt / clippy / test) (push) Successful in 21s
Secrets CLI - Build & Release / Build (x86_64-unknown-linux-musl) (push) Has been skipped
Secrets CLI - Build & Release / Build (aarch64-apple-darwin) (push) Has been skipped
Avoid failing the version step when there is no previous tag, and keep creating a release when the tag already exists but the release page is missing.

Made-with: Cursor
2026-03-18 15:00:10 +08:00
voson
0f7c151c89 chore: update .gitignore to include .DS_Store
Some checks failed
Secrets CLI - Build & Release / 版本 & Release (push) Failing after 2s
Secrets CLI - Build & Release / 探测 Runner (push) Successful in 0s
Secrets CLI - Build & Release / 通知 (push) Successful in 2s
Secrets CLI - Build & Release / 质量检查 (fmt / clippy / test) (push) Successful in 24s
Secrets CLI - Build & Release / Build (x86_64-unknown-linux-musl) (push) Has been skipped
Secrets CLI - Build & Release / Build (aarch64-apple-darwin) (push) Has been skipped
Secrets CLI - Build & Release / Build (x86_64-pc-windows-msvc) (push) Has been cancelled
2026-03-18 14:56:57 +08:00
voson
a3a92e073f ci: Release 正文仅保留变更日志,使用说明见 README
Made-with: Cursor
2026-03-18 14:55:31 +08:00
voson
3d00b65f55 Revert "ci: decouple notify from build to avoid blocking release"
This reverts commit 1acc2537b3.
2026-03-18 14:43:45 +08:00
voson
1acc2537b3 ci: decouple notify from build to avoid blocking release
Some checks failed
Secrets CLI - Build & Release / 版本 & Release (push) Successful in 1s
Secrets CLI - Build & Release / 通知 (push) Successful in 2s
Secrets CLI - Build & Release / 质量检查 (fmt / clippy / test) (push) Successful in 23s
Secrets CLI - Build & Release / Build (aarch64-apple-darwin) (push) Successful in 20s
Secrets CLI - Build & Release / Build (x86_64-unknown-linux-musl) (push) Successful in 28s
Secrets CLI - Build & Release / Build (x86_64-pc-windows-msvc) (push) Has been cancelled
Make notification fire independently of matrix builds so stalled runners do not block release publishing.

Made-with: Cursor
2026-03-18 14:41:59 +08:00
voson
9a562be4e4 ci: reduce check job timeout to 1 minute for efficiency
Some checks failed
Secrets CLI - Build & Release / 版本 & Release (push) Successful in 1s
Secrets CLI - Build & Release / 质量检查 (fmt / clippy / test) (push) Successful in 24s
Secrets CLI - Build & Release / Build (aarch64-apple-darwin) (push) Successful in 15s
Secrets CLI - Build & Release / Build (x86_64-unknown-linux-musl) (push) Successful in 26s
Secrets CLI - Build & Release / 通知 (push) Has been cancelled
Secrets CLI - Build & Release / Build (x86_64-pc-windows-msvc) (push) Has been cancelled
2026-03-18 14:36:14 +08:00
voson
3203984fb4 ci: 优化 workflow,拆分 check job,预创建 Release,超时 10m
Some checks failed
Secrets CLI - Build & Release / 版本 & Release (push) Successful in 1s
Secrets CLI - Build & Release / 质量检查 (fmt / clippy / test) (push) Successful in 2m20s
Secrets CLI - Build & Release / Build (aarch64-apple-darwin) (push) Successful in 53s
Secrets CLI - Build & Release / Build (x86_64-unknown-linux-musl) (push) Successful in 2m48s
Secrets CLI - Build & Release / 通知 (push) Has been cancelled
Secrets CLI - Build & Release / Build (x86_64-pc-windows-msvc) (push) Has been cancelled
- 新增 check job:fmt/clippy/test 仅在 Linux 跑一次
- version job 预创建 Release,消除多 job 竞态
- build job 只编译+上传,加 --locked
- 超时从 30m 改为 10m
- AGENTS.md 补充提交前检查规范

Made-with: Cursor
2026-03-18 14:30:54 +08:00
voson
52ee858fd7 fix: use tls-rustls for musl builds; fix clippy collapsible-if
Some checks failed
Secrets CLI - Build & Release / 检查版本 (push) Successful in 1s
Secrets CLI - Build & Release / Build (aarch64-apple-darwin) (push) Successful in 1m2s
Secrets CLI - Build & Release / Build (x86_64-unknown-linux-musl) (push) Successful in 3m14s
Secrets CLI - Build & Release / 发送通知 (push) Has been cancelled
Secrets CLI - Build & Release / Build (x86_64-pc-windows-msvc) (push) Has been cancelled
- Switch sqlx from tls-native-tls to tls-rustls to avoid OpenSSL
  pkg-config cross-compilation issues on x86_64-unknown-linux-musl
- Collapse nested if-let in search.rs to satisfy clippy::collapsible-if

Made-with: Cursor
2026-03-18 14:18:25 +08:00
voson
3b338be2d2 style: cargo fmt — fix rustfmt formatting
Some checks failed
Secrets CLI - Build & Release / 检查版本 (push) Successful in 1s
Secrets CLI - Build & Release / Build (x86_64-unknown-linux-musl) (push) Failing after 31s
Secrets CLI - Build & Release / Build (aarch64-apple-darwin) (push) Failing after 37s
Secrets CLI - Build & Release / Build (x86_64-pc-windows-msvc) (push) Has been cancelled
Secrets CLI - Build & Release / 发送通知 (push) Has been cancelled
Made-with: Cursor
2026-03-18 14:13:37 +08:00
60 changed files with 11955 additions and 1191 deletions

View File

@@ -1,13 +1,14 @@
name: Secrets CLI - Build & Release # MCP 分支:仅构建/发布 secrets-mcpCLI 在 main 分支维护)
name: Secrets MCP — Build & Release
on: on:
push: push:
branches: [main]
paths: paths:
- 'src/**' - 'crates/**'
- 'Cargo.toml' - 'Cargo.toml'
- 'Cargo.lock' - 'Cargo.lock'
- '.gitea/workflows/secrets.yml' - 'deploy/**'
- '.gitea/workflows/**'
concurrency: concurrency:
group: ${{ github.workflow }}-${{ github.ref }} group: ${{ github.workflow }}-${{ github.ref }}
@@ -17,127 +18,61 @@ permissions:
contents: write contents: write
env: env:
BINARY_NAME: secrets MCP_BINARY: secrets-mcp
RUST_TOOLCHAIN: 1.94.0
CARGO_INCREMENTAL: 0 CARGO_INCREMENTAL: 0
CARGO_NET_RETRY: 10 CARGO_NET_RETRY: 10
CARGO_TERM_COLOR: always CARGO_TERM_COLOR: always
RUST_BACKTRACE: short RUST_BACKTRACE: short
MUSL_TARGET: x86_64-unknown-linux-musl
jobs: jobs:
# ========== 版本检查(只跑一次,供后续 job 共用)========== ci:
version: name: 检查 / 构建 / 发版
name: 检查版本
runs-on: debian runs-on: debian
timeout-minutes: 40
outputs: outputs:
version: ${{ steps.version.outputs.version }} tag: ${{ steps.ver.outputs.tag }}
tag: ${{ steps.version.outputs.tag }} version: ${{ steps.ver.outputs.version }}
tag_exists: ${{ steps.version.outputs.tag_exists }}
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with: with:
fetch-depth: 0 fetch-depth: 0
- name: 检查版本 # ── 版本解析 ────────────────────────────────────────────────────────
id: version - name: 解析版本
id: ver
run: | run: |
version=$(grep -m1 '^version' Cargo.toml | sed 's/.*"\(.*\)".*/\1/') version=$(grep -m1 '^version' crates/secrets-mcp/Cargo.toml | sed 's/.*"\(.*\)".*/\1/')
tag="secrets-${version}" tag="secrets-mcp-${version}"
echo "version=${version}" >> $GITHUB_OUTPUT echo "version=${version}" >> "$GITHUB_OUTPUT"
echo "tag=${tag}" >> $GITHUB_OUTPUT echo "tag=${tag}" >> "$GITHUB_OUTPUT"
if git rev-parse "refs/tags/${tag}" >/dev/null 2>&1; then if git rev-parse "refs/tags/${tag}" >/dev/null 2>&1; then
echo "tag_exists=true" >> $GITHUB_OUTPUT echo "⚠ 版本 ${tag} 已存在,将覆盖重新发版。"
echo "版本 ${tag} 已存在" echo "tag_exists=true" >> "$GITHUB_OUTPUT"
else else
echo "tag_exists=false" >> $GITHUB_OUTPUT
echo "将创建新版本 ${tag}" echo "将创建新版本 ${tag}"
echo "tag_exists=false" >> "$GITHUB_OUTPUT"
fi fi
- name: 创建 Tag # ── Rust 工具链 ──────────────────────────────────────────────────────
if: steps.version.outputs.tag_exists == 'false' - name: 安装 Rust 与 musl 工具链
run: | run: |
git config user.name "github-actions[bot]" sudo apt-get update -qq
git config user.email "github-actions[bot]@users.noreply.github.com" sudo apt-get install -y -qq pkg-config musl-tools binutils jq
git tag -a "${{ steps.version.outputs.tag }}" -m "Release ${{ steps.version.outputs.tag }}" if ! command -v rustup >/dev/null 2>&1; then
git push origin "${{ steps.version.outputs.tag }}" curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain "${RUST_TOOLCHAIN}"
echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
# ========== 矩阵构建 ==========
build:
name: Build (${{ matrix.target }})
needs: version
continue-on-error: true # 某平台失败/超时不阻断其他平台和 notify
timeout-minutes: 30 # runner 不在线时 30 分钟后放弃
strategy:
fail-fast: false
matrix:
include:
- runner: debian
target: x86_64-unknown-linux-musl
archive_suffix: x86_64-linux-musl
os: linux
- runner: darwin-arm64
target: aarch64-apple-darwin
archive_suffix: aarch64-macos
os: macos
- runner: windows
target: x86_64-pc-windows-msvc
archive_suffix: x86_64-windows
os: windows
runs-on: ${{ matrix.runner }}
steps:
# ========== 环境准备 ==========
- name: 安装依赖 (Linux)
if: matrix.os == 'linux'
run: |
sudo apt-get update
sudo apt-get install -y jq curl git pkg-config musl-tools binutils
if ! command -v cargo &>/dev/null; then
echo "安装 Rust 工具链..."
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable
source "$HOME/.cargo/env"
rustup component add rustfmt clippy
fi fi
source "$HOME/.cargo/env" 2>/dev/null || true
rustup toolchain install "${RUST_TOOLCHAIN}" --profile minimal \
--component rustfmt --component clippy
rustup default "${RUST_TOOLCHAIN}"
rustup target add "${MUSL_TARGET}" --toolchain "${RUST_TOOLCHAIN}"
rustc -V && cargo -V
rustup target add ${{ matrix.target }} - name: 缓存 Cargo
echo "$HOME/.cargo/bin" >> $GITHUB_PATH
- name: 安装依赖 (macOS)
if: matrix.os == 'macos'
run: |
brew install jq
if ! command -v cargo &>/dev/null; then
echo "安装 Rust 工具链..."
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable
source "$HOME/.cargo/env"
rustup component add rustfmt clippy
fi
rustup target add ${{ matrix.target }}
echo "$HOME/.cargo/bin" >> $GITHUB_PATH
- name: 安装依赖 (Windows)
if: matrix.os == 'windows'
shell: pwsh
run: |
# 检查 Rust 是否已安装
if (-not (Get-Command cargo -ErrorAction SilentlyContinue)) {
Write-Host "安装 Rust 工具链..."
Invoke-WebRequest -Uri "https://win.rustup.rs/x86_64" -OutFile rustup-init.exe
.\rustup-init.exe -y --default-toolchain stable
Remove-Item rustup-init.exe
}
rustup component add rustfmt clippy
rustup target add ${{ matrix.target }}
- uses: actions/checkout@v4
with:
fetch-depth: 0
# ========== Cargo 缓存 ==========
- name: 缓存 Cargo 依赖
uses: actions/cache@v4 uses: actions/cache@v4
with: with:
path: | path: |
@@ -145,169 +80,169 @@ jobs:
~/.cargo/registry/cache ~/.cargo/registry/cache
~/.cargo/git/db ~/.cargo/git/db
target target
key: cargo-secrets-${{ matrix.target }}-${{ hashFiles('Cargo.lock') }} key: cargo-${{ env.MUSL_TARGET }}-${{ env.RUST_TOOLCHAIN }}-${{ hashFiles('Cargo.lock') }}
restore-keys: | restore-keys: |
cargo-secrets-${{ matrix.target }}- cargo-${{ env.MUSL_TARGET }}-${{ env.RUST_TOOLCHAIN }}-
cargo-${{ env.MUSL_TARGET }}-
- name: 检查代码格式 # ── 质量检查(先于构建,失败即止)──────────────────────────────────
if: matrix.os != 'windows' - name: fmt
run: cargo fmt -- --check run: cargo fmt -- --check
- name: 检查代码格式 (Windows) - name: clippy
if: matrix.os == 'windows' run: cargo clippy --locked -- -D warnings
shell: pwsh
run: cargo fmt -- --check
- name: 运行 Clippy 检查 - name: test
if: matrix.os != 'windows' run: cargo test --locked
run: cargo clippy --release --target ${{ matrix.target }} -- -D warnings
- name: 运行 Clippy 检查 (Windows) # ── 构建(质量检查通过后才执行)────────────────────────────────────
if: matrix.os == 'windows' - name: 构建 secrets-mcp (musl)
shell: pwsh run: |
run: cargo clippy --release --target ${{ matrix.target }} -- -D warnings cargo build --release --locked --target "${MUSL_TARGET}" -p secrets-mcp
strip "target/${MUSL_TARGET}/release/${MCP_BINARY}"
- name: 构建 - name: 上传构建产物
if: matrix.os != 'windows' uses: actions/upload-artifact@v3
env: with:
GIT_TAG: ${{ needs.version.outputs.version }} name: ${{ env.MCP_BINARY }}-linux-musl
run: cargo build --release --target ${{ matrix.target }} --verbose path: target/${{ env.MUSL_TARGET }}/release/${{ env.MCP_BINARY }}
retention-days: 3
- name: 构建 (Windows) # ── 创建 / 覆盖 Tag构建成功后才打───────────────────────────────
if: matrix.os == 'windows' - name: 创建 Tag
shell: pwsh run: |
env: git config user.name "github-actions[bot]"
GIT_TAG: ${{ needs.version.outputs.version }} git config user.email "github-actions[bot]@users.noreply.github.com"
run: cargo build --release --target ${{ matrix.target }} --verbose tag="${{ steps.ver.outputs.tag }}"
if [ "${{ steps.ver.outputs.tag_exists }}" = "true" ]; then
git tag -d "$tag" 2>/dev/null || true
git push origin ":refs/tags/$tag" 2>/dev/null || true
fi
git tag -a "$tag" -m "Release $tag"
git push origin "$tag"
- name: Strip 二进制 (Linux) # ── Release可选需配置 RELEASE_TOKEN───────────────────────────
if: matrix.os == 'linux' - name: Upsert Release
run: strip target/${{ matrix.target }}/release/${{ env.BINARY_NAME }} if: env.RELEASE_TOKEN != ''
- name: Strip 二进制 (macOS)
if: matrix.os == 'macos'
run: strip -x target/${{ matrix.target }}/release/${{ env.BINARY_NAME }}
# ========== 上传 Release 产物 ==========
- name: 上传 Release 产物 (Linux/macOS)
if: needs.version.outputs.tag_exists == 'false' && matrix.os != 'windows'
env: env:
RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }} RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }}
run: | run: |
[ -z "$RELEASE_TOKEN" ] && echo "跳过:未配置 RELEASE_TOKEN" && exit 0 tag="${{ steps.ver.outputs.tag }}"
version="${{ steps.ver.outputs.version }}"
api="${{ github.server_url }}/api/v1/repos/${{ github.repository }}/releases"
auth="Authorization: token $RELEASE_TOKEN"
tag="${{ needs.version.outputs.tag }}" previous_tag=$(git tag --list 'secrets-mcp-*' --sort=-v:refname | awk -v t="$tag" '$0 != t { print; exit }')
binary="target/${{ matrix.target }}/release/${{ env.BINARY_NAME }}" if [ -n "$previous_tag" ]; then
archive="${{ env.BINARY_NAME }}-${tag}-${{ matrix.archive_suffix }}.tar.gz" changes=$(git log --pretty=format:'- %s (%h)' "${previous_tag}..HEAD")
else
changes=$(git log --pretty=format:'- %s (%h)')
fi
[ -z "$changes" ] && changes="- 首次发布"
body=$(printf '## 变更日志\n\n%s' "$changes")
tar -czf "$archive" -C "$(dirname $binary)" "$(basename $binary)" # Upsert: 存在 → PATCH + 清旧 assets不存在 → POST
release_id=$(curl -sS -H "$auth" "${api}/tags/${tag}" 2>/dev/null | jq -r '.id // empty')
# 查找已有 Release由首个完成的 job 创建,后续 job 直接上传) if [ -n "$release_id" ]; then
release_url="${{ github.server_url }}/api/v1/repos/${{ github.repository }}/releases" curl -sS -o /dev/null -H "$auth" -H "Content-Type: application/json" \
release_id=$(curl -sS -H "Authorization: token $RELEASE_TOKEN" \ -X PATCH "${api}/${release_id}" \
"${release_url}/tags/${tag}" | jq -r '.id // empty') -d "$(jq -n --arg n "secrets-mcp ${version}" --arg b "$body" '{name:$n,body:$b,draft:false}')"
curl -sS -H "$auth" "${api}/${release_id}/assets" | \
if [ -z "$release_id" ]; then jq -r '.[].id' | xargs -I{} curl -sS -o /dev/null -H "$auth" -X DELETE "${api}/${release_id}/assets/{}"
release_id=$(curl -sS -H "Authorization: token $RELEASE_TOKEN" \ echo "已更新 Release ${release_id}"
-H "Content-Type: application/json" \ else
-X POST "$release_url" \ release_id=$(curl -fsS -H "$auth" -H "Content-Type: application/json" \
-d "{\"tag_name\": \"${tag}\", \"name\": \"${tag}\", \"body\": \"Release ${tag}\"}" \ -X POST "$api" \
| jq -r '.id') -d "$(jq -n --arg t "$tag" --arg n "secrets-mcp ${version}" --arg b "$body" \
'{tag_name:$t,name:$n,body:$b,draft:false}')" | jq -r '.id')
echo "已创建 Release ${release_id}"
fi fi
upload_url="${{ github.server_url }}/api/v1/repos/${{ github.repository }}/releases/${release_id}/assets" bin="target/${MUSL_TARGET}/release/${MCP_BINARY}"
curl -sS -H "Authorization: token $RELEASE_TOKEN" \ archive="${MCP_BINARY}-${tag}-x86_64-linux-musl.tar.gz"
-F "attachment=@${archive}" \ tar -czf "$archive" -C "$(dirname "$bin")" "$(basename "$bin")"
"$upload_url" sha256sum "$archive" > "${archive}.sha256"
curl -fsS -H "$auth" -F "attachment=@${archive}" "${api}/${release_id}/assets"
curl -fsS -H "$auth" -F "attachment=@${archive}.sha256" "${api}/${release_id}/assets"
echo "Release ${tag} 已发布"
echo "已上传: ${archive} → Release ${tag}" # ── 飞书汇总通知 ─────────────────────────────────────────────────────
- name: 飞书通知
- name: 上传 Release 产物 (Windows) if: always()
if: needs.version.outputs.tag_exists == 'false' && matrix.os == 'windows'
shell: pwsh
env:
RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }}
run: |
if (-not $env:RELEASE_TOKEN) { Write-Host "跳过:未配置 RELEASE_TOKEN"; exit 0 }
$tag = "${{ needs.version.outputs.tag }}"
$binary = "target\${{ matrix.target }}\release\${{ env.BINARY_NAME }}.exe"
$archive = "${{ env.BINARY_NAME }}-${tag}-${{ matrix.archive_suffix }}.zip"
Compress-Archive -Path $binary -DestinationPath $archive
$headers = @{ "Authorization" = "token $env:RELEASE_TOKEN"; "Content-Type" = "application/json" }
$release_url = "${{ github.server_url }}/api/v1/repos/${{ github.repository }}/releases"
# 查找已有 Release
$existing = Invoke-RestMethod -Uri "${release_url}/tags/${tag}" -Headers $headers -ErrorAction SilentlyContinue
if ($existing.id) {
$release_id = $existing.id
} else {
$body = @{ tag_name = $tag; name = $tag; body = "Release ${tag}" } | ConvertTo-Json
$release_id = (Invoke-RestMethod -Uri $release_url -Method Post -Headers $headers -Body $body).id
}
$upload_url = "${release_url}/${release_id}/assets"
$upload_headers = @{ "Authorization" = "token $env:RELEASE_TOKEN" }
$form = @{ attachment = Get-Item $archive }
Invoke-RestMethod -Uri $upload_url -Method Post -Headers $upload_headers -Form $form
Write-Host "已上传: ${archive} → Release ${tag}"
# ========== 汇总通知 ==========
notify:
name: 发送通知
needs: [version, build]
if: always() && github.event_name == 'push'
runs-on: debian
steps:
- uses: actions/checkout@v4
- name: 发送通知
continue-on-error: true
env: env:
WEBHOOK_URL: ${{ vars.WEBHOOK_URL }} WEBHOOK_URL: ${{ vars.WEBHOOK_URL }}
run: | run: |
[ -z "$WEBHOOK_URL" ] && exit 0 [ -z "$WEBHOOK_URL" ] && exit 0
tag="${{ steps.ver.outputs.tag }}"
tag="${{ needs.version.outputs.tag }}" commit="${{ github.event.head_commit.message }}"
tag_exists="${{ needs.version.outputs.tag_exists }}" [ -z "$commit" ] && commit="${{ github.sha }}"
build_result="${{ needs.build.result }}" url="${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_number }}"
result="${{ job.status }}"
if [ "$build_result" = "success" ]; then if [ "$result" = "success" ]; then icon="✅"; else icon="❌"; fi
status_text="构建成功 ✅" msg="secrets-mcp 构建&发版 ${icon}
else 版本:${tag}
status_text="构建失败 ❌" 提交:${commit}
fi 作者:${{ github.actor }}
详情:${url}"
commit_title=$(git log -1 --pretty=format:"%s" 2>/dev/null || echo "N/A") payload=$(jq -n --arg text "$msg" '{msg_type: "text", content: {text: $text}}')
workflow_url="${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_number }}" curl -sS -H "Content-Type: application/json" -X POST -d "$payload" "$WEBHOOK_URL"
if [ "$build_result" != "success" ]; then deploy:
payload=$(jq -n \ name: 部署 secrets-mcp
--arg title "${{ env.BINARY_NAME }} ${status_text}" \ needs: [ci]
--arg commit "$commit_title" \ if: |
--arg version "$tag" \ github.ref == 'refs/heads/main' ||
--arg author "${{ github.actor }}" \ github.ref == 'refs/heads/feat/mcp' ||
--arg url "$workflow_url" \ github.ref == 'refs/heads/mcp'
'{msg_type: "text", content: {text: "\($title)\n提交\($commit)\n版本\($version)\n作者\($author)\n详情\($url)"}}') runs-on: debian
elif [ "$tag_exists" = "false" ]; then timeout-minutes: 10
payload=$(jq -n \ steps:
--arg title "${{ env.BINARY_NAME }} ${status_text}" \ - name: 下载构建产物
--arg commit "$commit_title" \ uses: actions/download-artifact@v3
--arg version "$tag" \ with:
--arg author "${{ github.actor }}" \ name: ${{ env.MCP_BINARY }}-linux-musl
--arg url "$workflow_url" \ path: /tmp/artifact
'{msg_type: "text", content: {text: "\($title)\n🆕 新版本已发布 (linux / macOS / windows)\n提交\($commit)\n版本\($version)\n作者\($author)\n详情\($url)"}}')
else - name: 部署到阿里云 ECS
payload=$(jq -n \ env:
--arg title "${{ env.BINARY_NAME }} ${status_text}" \ DEPLOY_HOST: ${{ vars.DEPLOY_HOST }}
--arg commit "$commit_title" \ DEPLOY_USER: ${{ vars.DEPLOY_USER }}
--arg version "$tag" \ DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
--arg author "${{ github.actor }}" \ run: |
--arg url "$workflow_url" \ if [ -z "$DEPLOY_HOST" ] || [ -z "$DEPLOY_USER" ] || [ -z "$DEPLOY_SSH_KEY" ]; then
'{msg_type: "text", content: {text: "\($title)\n🔄 重复构建\n提交\($commit)\n版本\($version)\n作者\($author)\n详情\($url)"}}') echo "部署跳过:请配置 vars.DEPLOY_HOST、vars.DEPLOY_USER 与 secrets.DEPLOY_SSH_KEY"
fi exit 0
fi
echo "$DEPLOY_SSH_KEY" > /tmp/deploy_key
chmod 600 /tmp/deploy_key
scp -i /tmp/deploy_key -o StrictHostKeyChecking=no \
"/tmp/artifact/${MCP_BINARY}" \
"${DEPLOY_USER}@${DEPLOY_HOST}:/tmp/secrets-mcp.new"
ssh -i /tmp/deploy_key -o StrictHostKeyChecking=no "${DEPLOY_USER}@${DEPLOY_HOST}" "
sudo mv /tmp/secrets-mcp.new /opt/secrets-mcp/secrets-mcp
sudo chmod +x /opt/secrets-mcp/secrets-mcp
sudo systemctl restart secrets-mcp
sleep 2
sudo systemctl is-active secrets-mcp && echo '服务启动成功' || (sudo journalctl -u secrets-mcp -n 20 && exit 1)
"
rm -f /tmp/deploy_key
- name: 飞书通知
if: always()
env:
WEBHOOK_URL: ${{ vars.WEBHOOK_URL }}
run: |
[ -z "$WEBHOOK_URL" ] && exit 0
tag="${{ needs.ci.outputs.tag }}"
url="${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_number }}"
result="${{ job.status }}"
if [ "$result" = "success" ]; then icon="✅"; else icon="❌"; fi
msg="secrets-mcp 部署 ${icon}
版本:${tag}
作者:${{ github.actor }}
详情:${url}"
payload=$(jq -n --arg text "$msg" '{msg_type: "text", content: {text: $text}}')
curl -sS -H "Content-Type: application/json" -X POST -d "$payload" "$WEBHOOK_URL" curl -sS -H "Content-Type: application/json" -X POST -d "$payload" "$WEBHOOK_URL"

5
.gitignore vendored
View File

@@ -1,2 +1,7 @@
/target /target
.env .env
.DS_Store
.cursor/
# Google OAuth 下载的 JSON 凭据文件
client_secret_*.apps.googleusercontent.com.json
*.pem

124
.vscode/tasks.json vendored
View File

@@ -2,106 +2,46 @@
"version": "2.0.0", "version": "2.0.0",
"tasks": [ "tasks": [
{ {
"label": "build", "label": "mcp: build",
"type": "shell", "type": "shell",
"command": "cargo build", "command": "cargo build --locked -p secrets-mcp",
"group": { "kind": "build", "isDefault": true } "group": "build",
"options": {
"envFile": "${workspaceFolder}/.env"
}
}, },
{ {
"label": "cli: version", "label": "mcp: run",
"type": "shell", "type": "shell",
"command": "./target/debug/secrets -V", "command": "cargo run --locked -p secrets-mcp",
"options": {
"envFile": "${workspaceFolder}/.env"
}
},
{
"label": "test: workspace",
"type": "shell",
"command": "cargo test --workspace --locked",
"dependsOn": "build",
"group": { "kind": "test", "isDefault": true }
},
{
"label": "fmt: check",
"type": "shell",
"command": "cargo fmt -- --check",
"problemMatcher": []
},
{
"label": "clippy: workspace",
"type": "shell",
"command": "cargo clippy --workspace --locked -- -D warnings",
"dependsOn": "build" "dependsOn": "build"
}, },
{ {
"label": "cli: help", "label": "ci: release-check",
"type": "shell", "type": "shell",
"command": "./target/debug/secrets --help", "command": "./scripts/release-check.sh",
"dependsOn": "build" "problemMatcher": []
},
{
"label": "cli: help add",
"type": "shell",
"command": "./target/debug/secrets help add",
"dependsOn": "build"
},
{
"label": "test: search all",
"type": "shell",
"command": "./target/debug/secrets search",
"dependsOn": "build"
},
{
"label": "test: search by namespace (refining)",
"type": "shell",
"command": "./target/debug/secrets search -n refining",
"dependsOn": "build"
},
{
"label": "test: search by namespace (ricnsmart)",
"type": "shell",
"command": "./target/debug/secrets search -n ricnsmart",
"dependsOn": "build"
},
{
"label": "test: search servers",
"type": "shell",
"command": "./target/debug/secrets search --kind server",
"dependsOn": "build"
},
{
"label": "test: search services",
"type": "shell",
"command": "./target/debug/secrets search --kind service",
"dependsOn": "build"
},
{
"label": "test: search keys",
"type": "shell",
"command": "./target/debug/secrets search --kind key",
"dependsOn": "build"
},
{
"label": "test: search by tag (aliyun)",
"type": "shell",
"command": "./target/debug/secrets search --tag aliyun",
"dependsOn": "build"
},
{
"label": "test: search by tag (hongkong)",
"type": "shell",
"command": "./target/debug/secrets search --tag hongkong",
"dependsOn": "build"
},
{
"label": "test: search keyword (gitea)",
"type": "shell",
"command": "./target/debug/secrets search -q gitea",
"dependsOn": "build"
},
{
"label": "test: search with secrets revealed",
"type": "shell",
"command": "./target/debug/secrets search -n refining --kind service --name gitea --show-secrets",
"dependsOn": "build"
},
{
"label": "test: combined search (ricnsmart + server + shanghai)",
"type": "shell",
"command": "./target/debug/secrets search -n ricnsmart --kind server --tag shanghai",
"dependsOn": "build"
},
{
"label": "test: add + delete roundtrip",
"type": "shell",
"command": "echo '--- add ---' && ./target/debug/secrets add -n test --kind demo --name roundtrip-test --tag test -m foo=bar -s password=secret123 && echo '--- search ---' && ./target/debug/secrets search -n test --show-secrets && echo '--- delete ---' && ./target/debug/secrets delete -n test --kind demo --name roundtrip-test && echo '--- verify deleted ---' && ./target/debug/secrets search -n test",
"dependsOn": "build"
},
{
"label": "test: add with file secret",
"type": "shell",
"command": "echo '--- add key from file ---' && ./target/debug/secrets add -n test --kind key --name test-key --tag test -s content=@./refining/keys/Vultr && echo '--- verify ---' && ./target/debug/secrets search -n test --kind key --show-secrets && echo '--- cleanup ---' && ./target/debug/secrets delete -n test --kind key --name test-key",
"dependsOn": "build"
} }
] ]
} }

230
AGENTS.md
View File

@@ -1,130 +1,174 @@
# Secrets CLI — AGENTS.md # Secrets MCP — AGENTS.md
跨设备密钥与配置管理 CLI 工具,将 refining / ricnsmart 两个项目的服务器信息、服务凭据存储到 PostgreSQL 18供 AI 工具读取上下文 本仓库为 **MCP SaaS**`secrets-core`(业务与持久化)+ `secrets-mcp`Streamable HTTP MCP、Web、OAuth、API Key。对外入口见 `crates/secrets-mcp`
## 提交 / 推送硬规则(优先于下文)
**每次提交和推送前必须执行以下检查,无论是否明确「发版」:**
1. 涉及 `crates/**`、根目录 `Cargo.toml`/`Cargo.lock``secrets-mcp` 行为变更的提交,默认视为**需要发版**,除非明确说明「本次不发版」。
2. 提交前检查 `crates/secrets-mcp/Cargo.toml``version`,再查 tag`git tag -l 'secrets-mcp-*'`。若当前版本对应 tag 已存在且有代码变更,**必须 bump 版本号**并 `cargo build` 同步 `Cargo.lock`
3. 提交前运行 `./scripts/release-check.sh`(版本/tag + `fmt` + `clippy --locked` + `test --locked`)。若脚本不存在或不可用,至少运行 `cargo fmt -- --check && cargo clippy --locked -- -D warnings && cargo test --locked`
## 项目结构 ## 项目结构
``` ```
secrets/ secrets/
src/ Cargo.toml
main.rs # CLI 入口clap 命令定义auto-migrate crates/
db.rs # PgPool 创建 + 建表/索引(幂等) secrets-core/ # db / crypto / models / audit / service
models.rs # Secret 结构体sqlx::FromRow + serde secrets-mcp/ # rmcp tools、axum、OAuth、Dashboard
commands/
add.rs # add 命令upsert支持 --meta key=value / --secret key=@file
search.rs # search 命令:多条件动态查询
delete.rs # delete 命令
scripts/ scripts/
seed-data.sh # 从 refining/ricnsmart config.toml 导入全量数据 release-check.sh
.gitea/workflows/ setup-gitea-actions.sh
secrets.yml # CIfmt + clippy + musl 构建 + Release 上传 + 飞书通知 .gitea/workflows/secrets.yml
.vscode/tasks.json # 本地测试任务build / search / add+delete roundtrip 等) .vscode/tasks.json
.env # DATABASE_URLgitignore不提交
``` ```
## 数据库 ## 数据库
- **Host**: `47.117.131.22:5432`(阿里云上海 ECSPostgreSQL 18 with io_uring - **建议库名**`secrets-mcp`(专用实例,与历史库名区分)。
- **Database**: `secrets` - **连接**:环境变量 **`SECRETS_DATABASE_URL`**(本分支无本地配置文件路径)。
- **连接串**: `postgres://postgres:<password>@47.117.131.22:5432/secrets` - **表**`entries`(含 `user_id`)、`secrets``entries_history``secrets_history``audit_log``users``oauth_accounts`,首次连接 **auto-migrate**`secrets-core``migrate`)。
- **表**: 单张 `secrets`首次连接自动建表auto-migrate - **Web 会话**:与上项 **同一数据库 URL**`secrets-mcp` 启动时对 tower-sessions 的 PostgreSQL 存储 **auto-migrate**(会话表与业务表共存于该实例,无需第二套连接串)。
### 表结构 ### 表结构(摘录)
```sql
entries (
id UUID PRIMARY KEY DEFAULT uuidv7(),
user_id UUID, -- 多租户NULL=遗留行;非空=归属用户
folder VARCHAR(128) NOT NULL DEFAULT '',
type VARCHAR(64) NOT NULL DEFAULT '',
name VARCHAR(256) NOT NULL,
notes TEXT NOT NULL DEFAULT '',
tags TEXT[] NOT NULL DEFAULT '{}',
metadata JSONB NOT NULL DEFAULT '{}',
version BIGINT NOT NULL DEFAULT 1,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
-- 唯一UNIQUE(user_id, folder, name) WHERE user_id IS NOT NULL
-- UNIQUE(folder, name) WHERE user_id IS NULL单租户遗留
```
```sql ```sql
secrets ( secrets (
id UUID PRIMARY KEY DEFAULT uuidv7(), -- PG18 时间有序 UUID id UUID PRIMARY KEY DEFAULT uuidv7(),
namespace VARCHAR(64) NOT NULL, -- 一级隔离: "refining" | "ricnsmart" entry_id UUID NOT NULL REFERENCES entries(id) ON DELETE CASCADE,
kind VARCHAR(64) NOT NULL, -- 类型: "server" | "service"(可扩展) field_name VARCHAR(256) NOT NULL,
name VARCHAR(256) NOT NULL, -- 人类可读标识 encrypted BYTEA NOT NULL DEFAULT '\x',
tags TEXT[] NOT NULL DEFAULT '{}', -- 灵活标签: ["aliyun","hongkong"] version BIGINT NOT NULL DEFAULT 1,
metadata JSONB NOT NULL DEFAULT '{}', -- 明文描述: ip, desc, domains, location... created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
encrypted JSONB NOT NULL DEFAULT '{}', -- 敏感数据: ssh_key, password, token... updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), UNIQUE(entry_id, field_name)
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(namespace, kind, name)
) )
``` ```
### 字段职责划分 ### users / oauth_accounts
| 字段 | 存什么 | 示例 | ```sql
|------|--------|------| users (
| `namespace` | 项目/团队隔离 | `refining`, `ricnsmart` | id UUID PRIMARY KEY DEFAULT uuidv7(),
| `kind` | 记录类型 | `server`, `service` | email VARCHAR(256),
| `name` | 唯一标识名 | `i-uf63f2uookgs5uxmrdyc`, `gitea` | name VARCHAR(256) NOT NULL DEFAULT '',
| `tags` | 多维分类标签 | `["aliyun","hongkong","ricn"]` | avatar_url TEXT,
| `metadata` | 明文非敏感信息 | `{"ip":"47.243.154.187","desc":"Grafana","domains":["..."]}` | key_salt BYTEA, -- PBKDF2 salt32B首次设置密码短语时写入
| `encrypted` | 敏感凭据MVP 阶段明文存储,后续对 value 加密) | `{"ssh_key":"-----BEGIN...","password":"..."}` | key_check BYTEA, -- 派生密钥加密已知常量,用于验证密码短语
key_params JSONB, -- 算法参数,如 {"alg":"pbkdf2-sha256","iterations":600000}
api_key TEXT UNIQUE, -- MCP Bearer token当前实现为明文存储
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
## CLI 命令 oauth_accounts (
id UUID PRIMARY KEY DEFAULT uuidv7(),
```bash user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
# 查看版本 provider VARCHAR(32) NOT NULL,
secrets -V / --version provider_id VARCHAR(256) NOT NULL,
email VARCHAR(256),
# 查看帮助 name VARCHAR(256),
secrets -h / --help avatar_url TEXT,
secrets help <subcommand> # 子命令详细帮助,如 secrets help add created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(provider, provider_id)
# 添加或更新记录upsert )
secrets add -n <namespace> --kind <kind> --name <name> \ -- 另有唯一索引 UNIQUE(user_id, provider)(迁移中 idx_oauth_accounts_user_provider同一用户每种 provider 至多一条关联。
[--tag <tag>]... # 可重复
[-m key=value]... # --meta 明文字段,-m 是短标志
[-s key=value]... # --secret 敏感字段value 以 @ 开头表示从文件读取
# 搜索(默认隐藏 encrypted 内容)
secrets search [-n <namespace>] [--kind <kind>] [--tag <tag>] [-q <keyword>] [--show-secrets]
# -q 匹配范围name、namespace、kind、metadata 全文内容、tags
# 删除
secrets delete -n <namespace> --kind <kind> --name <name>
``` ```
### 示例 ### audit_log / history
```bash 与迁移脚本一致:`audit_log``entries_history``secrets_history` 用于审计与时间旅行恢复;字段定义见 `crates/secrets-core/src/db.rs``migrate` SQL。`audit_log` 含可选 **`user_id`**(多租户下标识操作者;可空以兼容遗留数据)。`audit_log` 中普通业务事件使用 **`folder` / `type` / `name`** 对应 entry 坐标;登录类事件固定使用 **`folder='auth'`**,此时 `type`/`name` 表示认证目标而非 entry 身份。
# 添加服务器
secrets add -n refining --kind server --name i-uf63f2uookgs5uxmrdyc \
--tag aliyun --tag shanghai \
-m ip=47.117.131.22 -m desc="Aliyun Shanghai ECS" \
-s username=root -s ssh_key=@./keys/voson_shanghai_e.pem
# 添加服务凭据 ### MCP 消歧AI 调用)
secrets add -n refining --kind service --name gitea \
--tag gitea \
-m url=https://gitea.refining.dev \
-s token=<token>
# 搜索含 mqtt 的所有记录 `name` 定位条目的工具(`get` / `update` / 单条 `delete` / `history` / `rollback`):若该用户下仅一条匹配则直接执行;若多条(同 `name`、不同 `folder`)则返回错误并提示补全 `folder``secrets_delete``dry_run=true` 与真实删除使用相同消歧规则。
secrets search -q mqtt
# 查看 refining 的全部服务配置(显示 secrets ### 字段职责
secrets search -n refining --kind service --show-secrets
# 按 tag 筛选 | 字段 | 含义 | 示例 |
secrets search --tag hongkong |------|------|------|
``` | `folder` | 隔离空间(参与唯一键) | `refining` |
| `type` | 软分类(不参与唯一键) | `server`, `service`, `key`, `person` |
| `name` | 标识名 | `gitea`, `aliyun` |
| `notes` | 非敏感说明 | 自由文本 |
| `tags` | 标签 | `["aliyun","prod"]` |
| `metadata` | 明文描述 | `ip``url``key_ref` |
| `secrets.field_name` | 加密字段名(明文) | `token`, `ssh_key` |
| `secrets.encrypted` | 密文 | AES-GCM |
### PEM 共享(`key_ref`
建议将共享 PEM 存为 **`type=key`** 的 entry其它记录在 `metadata.key_ref` 指向目标 entry 的 `name`(支持 `folder/name` 格式消歧)。删除被引用 key 时,服务会自动迁移为单副本 + 重定向(复制到首个引用方,其余引用方改指向新 owner解析逻辑见 `secrets_core::service::env_map`
## 代码规范 ## 代码规范
- 错误处理:统一使用 `anyhow::Result`不用 `unwrap()` - 错误:业务层 `anyhow::Result`避免生产路径 `unwrap()`
- 异步:全程 `tokio`,数据库操作 `sqlx` async - 异步:`tokio` + `sqlx` async
- SQL使用 `sqlx::query` / `sqlx::query_as` 绑定参数,禁止字符串拼接(搜索的动态 WHERE 子句除外,需使用参数绑定 `$1/$2` - SQL`sqlx::query` / `query_as` 参数绑定;动态 WHERE 仍须用占位符绑定。
- 新增 `kind` 类型时:只需在 `add` 调用时传入,无需改代码 - 日志:运维用 `tracing`;面向用户的 Web 响应走 axum handler。tracing 字段风格:变量名即字段名时用简写(`%var``?var``var`),否则用显式形式(`field = %expr`)。
- 字段命名CLI 短标志 `-n`=namespace`-m`=meta`-s`=secret`-q`=query - 审计:写操作成功后尽量 `audit::log_tx`;失败可 `warn`,不掩盖主错误。
- 加密:密钥由用户密码短语通过 **PBKDF2-SHA256600k 次)** 在客户端派生,服务端只存 `key_salt`/`key_check`/`key_params`不持有原始密钥。Web 客户端在浏览器本地完成加解密MCP 客户端通过 `X-Encryption-Key` 请求头传递密钥,服务端临时解密后返回明文。
- MCPtools 参数与 JSON Schema`schemars`)保持同步,鉴权以请求扩展中的用户上下文为准。
## 提交前检查
```bash
./scripts/release-check.sh
```
或手动:
```bash
cargo fmt -- --check
cargo clippy --locked -- -D warnings
cargo test --locked
```
发版前确认未重复 tag
```bash
grep '^version' crates/secrets-mcp/Cargo.toml
git tag -l 'secrets-mcp-*'
```
## CI/CD ## CI/CD
- Gitea Actionsrunner: debian - **触发**:任意分支 `push`,且路径含 `crates/**``deploy/**`、根目录 `Cargo.toml``Cargo.lock``.gitea/workflows/**`(见 `.gitea/workflows/secrets.yml`)。
- 触发:`src/**``Cargo.toml``Cargo.lock` 变更推送到 main - **版本与 tag**:从 `crates/secrets-mcp/Cargo.toml` 读版本;构建成功后打 `secrets-mcp-<version>`:若远端已存在同名 tagCI 会先删后于**当前提交**重建并推送(覆盖式发版)。
- 构建目标:`x86_64-unknown-linux-musl`(静态链接,无 glibc 依赖) - **质量与构建**`fmt` / `clippy --locked` / `test --locked``x86_64-unknown-linux-musl` 发布构建 `secrets-mcp`
- 新版本自动打 Tag格式 `secrets-<version>`)并上传二进制到 Gitea Release - **Release可选**`secrets.RELEASE_TOKEN`Gitea PAT用于通过 API **创建或更新**该 tag 的 Release非 draft、上传 `tar.gz` + `.sha256`;未配置则跳过 API Release仅 tag + 构建。
- 通知:飞书 Webhook`vars.WEBHOOK_URL` - **部署(可选)**:仅 `main``feat/mcp``mcp` 分支在构建成功时跑 `deploy-mcp`;需 `vars.DEPLOY_HOST``vars.DEPLOY_USER``secrets.DEPLOY_SSH_KEY`。勿把 OAuth/DB 等写进 workflow`deploy/.env.example` 在目标机配置。
- 所需 secrets/vars`RELEASE_TOKEN`Release 上传Gitea PAT`vars.WEBHOOK_URL`(通知,可选) - **Secrets 写法**Actions **secrets 须为原始值**PEM、PAT 明文),**勿** base64否则 SSH/Release 会失败。**勿**在 CI 中保存 `GOOGLE_CLIENT_SECRET`、DB 密码。
- **通知**`vars.WEBHOOK_URL`(可选,飞书)。
## 环境变量 ## 环境变量secrets-mcp
| 变量 | 说明 | | 变量 | 说明 |
|------|------| |------|------|
| `DATABASE_URL` | PostgreSQL 连接串,优先级高于 `--db-url` 参数 | | `SECRETS_DATABASE_URL` | **必填**。PostgreSQL URL。 |
| `BASE_URL` | 对外基址OAuth 回调 `${BASE_URL}/auth/google/callback`。 |
| `SECRETS_MCP_BIND` | 监听地址,默认 `127.0.0.1:9315`(容器/远程直接暴露时需改为 `0.0.0.0:9315`)。 |
| `GOOGLE_CLIENT_ID` / `GOOGLE_CLIENT_SECRET` | 可选;仅运行时配置。 |
| `RUST_LOG` | 如 `secrets_mcp=debug`。 |
> `SERVER_MASTER_KEY` 已不再需要。新架构下密钥由用户密码短语在客户端派生,服务端不持有。

1722
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,16 +1,38 @@
[package] [workspace]
name = "secrets" members = [
version = "0.1.0" "crates/secrets-core",
"crates/secrets-mcp",
]
resolver = "2"
[workspace.package]
edition = "2024" edition = "2024"
[dependencies] [workspace.dependencies]
anyhow = "1.0.102" # Async runtime
chrono = { version = "0.4.44", features = ["serde"] } tokio = { version = "^1.50.0", features = ["rt-multi-thread", "macros", "fs", "io-util", "process", "signal"] }
clap = { version = "4.6.0", features = ["derive", "env"] }
dotenvy = "0.15.7" # Database
serde = { version = "1.0.228", features = ["derive"] } sqlx = { version = "^0.8.6", features = ["runtime-tokio", "tls-rustls", "postgres", "uuid", "json", "chrono"] }
serde_json = "1.0.149"
sqlx = { version = "0.8.6", features = ["runtime-tokio", "tls-native-tls", "postgres", "uuid", "json", "chrono"] } # Serialization
tokio = { version = "1.50.0", features = ["full"] } serde = { version = "^1.0.228", features = ["derive"] }
toml = "1.0.7" serde_json = "^1.0.149"
uuid = { version = "1.22.0", features = ["serde", "v4"] } serde_yaml = "^0.9"
toml = "^1.0.7"
# Crypto
aes-gcm = "^0.10.3"
sha2 = "^0.10.9"
rand = "^0.10.0"
# Utils
anyhow = "^1.0.102"
chrono = { version = "^0.4.44", features = ["serde"] }
uuid = { version = "^1.22.0", features = ["serde"] }
tracing = "^0.1"
tracing-subscriber = { version = "^0.3", features = ["env-filter"] }
dotenvy = "^0.15"
# HTTP
reqwest = { version = "^0.12", default-features = false, features = ["rustls-tls", "json"] }

237
README.md
View File

@@ -1,104 +1,211 @@
# secrets # secrets-mcp
跨设备密钥与配置管理 CLI基于 Rust + PostgreSQL 18 Workspace**`secrets-core`** + **`secrets-mcp`**HTTP Streamable MCP + Web。多租户密钥与元数据存 PostgreSQL用户通过 **Google OAuth** 登录,**API Key** 鉴权 MCP 请求;秘密数据用**用户密码短语派生的密钥**在客户端加密,服务端不持有原始密钥
将服务器信息、服务凭据统一存入数据库,供本地工具和 AI 读取上下文。
## 安装 ## 安装
```bash ```bash
cargo build --release cargo build --release -p secrets-mcp
# 或从 Release 页面下载预编译二进制 # 产物: target/release/secrets-mcp
``` ```
配置数据库连接: 发版产物见 Gitea Releasetag`secrets-mcp-<version>`Linux musl 预编译);其它平台本地 `cargo build`
## 环境变量与本地运行
复制 `deploy/.env.example` 为项目根目录 `.env`(已在 `.gitignore`),或导出同名变量:
| 变量 | 说明 |
|------|------|
| `SECRETS_DATABASE_URL` | **必填**。PostgreSQL 连接串(推荐使用域名,例如 `db.refining.ltd`,避免直连 IP。 |
| `SECRETS_DATABASE_SSL_MODE` | 可选但强烈建议生产必填。推荐 `verify-full`(至少 `verify-ca`),避免回退到弱 TLS 模式。 |
| `SECRETS_DATABASE_SSL_ROOT_CERT` | 可选。私有 CA 或自签链路时指定 CA 根证书路径(如 `/etc/secrets/pg-ca.crt`)。 |
| `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、勿打入二进制。 |
| `RUST_LOG` | 可选;日志级别,如 `secrets_mcp=debug`。 |
```bash ```bash
export DATABASE_URL=postgres://postgres:<password>@<host>:5432/secrets cargo run -p secrets-mcp
# 或在项目根目录创建 .env 文件写入上述变量
``` ```
## 使用 生产推荐示例PostgreSQL TLS
```bash ```bash
# 查看版本 SECRETS_DATABASE_URL=postgres://postgres:***@db.refining.ltd:5432/secrets-mcp
secrets -V SECRETS_DATABASE_SSL_MODE=verify-full
secrets --version SECRETS_DATABASE_SSL_ROOT_CERT=/etc/secrets/pg-ca.crt
SECRETS_ENV=production
```
# 查看帮助 - **Web**`BASE_URL`登录、Dashboard、设置密码短语、创建 API Key
secrets --help - **MCP**Streamable HTTP 基址 `{BASE_URL}/mcp`,需 `Authorization: Bearer <api_key>` + `X-Encryption-Key: <hex>` 请求头(读密文工具须带密钥)。
secrets -h
# 查看子命令帮助 ## PostgreSQL TLS 加固
secrets help add
secrets help search
secrets help delete
# 添加服务器 - 推荐将数据库域名单独设置为 `db.refining.ltd`,服务域名保持 `secrets.refining.app`
secrets add -n refining --kind server --name my-server \ - 数据库证书建议使用可校验链路(如 Let's Encrypt 或私有 CA并保证证书 `SAN` 包含 `db.refining.ltd`
--tag aliyun --tag shanghai \ - PostgreSQL 侧建议使用 `hostssl` 规则限制应用来源(如 `47.238.146.244/32`),逐步移除公网明文 `host` 访问。
-m ip=1.2.3.4 -m desc="My Server" \ - 应用端推荐 `SECRETS_DATABASE_SSL_MODE=verify-full`;仅在过渡阶段可临时用 `verify-ca`
-s username=root \ - 可执行运维步骤见 [`deploy/postgres-tls-hardening.md`](deploy/postgres-tls-hardening.md)。
-s ssh_key=@./keys/my.pem
# 添加服务凭据 ## MCP 与 AI 工作流v0.3+
secrets add -n refining --kind service --name gitea \
-m url=https://gitea.example.com \
-s token=<token>
# 搜索(默认隐藏敏感字段) 条目在逻辑上以 **`(folder, name)`** 在用户内唯一(数据库唯一索引:`user_id + folder + name`)。同名可在不同 folder 下各存一条(例如 `refining/aliyun``ricnsmart/aliyun`)。
secrets search
secrets search -n refining --kind server
secrets search --tag hongkong
secrets search -q mqtt # 关键词匹配 name / metadata / tags
secrets search -n refining --kind service --name gitea --show-secrets
# 删除 - **`secrets_search`**:发现条目(可按 query / folder / type / name 过滤);不要求加密头。
secrets delete -n refining --kind server --name my-server - **`secrets_get` / `secrets_update` / `secrets_delete`(按 name/ `secrets_history` / `secrets_rollback`**:仅 `name` 且全局唯一则直接命中;若多条同名,返回消歧错误,需在参数中补 **`folder`**。
- **`secrets_delete`**`dry_run=true` 时与真实删除相同的消歧规则——唯一则预览一条,多条则报错并要求 `folder`
- **共享 key 自动迁移删除**:删除仍被 `metadata.key_ref` 引用的 key 条目时,系统会自动迁移:把密文复制到首个引用方,并将其余引用方的 `key_ref` 重定向到新 owner然后继续删除。
## 加密架构(混合 E2EE
### 密钥派生
用户在 Web Dashboard 设置**密码短语**,浏览器使用 **Web Crypto APIPBKDF2-SHA256600k 次迭代)**在本地派生 256-bit AES 密钥。
- **Salt32B**:首次设置时在浏览器生成,存入服务端 `users.key_salt`
- **key_check**:派生密钥加密已知常量 `"secrets-mcp-key-check"`,存入 `users.key_check`,用于登录时验证密码短语
- **服务端不存储原始密钥**,只存 salt + key_check
跨设备同步:新设备登录 → 输入相同密码短语 → 从服务端取 salt → 同样的 PBKDF2 → 得到相同密钥。
### 写入与读取流程
```mermaid
flowchart LR
subgraph Web["Web 浏览器E2E"]
P["密码短语"] --> K["PBKDF2 → 256-bit key"]
K --> Enc["AES-256-GCM 加密"]
K --> Dec["AES-256-GCM 解密"]
end
subgraph AI["AI 客户端MCP"]
HdrKey["X-Encryption-Key: hex"]
end
subgraph Server["secrets-mcp 服务端"]
Middleware["请求中临时持有 key\n请求结束即丢弃"]
DB[(PostgreSQL\nsecrets.encrypted = 密文\nentries.metadata = 明文)]
end
Enc -->|密文| Server
HdrKey -->|key + 请求| Middleware
Middleware <-->|加解密| DB
DB -->|密文| Dec
```
### 两种客户端对比
| | Web 浏览器 | AI 客户端MCP |
|---|---|---|
| 密钥位置 | 仅在浏览器内存 / sessionStorage | MCP 配置 headers 中 |
| 加解密位置 | 客户端(真正 E2E | 服务端临时(请求级生命周期) |
| 安全边界 | 服务端零知识 | 依赖 TLS + 服务端内存隔离 |
### 敏感数据传输
- **OAuth `client_secret`** 只存服务端环境变量,不发给浏览器
- **API Key** 当前存放在 `users.api_key`Dashboard 会明文展示并可重置
- **X-Encryption-Key** 随 MCP 请求经 TLS 传输,服务端仅在请求处理期间持有(不持久化)
- **生产环境必须走 HTTPS/TLS**
## AI 客户端配置
在 Web Dashboard 设置密码短语后,解锁页面会按客户端格式生成配置。常见客户端示例如下:
`Cursor / Claude Desktop` 风格:
```json
{
"mcpServers": {
"secrets": {
"url": "https://secrets.example.com/mcp",
"headers": {
"Authorization": "Bearer sk_abc123...",
"X-Encryption-Key": "a1b2c3...64位hex"
}
}
}
}
```
`OpenCode` 风格:
```json
{
"mcp": {
"secrets": {
"type": "remote",
"enabled": true,
"url": "https://secrets.example.com/mcp",
"headers": {
"Authorization": "Bearer sk_abc123...",
"X-Encryption-Key": "a1b2c3...64位hex"
}
}
}
}
``` ```
## 数据模型 ## 数据模型
单张 `secrets` 表,首次连自动建表 主表 **`entries`**`folder``type``name``notes``tags``metadata`,多租户时带 `user_id`+ 子表 **`secrets`**(每行一个加密字段:`field_name``encrypted`)。**唯一性**`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`);已有库可对照 [`scripts/migrate-v0.3.0.sql`](scripts/migrate-v0.3.0.sql) 做列重命名与索引重建。**Web 登录会话**tower-sessions使用同一 `SECRETS_DATABASE_URL`,进程启动时对会话存储执行迁移(见 `secrets-mcp``PostgresStore::migrate`),无需额外环境变量
| 字段 | 说明 | | 位置 | 字段 | 说明 |
|------|------| |------|------|------|
| `namespace` | 一级隔离,如 `refining``ricnsmart` | | entries | folder | 组织/隔离空间,如 `refining``ricnsmart`;参与唯一键 |
| `kind` | 记录类型,如 `server``service`(可自由扩展 | | entries | type | 软分类,如 `server``service``key``person`(可扩展,不参与唯一键 |
| `name` | 人类可读唯一标识 | | entries | name | 人类可读标识;与 `folder` 一起在用户内唯一 |
| `tags` | 多维标签,如 `["aliyun","hongkong"]` | | entries | notes | 非敏感说明文本 |
| `metadata` | 明文描述信息ip、desc、domains 等) | | entries | metadata | 明文 JSONip、url、`key_ref` 等) |
| `encrypted` | 敏感凭据ssh_key、password、token 等MVP 阶段明文存储,预留加密字段 | | secrets | field_name | 明文字段名,便于 schema 展示 |
| secrets | encrypted | AES-GCM 密文(含 nonce |
| users | key_salt | PBKDF2 salt32B首次设置密码短语时写入 |
| users | key_check | 派生密钥加密已知常量,用于验证密码短语 |
| users | key_params | 派生算法参数,如 `{"alg":"pbkdf2-sha256","iterations":600000}` |
`-m` / `--meta` 写入 `metadata``-s` / `--secret` 写入 `encrypted``value=@file` 从文件读取内容。 ### PEM 共享(`key_ref`
同一 PEM 可被多条 `server` 等记录引用:建议将 PEM 存为 **`type=key`** 的 entry在其它条目的 `metadata.key_ref` 中写目标 entry 的 `name`(支持 `folder/name` 格式消歧);轮换时只更新该目标记录即可。
删除共享 key 时,系统会自动迁移引用:将密文复制到首个引用方(单副本),其余引用方的 `key_ref` 自动重定向到该新 owner再删除原 key 记录。
## 审计日志
`add``update``delete` 等写操作写入 **`audit_log`**(操作类型、对象、摘要,不含 secret 明文)。多租户场景下可写 **`user_id`**(可空,兼容遗留行)。
业务条目事件使用 **`folder` / `type` / `name`**;登录类事件使用 **`folder='auth'`**,此时 `type`/`name` 表示认证目标(例如 `oauth` / `google`),不表示某条 secrets entry。
```sql
SELECT action, folder, type, name, detail, user_id, created_at
FROM audit_log
ORDER BY created_at DESC
LIMIT 20;
```
## 项目结构 ## 项目结构
``` ```
src/ Cargo.toml
main.rs # CLI 入口clap crates/secrets-core/ # db / crypto / models / audit / service
db.rs # 连接池 + auto-migrate crates/secrets-mcp/ # MCP HTTP、Web、OAuth、API Key
models.rs # Secret 结构体
commands/
add.rs # upsert
search.rs # 多条件查询
delete.rs # 删除
scripts/ scripts/
seed-data.sh # 导入 refining / ricnsmart 全量数据 migrate-v0.3.0.sql # 可选:手动 SQL 迁移namespace/kind → folder/type、唯一键含 folder
deploy/ # systemd、.env 示例
``` ```
## CI/CDGitea Actions ## CI/CDGitea Actions
推送 `main` 分支时自动fmt/clippy 检查 → musl 构建 → 创建 Release 并上传二进制 见 [`.gitea/workflows/secrets.yml`](.gitea/workflows/secrets.yml)
**首次使用需配置 Actions 变量和 Secrets** - **触发**:任意分支 `push`,且变更路径包含 `crates/**``deploy/**`、根目录 `Cargo.toml` / `Cargo.lock``.gitea/workflows/**`
- **流水线**:解析 `crates/secrets-mcp/Cargo.toml` 版本 → `cargo fmt` / `clippy --locked` / `test --locked` → 交叉编译 `x86_64-unknown-linux-musl``secrets-mcp` → 构建成功后打 tag `secrets-mcp-<version>`(若远端已存在同名 tag会先删除再于**当前提交**重建并推送,覆盖式发版)。
- **Release可选**:配置仓库 Secret `RELEASE_TOKEN`Gitea PAT明文勿 base64会通过 API **创建或更新**已指向该 tag 的 Release非 draft、上传 `tar.gz``.sha256`;未配置则跳过 API Release仅 tag + 构建结果。
- **部署(可选)**:仅在 `main``feat/mcp``mcp` 分支且构建成功时,若已配置 `vars.DEPLOY_HOST``vars.DEPLOY_USER``secrets.DEPLOY_SSH_KEY`,则 `deploy-mcp` 通过 SCP/SSH 更新目标机二进制并 `systemctl restart secrets-mcp`
- **通知(可选)**`vars.WEBHOOK_URL` 为飞书 Webhook 时,构建/部署/发布节点会推送简要状态。
```bash ```bash
# 需有 ~/.config/gitea/config.envGITEA_URL、GITEA_TOKEN、GITEA_WEBHOOK_URL ./scripts/setup-gitea-actions.sh # 通过 Gitea API 写入 RELEASE_TOKEN、WEBHOOK_URL、部署相关变量等
./scripts/setup-gitea-actions.sh
``` ```
- `RELEASE_TOKEN`SecretGitea PAT用于创建 Release 上传二进制 详见 [AGENTS.md](AGENTS.md)(发版规则、代码规范)。
- `WEBHOOK_URL`Variable飞书通知可选
详见 [AGENTS.md](AGENTS.md)。

View File

@@ -0,0 +1,26 @@
[package]
name = "secrets-core"
version = "0.1.0"
edition.workspace = true
[lib]
name = "secrets_core"
path = "src/lib.rs"
[dependencies]
aes-gcm.workspace = true
anyhow.workspace = true
chrono.workspace = true
rand.workspace = true
serde.workspace = true
serde_json.workspace = true
serde_yaml.workspace = true
sha2.workspace = true
sqlx.workspace = true
toml.workspace = true
tokio.workspace = true
tracing.workspace = true
uuid.workspace = true
[dev-dependencies]
tempfile = "3"

View File

@@ -0,0 +1,88 @@
use serde_json::{Value, json};
use sqlx::{PgPool, Postgres, Transaction};
use uuid::Uuid;
pub const ACTION_LOGIN: &str = "login";
pub const FOLDER_AUTH: &str = "auth";
fn login_detail(provider: &str, client_ip: Option<&str>, user_agent: Option<&str>) -> Value {
json!({
"provider": provider,
"client_ip": client_ip,
"user_agent": user_agent,
})
}
/// Write a login audit entry without requiring an explicit transaction.
pub async fn log_login(
pool: &PgPool,
entry_type: &str,
provider: &str,
user_id: Uuid,
client_ip: Option<&str>,
user_agent: Option<&str>,
) {
let detail = login_detail(provider, client_ip, user_agent);
let result: Result<_, sqlx::Error> = sqlx::query(
"INSERT INTO audit_log (user_id, action, folder, type, name, detail) \
VALUES ($1, $2, $3, $4, $5, $6)",
)
.bind(user_id)
.bind(ACTION_LOGIN)
.bind(FOLDER_AUTH)
.bind(entry_type)
.bind(provider)
.bind(&detail)
.execute(pool)
.await;
if let Err(e) = result {
tracing::warn!(error = %e, entry_type, provider, "failed to write login audit log");
} else {
tracing::debug!(entry_type, provider, ?user_id, "login audit logged");
}
}
/// Write an audit entry within an existing transaction.
pub async fn log_tx(
tx: &mut Transaction<'_, Postgres>,
user_id: Option<Uuid>,
action: &str,
folder: &str,
entry_type: &str,
name: &str,
detail: Value,
) {
let result: Result<_, sqlx::Error> = sqlx::query(
"INSERT INTO audit_log (user_id, action, folder, type, name, detail) \
VALUES ($1, $2, $3, $4, $5, $6)",
)
.bind(user_id)
.bind(action)
.bind(folder)
.bind(entry_type)
.bind(name)
.bind(&detail)
.execute(&mut **tx)
.await;
if let Err(e) = result {
tracing::warn!(error = %e, "failed to write audit log");
} else {
tracing::debug!(action, folder, entry_type, name, "audit logged");
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn login_detail_includes_expected_fields() {
let detail = login_detail("google", Some("127.0.0.1"), Some("Mozilla/5.0"));
assert_eq!(detail["provider"], "google");
assert_eq!(detail["client_ip"], "127.0.0.1");
assert_eq!(detail["user_agent"], "Mozilla/5.0");
}
}

View File

@@ -0,0 +1,82 @@
use std::path::PathBuf;
use anyhow::{Context, Result};
use sqlx::postgres::PgSslMode;
#[derive(Debug, Clone)]
pub struct DatabaseConfig {
pub url: String,
pub ssl_mode: Option<PgSslMode>,
pub ssl_root_cert: Option<PathBuf>,
pub enforce_strict_tls: bool,
}
/// Resolve database URL from environment.
/// Priority: `SECRETS_DATABASE_URL` env var → error.
pub fn resolve_db_url(override_url: &str) -> Result<String> {
if !override_url.is_empty() {
return Ok(override_url.to_string());
}
if let Ok(url) = std::env::var("SECRETS_DATABASE_URL")
&& !url.is_empty()
{
return Ok(url);
}
anyhow::bail!(
"Database not configured. Set the SECRETS_DATABASE_URL environment variable.\n\
Example: SECRETS_DATABASE_URL=postgres://user:pass@host:port/dbname"
)
}
fn env_var_non_empty(name: &str) -> Option<String> {
std::env::var(name)
.ok()
.filter(|value| !value.trim().is_empty())
}
fn parse_ssl_mode_from_env() -> Result<Option<PgSslMode>> {
let Some(mode) = env_var_non_empty("SECRETS_DATABASE_SSL_MODE") else {
return Ok(None);
};
let parsed = mode.parse::<PgSslMode>().with_context(|| {
format!(
"Invalid SECRETS_DATABASE_SSL_MODE='{mode}'. Use one of: disable, allow, prefer, require, verify-ca, verify-full."
)
})?;
Ok(Some(parsed))
}
fn resolve_ssl_root_cert_from_env() -> Result<Option<PathBuf>> {
let Some(path) = env_var_non_empty("SECRETS_DATABASE_SSL_ROOT_CERT") else {
return Ok(None);
};
let path = PathBuf::from(path);
if !path.exists() {
anyhow::bail!(
"SECRETS_DATABASE_SSL_ROOT_CERT points to a missing file: {}",
path.display()
);
}
Ok(Some(path))
}
fn is_production_env() -> bool {
matches!(
env_var_non_empty("SECRETS_ENV")
.as_deref()
.map(|value| value.to_ascii_lowercase()),
Some(value) if value == "prod" || value == "production"
)
}
pub fn resolve_db_config(override_url: &str) -> Result<DatabaseConfig> {
Ok(DatabaseConfig {
url: resolve_db_url(override_url)?,
ssl_mode: parse_ssl_mode_from_env()?,
ssl_root_cert: resolve_ssl_root_cert_from_env()?,
enforce_strict_tls: is_production_env(),
})
}

View File

@@ -0,0 +1,133 @@
use aes_gcm::{
Aes256Gcm, Key, Nonce,
aead::{Aead, AeadCore, KeyInit, OsRng},
};
use anyhow::{Context, Result, bail};
use serde_json::Value;
const NONCE_LEN: usize = 12;
// ─── AES-256-GCM encrypt / decrypt ───────────────────────────────────────────
/// Encrypt plaintext bytes with AES-256-GCM.
/// Returns `nonce (12 B) || ciphertext+tag`.
pub fn encrypt(master_key: &[u8; 32], plaintext: &[u8]) -> Result<Vec<u8>> {
let key = Key::<Aes256Gcm>::from_slice(master_key);
let cipher = Aes256Gcm::new(key);
let nonce = Aes256Gcm::generate_nonce(&mut OsRng);
let ciphertext = cipher
.encrypt(&nonce, plaintext)
.map_err(|e| anyhow::anyhow!("AES-256-GCM encryption failed: {}", e))?;
let mut out = Vec::with_capacity(NONCE_LEN + ciphertext.len());
out.extend_from_slice(&nonce);
out.extend_from_slice(&ciphertext);
Ok(out)
}
/// Decrypt `nonce (12 B) || ciphertext+tag` with AES-256-GCM.
pub fn decrypt(master_key: &[u8; 32], data: &[u8]) -> Result<Vec<u8>> {
if data.len() < NONCE_LEN {
bail!(
"encrypted data too short ({}B); possibly corrupted",
data.len()
);
}
let (nonce_bytes, ciphertext) = data.split_at(NONCE_LEN);
let key = Key::<Aes256Gcm>::from_slice(master_key);
let cipher = Aes256Gcm::new(key);
let nonce = Nonce::from_slice(nonce_bytes);
cipher
.decrypt(nonce, ciphertext)
.map_err(|_| anyhow::anyhow!("decryption failed — wrong master key or corrupted data"))
}
// ─── JSON helpers ─────────────────────────────────────────────────────────────
/// Serialize a JSON Value and encrypt it. Returns the encrypted blob.
pub fn encrypt_json(master_key: &[u8; 32], value: &Value) -> Result<Vec<u8>> {
let bytes = serde_json::to_vec(value).context("serialize JSON for encryption")?;
encrypt(master_key, &bytes)
}
/// Decrypt an encrypted blob and deserialize it as a JSON Value.
pub fn decrypt_json(master_key: &[u8; 32], data: &[u8]) -> Result<Value> {
let bytes = decrypt(master_key, data)?;
serde_json::from_slice(&bytes).context("deserialize decrypted JSON")
}
// ─── Client-supplied key extraction ──────────────────────────────────────────
/// Parse a 64-char hex string (from X-Encryption-Key header) into a 32-byte key.
pub fn extract_key_from_hex(hex_str: &str) -> Result<[u8; 32]> {
let bytes = hex::decode_hex(hex_str.trim())?;
if bytes.len() != 32 {
bail!(
"X-Encryption-Key must be 64 hex chars (32 bytes), got {} bytes",
bytes.len()
);
}
let mut key = [0u8; 32];
key.copy_from_slice(&bytes);
Ok(key)
}
// ─── Public hex helpers ───────────────────────────────────────────────────────
pub mod hex {
use anyhow::{Result, bail};
pub fn encode_hex(bytes: &[u8]) -> String {
bytes.iter().map(|b| format!("{:02x}", b)).collect()
}
pub fn decode_hex(s: &str) -> Result<Vec<u8>> {
let s = s.trim();
if !s.len().is_multiple_of(2) {
bail!("hex string has odd length");
}
(0..s.len())
.step_by(2)
.map(|i| u8::from_str_radix(&s[i..i + 2], 16).map_err(|e| anyhow::anyhow!("{}", e)))
.collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn roundtrip_encrypt_decrypt() {
let key = [0x42u8; 32];
let plaintext = b"hello world";
let enc = encrypt(&key, plaintext).unwrap();
let dec = decrypt(&key, &enc).unwrap();
assert_eq!(dec, plaintext);
}
#[test]
fn encrypt_produces_different_ciphertexts() {
let key = [0x42u8; 32];
let plaintext = b"hello world";
let enc1 = encrypt(&key, plaintext).unwrap();
let enc2 = encrypt(&key, plaintext).unwrap();
assert_ne!(enc1, enc2);
}
#[test]
fn wrong_key_fails_decryption() {
let key1 = [0x42u8; 32];
let key2 = [0x43u8; 32];
let enc = encrypt(&key1, b"secret").unwrap();
assert!(decrypt(&key2, &enc).is_err());
}
#[test]
fn json_roundtrip() {
let key = [0x42u8; 32];
let value = serde_json::json!({"token": "abc123", "password": "hunter2"});
let enc = encrypt_json(&key, &value).unwrap();
let dec = decrypt_json(&key, &enc).unwrap();
assert_eq!(dec, value);
}
}

View File

@@ -0,0 +1,530 @@
use std::str::FromStr;
use anyhow::{Context, Result};
use serde_json::Value;
use sqlx::PgPool;
use sqlx::postgres::{PgConnectOptions, PgPoolOptions, PgSslMode};
use crate::config::DatabaseConfig;
fn build_connect_options(config: &DatabaseConfig) -> Result<PgConnectOptions> {
let mut options = PgConnectOptions::from_str(&config.url)
.with_context(|| "failed to parse SECRETS_DATABASE_URL".to_string())?;
if let Some(mode) = config.ssl_mode {
options = options.ssl_mode(mode);
}
if let Some(path) = &config.ssl_root_cert {
options = options.ssl_root_cert(path);
}
if config.enforce_strict_tls
&& !matches!(
options.get_ssl_mode(),
PgSslMode::VerifyCa | PgSslMode::VerifyFull
)
{
anyhow::bail!(
"Refusing to start in production with weak PostgreSQL TLS mode. \
Set SECRETS_DATABASE_SSL_MODE=verify-ca or verify-full."
);
}
Ok(options)
}
pub async fn create_pool(config: &DatabaseConfig) -> Result<PgPool> {
tracing::debug!("connecting to database");
let connect_options = build_connect_options(config)?;
let pool = PgPoolOptions::new()
.max_connections(10)
.acquire_timeout(std::time::Duration::from_secs(5))
.connect_with(connect_options)
.await?;
tracing::debug!("database connection established");
Ok(pool)
}
pub async fn migrate(pool: &PgPool) -> Result<()> {
tracing::debug!("running migrations");
sqlx::raw_sql(
r#"
-- ── entries: top-level entities ─────────────────────────────────────────
CREATE TABLE IF NOT EXISTS entries (
id UUID PRIMARY KEY DEFAULT uuidv7(),
user_id UUID,
folder VARCHAR(128) NOT NULL DEFAULT '',
type VARCHAR(64) NOT NULL DEFAULT '',
name VARCHAR(256) NOT NULL,
notes TEXT NOT NULL DEFAULT '',
tags TEXT[] NOT NULL DEFAULT '{}',
metadata JSONB NOT NULL DEFAULT '{}',
version BIGINT NOT NULL DEFAULT 1,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Legacy unique constraint without user_id (single-user mode)
CREATE UNIQUE INDEX IF NOT EXISTS idx_entries_unique_legacy
ON entries(folder, name)
WHERE user_id IS NULL;
-- Multi-user unique constraint
CREATE UNIQUE INDEX IF NOT EXISTS idx_entries_unique_user
ON entries(user_id, folder, name)
WHERE user_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_entries_folder ON entries(folder) WHERE folder <> '';
CREATE INDEX IF NOT EXISTS idx_entries_type ON entries(type) WHERE type <> '';
CREATE INDEX IF NOT EXISTS idx_entries_user_id ON entries(user_id) WHERE user_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_entries_tags ON entries USING GIN(tags);
CREATE INDEX IF NOT EXISTS idx_entries_metadata ON entries USING GIN(metadata jsonb_path_ops);
-- ── secrets: one row per encrypted field ─────────────────────────────────
CREATE TABLE IF NOT EXISTS secrets (
id UUID PRIMARY KEY DEFAULT uuidv7(),
entry_id UUID NOT NULL REFERENCES entries(id) ON DELETE CASCADE,
field_name VARCHAR(256) NOT NULL,
encrypted BYTEA NOT NULL DEFAULT '\x',
version BIGINT NOT NULL DEFAULT 1,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(entry_id, field_name)
);
CREATE INDEX IF NOT EXISTS idx_secrets_entry_id ON secrets(entry_id);
-- ── audit_log: append-only operation log ─────────────────────────────────
CREATE TABLE IF NOT EXISTS audit_log (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
user_id UUID,
action VARCHAR(32) NOT NULL,
folder VARCHAR(128) NOT NULL DEFAULT '',
type VARCHAR(64) NOT NULL DEFAULT '',
name VARCHAR(256) NOT NULL,
detail JSONB NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_audit_log_created ON audit_log(created_at DESC);
CREATE INDEX IF NOT EXISTS idx_audit_log_folder_type ON audit_log(folder, type);
CREATE INDEX IF NOT EXISTS idx_audit_log_user_id ON audit_log(user_id) WHERE user_id IS NOT NULL;
-- ── entries_history ───────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS entries_history (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
entry_id UUID NOT NULL,
folder VARCHAR(128) NOT NULL DEFAULT '',
type VARCHAR(64) NOT NULL DEFAULT '',
name VARCHAR(256) NOT NULL,
version BIGINT NOT NULL,
action VARCHAR(16) NOT NULL,
tags TEXT[] NOT NULL DEFAULT '{}',
metadata JSONB NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_entries_history_entry_id
ON entries_history(entry_id, version DESC);
CREATE INDEX IF NOT EXISTS idx_entries_history_folder_type_name
ON entries_history(folder, type, name, version DESC);
-- Backfill: add user_id to entries_history for multi-tenant isolation
ALTER TABLE entries_history ADD COLUMN IF NOT EXISTS user_id UUID;
CREATE INDEX IF NOT EXISTS idx_entries_history_user_id
ON entries_history(user_id) WHERE user_id IS NOT NULL;
ALTER TABLE entries_history DROP COLUMN IF EXISTS actor;
-- Backfill: add notes to entries if not present (fresh installs already have it)
ALTER TABLE entries ADD COLUMN IF NOT EXISTS notes TEXT NOT NULL DEFAULT '';
-- ── secrets_history: field-level snapshot ────────────────────────────────
CREATE TABLE IF NOT EXISTS secrets_history (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
entry_id UUID NOT NULL,
secret_id UUID NOT NULL,
entry_version BIGINT NOT NULL,
field_name VARCHAR(256) NOT NULL,
encrypted BYTEA NOT NULL DEFAULT '\x',
action VARCHAR(16) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_secrets_history_entry_id
ON secrets_history(entry_id, entry_version DESC);
CREATE INDEX IF NOT EXISTS idx_secrets_history_secret_id
ON secrets_history(secret_id);
-- Drop redundant actor column (derivable via entries_history JOIN)
ALTER TABLE secrets_history DROP COLUMN IF EXISTS actor;
-- ── users ─────────────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS users (
id UUID PRIMARY KEY DEFAULT uuidv7(),
email VARCHAR(256),
name VARCHAR(256) NOT NULL DEFAULT '',
avatar_url TEXT,
key_salt BYTEA,
key_check BYTEA,
key_params JSONB,
api_key TEXT UNIQUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- ── oauth_accounts: per-provider identity links ───────────────────────────
CREATE TABLE IF NOT EXISTS oauth_accounts (
id UUID PRIMARY KEY DEFAULT uuidv7(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
provider VARCHAR(32) NOT NULL,
provider_id VARCHAR(256) NOT NULL,
email VARCHAR(256),
name VARCHAR(256),
avatar_url TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(provider, provider_id)
);
CREATE INDEX IF NOT EXISTS idx_oauth_accounts_user ON oauth_accounts(user_id);
CREATE UNIQUE INDEX IF NOT EXISTS idx_oauth_accounts_user_provider
ON oauth_accounts(user_id, provider);
-- FK: user_id columns -> users(id) (nullable = legacy rows; ON DELETE SET NULL)
DO $$ BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'fk_entries_user_id'
) THEN
ALTER TABLE entries
ADD CONSTRAINT fk_entries_user_id
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL;
END IF;
END $$;
DO $$ BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'fk_entries_history_user_id'
) THEN
ALTER TABLE entries_history
ADD CONSTRAINT fk_entries_history_user_id
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL;
END IF;
END $$;
DO $$ BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'fk_audit_log_user_id'
) THEN
ALTER TABLE audit_log
ADD CONSTRAINT fk_audit_log_user_id
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL;
END IF;
END $$;
"#,
)
.execute(pool)
.await?;
migrate_schema(pool).await?;
restore_plaintext_api_keys(pool).await?;
tracing::debug!("migrations complete");
Ok(())
}
/// Idempotent schema migration: rename namespace→folder, kind→type in existing databases.
async fn migrate_schema(pool: &PgPool) -> Result<()> {
sqlx::raw_sql(
r#"
-- ── entries: rename namespace→folder, kind→type ──────────────────────────
DO $$ BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'entries' AND column_name = 'namespace'
) THEN
ALTER TABLE entries RENAME COLUMN namespace TO folder;
END IF;
END $$;
DO $$ BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'entries' AND column_name = 'kind'
) THEN
ALTER TABLE entries RENAME COLUMN kind TO type;
END IF;
END $$;
-- ── audit_log: rename namespace→folder, kind→type ────────────────────────
DO $$ BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'audit_log' AND column_name = 'namespace'
) THEN
ALTER TABLE audit_log RENAME COLUMN namespace TO folder;
END IF;
END $$;
DO $$ BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'audit_log' AND column_name = 'kind'
) THEN
ALTER TABLE audit_log RENAME COLUMN kind TO type;
END IF;
END $$;
-- ── entries_history: rename namespace→folder, kind→type ──────────────────
DO $$ BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'entries_history' AND column_name = 'namespace'
) THEN
ALTER TABLE entries_history RENAME COLUMN namespace TO folder;
END IF;
END $$;
DO $$ BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'entries_history' AND column_name = 'kind'
) THEN
ALTER TABLE entries_history RENAME COLUMN kind TO type;
END IF;
END $$;
-- ── Set empty defaults for new folder/type columns ────────────────────────
DO $$ BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'entries' AND column_name = 'folder'
) THEN
UPDATE entries SET folder = '' WHERE folder IS NULL;
ALTER TABLE entries ALTER COLUMN folder SET NOT NULL;
ALTER TABLE entries ALTER COLUMN folder SET DEFAULT '';
END IF;
END $$;
DO $$ BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'entries' AND column_name = 'type'
) THEN
UPDATE entries SET type = '' WHERE type IS NULL;
ALTER TABLE entries ALTER COLUMN type SET NOT NULL;
ALTER TABLE entries ALTER COLUMN type SET DEFAULT '';
END IF;
END $$;
DO $$ BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'audit_log' AND column_name = 'folder'
) THEN
UPDATE audit_log SET folder = '' WHERE folder IS NULL;
ALTER TABLE audit_log ALTER COLUMN folder SET NOT NULL;
ALTER TABLE audit_log ALTER COLUMN folder SET DEFAULT '';
END IF;
END $$;
DO $$ BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'audit_log' AND column_name = 'type'
) THEN
UPDATE audit_log SET type = '' WHERE type IS NULL;
ALTER TABLE audit_log ALTER COLUMN type SET NOT NULL;
ALTER TABLE audit_log ALTER COLUMN type SET DEFAULT '';
END IF;
END $$;
DO $$ BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'entries_history' AND column_name = 'folder'
) THEN
UPDATE entries_history SET folder = '' WHERE folder IS NULL;
ALTER TABLE entries_history ALTER COLUMN folder SET NOT NULL;
ALTER TABLE entries_history ALTER COLUMN folder SET DEFAULT '';
END IF;
END $$;
DO $$ BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'entries_history' AND column_name = 'type'
) THEN
UPDATE entries_history SET type = '' WHERE type IS NULL;
ALTER TABLE entries_history ALTER COLUMN type SET NOT NULL;
ALTER TABLE entries_history ALTER COLUMN type SET DEFAULT '';
END IF;
END $$;
-- ── Rebuild unique indexes on entries: folder is now part of the key ────────
-- (user_id, folder, name) allows same name in different folders.
DROP INDEX IF EXISTS idx_entries_unique_legacy;
DROP INDEX IF EXISTS idx_entries_unique_user;
CREATE UNIQUE INDEX IF NOT EXISTS idx_entries_unique_legacy
ON entries(folder, name)
WHERE user_id IS NULL;
CREATE UNIQUE INDEX IF NOT EXISTS idx_entries_unique_user
ON entries(user_id, folder, name)
WHERE user_id IS NOT NULL;
-- ── Replace old namespace/kind indexes ────────────────────────────────────
DROP INDEX IF EXISTS idx_entries_namespace;
DROP INDEX IF EXISTS idx_entries_kind;
DROP INDEX IF EXISTS idx_audit_log_ns_kind;
DROP INDEX IF EXISTS idx_entries_history_ns_kind_name;
CREATE INDEX IF NOT EXISTS idx_entries_folder
ON entries(folder) WHERE folder <> '';
CREATE INDEX IF NOT EXISTS idx_entries_type
ON entries(type) WHERE type <> '';
CREATE INDEX IF NOT EXISTS idx_audit_log_folder_type
ON audit_log(folder, type);
CREATE INDEX IF NOT EXISTS idx_entries_history_folder_type_name
ON entries_history(folder, type, name, version DESC);
-- ── Drop legacy actor columns ─────────────────────────────────────────────
ALTER TABLE secrets_history DROP COLUMN IF EXISTS actor;
ALTER TABLE audit_log DROP COLUMN IF EXISTS actor;
"#,
)
.execute(pool)
.await?;
Ok(())
}
async fn restore_plaintext_api_keys(pool: &PgPool) -> Result<()> {
let has_users_api_key: bool = sqlx::query_scalar(
"SELECT EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'users'
AND column_name = 'api_key'
)",
)
.fetch_one(pool)
.await?;
if !has_users_api_key {
sqlx::query("ALTER TABLE users ADD COLUMN api_key TEXT")
.execute(pool)
.await?;
sqlx::query("CREATE UNIQUE INDEX IF NOT EXISTS idx_users_api_key ON users(api_key) WHERE api_key IS NOT NULL")
.execute(pool)
.await?;
}
let has_api_keys_table: bool = sqlx::query_scalar(
"SELECT EXISTS (
SELECT 1
FROM information_schema.tables
WHERE table_schema = 'public'
AND table_name = 'api_keys'
)",
)
.fetch_one(pool)
.await?;
if !has_api_keys_table {
return Ok(());
}
#[derive(sqlx::FromRow)]
struct UserWithoutKey {
id: uuid::Uuid,
}
let users_without_key: Vec<UserWithoutKey> =
sqlx::query_as("SELECT DISTINCT user_id AS id FROM api_keys WHERE user_id NOT IN (SELECT id FROM users WHERE api_key IS NOT NULL)")
.fetch_all(pool)
.await?;
for user in users_without_key {
let new_key = crate::service::api_key::generate_api_key();
sqlx::query("UPDATE users SET api_key = $1 WHERE id = $2")
.bind(&new_key)
.bind(user.id)
.execute(pool)
.await?;
}
sqlx::query("DROP TABLE IF EXISTS api_keys")
.execute(pool)
.await?;
Ok(())
}
// ── Entry-level history snapshot ─────────────────────────────────────────────
pub struct EntrySnapshotParams<'a> {
pub entry_id: uuid::Uuid,
pub user_id: Option<uuid::Uuid>,
pub folder: &'a str,
pub entry_type: &'a str,
pub name: &'a str,
pub version: i64,
pub action: &'a str,
pub tags: &'a [String],
pub metadata: &'a Value,
}
pub async fn snapshot_entry_history(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
p: EntrySnapshotParams<'_>,
) -> Result<()> {
sqlx::query(
"INSERT INTO entries_history \
(entry_id, folder, type, name, version, action, tags, metadata, user_id) \
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)",
)
.bind(p.entry_id)
.bind(p.folder)
.bind(p.entry_type)
.bind(p.name)
.bind(p.version)
.bind(p.action)
.bind(p.tags)
.bind(p.metadata)
.bind(p.user_id)
.execute(&mut **tx)
.await?;
Ok(())
}
// ── Secret field-level history snapshot ──────────────────────────────────────
pub struct SecretSnapshotParams<'a> {
pub entry_id: uuid::Uuid,
pub secret_id: uuid::Uuid,
pub entry_version: i64,
pub field_name: &'a str,
pub encrypted: &'a [u8],
pub action: &'a str,
}
pub async fn snapshot_secret_history(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
p: SecretSnapshotParams<'_>,
) -> Result<()> {
sqlx::query(
"INSERT INTO secrets_history \
(entry_id, secret_id, entry_version, field_name, encrypted, action) \
VALUES ($1, $2, $3, $4, $5, $6)",
)
.bind(p.entry_id)
.bind(p.secret_id)
.bind(p.entry_version)
.bind(p.field_name)
.bind(p.encrypted)
.bind(p.action)
.execute(&mut **tx)
.await?;
Ok(())
}
// ── DB helpers ────────────────────────────────────────────────────────────────

View File

@@ -0,0 +1,6 @@
pub mod audit;
pub mod config;
pub mod crypto;
pub mod db;
pub mod models;
pub mod service;

View File

@@ -0,0 +1,304 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::BTreeMap;
use uuid::Uuid;
/// A top-level entry (server, service, key, person, …).
/// Sensitive fields are stored separately in `secrets`.
#[derive(Debug, Serialize, Deserialize, sqlx::FromRow)]
pub struct Entry {
pub id: Uuid,
pub user_id: Option<Uuid>,
pub folder: String,
#[serde(rename = "type")]
#[sqlx(rename = "type")]
pub entry_type: String,
pub name: String,
pub notes: String,
pub tags: Vec<String>,
pub metadata: Value,
pub version: i64,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
/// A single encrypted field belonging to an Entry.
#[derive(Debug, Serialize, Deserialize, sqlx::FromRow)]
pub struct SecretField {
pub id: Uuid,
pub entry_id: Uuid,
pub field_name: String,
/// AES-256-GCM ciphertext: nonce(12B) || ciphertext+tag
pub encrypted: Vec<u8>,
pub version: i64,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
// ── Internal query row types (shared across commands) ─────────────────────────
/// Minimal entry row fetched for write operations (add / update / delete / rollback).
#[derive(Debug, sqlx::FromRow)]
pub struct EntryRow {
pub id: Uuid,
pub version: i64,
pub folder: String,
#[sqlx(rename = "type")]
pub entry_type: String,
pub tags: Vec<String>,
pub metadata: Value,
pub notes: String,
}
/// Entry row including `name` (used for id-scoped web / service updates).
#[derive(Debug, sqlx::FromRow)]
pub struct EntryWriteRow {
pub id: Uuid,
pub version: i64,
pub folder: String,
#[sqlx(rename = "type")]
pub entry_type: String,
pub name: String,
pub tags: Vec<String>,
pub metadata: Value,
pub notes: String,
}
impl From<&EntryWriteRow> for EntryRow {
fn from(r: &EntryWriteRow) -> Self {
EntryRow {
id: r.id,
version: r.version,
folder: r.folder.clone(),
entry_type: r.entry_type.clone(),
tags: r.tags.clone(),
metadata: r.metadata.clone(),
notes: r.notes.clone(),
}
}
}
/// Minimal secret field row fetched before snapshots or cascade deletes.
#[derive(Debug, sqlx::FromRow)]
pub struct SecretFieldRow {
pub id: Uuid,
pub field_name: String,
pub encrypted: Vec<u8>,
}
// ── Export / Import types ──────────────────────────────────────────────────────
/// Supported file formats for export/import.
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum ExportFormat {
Json,
Toml,
Yaml,
}
impl std::str::FromStr for ExportFormat {
type Err = anyhow::Error;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_lowercase().as_str() {
"json" => Ok(Self::Json),
"toml" => Ok(Self::Toml),
"yaml" | "yml" => Ok(Self::Yaml),
other => anyhow::bail!("Unknown format '{}'. Expected: json, toml, or yaml", other),
}
}
}
impl ExportFormat {
/// Infer format from file extension (.json / .toml / .yaml / .yml).
pub fn from_extension(path: &str) -> anyhow::Result<Self> {
let ext = path.rsplit('.').next().unwrap_or("").to_lowercase();
ext.parse().map_err(|_| {
anyhow::anyhow!(
"Cannot infer format from extension '.{}'. Use --format json|toml|yaml",
ext
)
})
}
/// Serialize ExportData to a string in this format.
pub fn serialize(&self, data: &ExportData) -> anyhow::Result<String> {
match self {
Self::Json => Ok(serde_json::to_string_pretty(data)?),
Self::Toml => {
let toml_val = json_to_toml_value(&serde_json::to_value(data)?)?;
toml::to_string_pretty(&toml_val)
.map_err(|e| anyhow::anyhow!("TOML serialization failed: {}", e))
}
Self::Yaml => serde_yaml::to_string(data)
.map_err(|e| anyhow::anyhow!("YAML serialization failed: {}", e)),
}
}
/// Deserialize ExportData from a string in this format.
pub fn deserialize(&self, content: &str) -> anyhow::Result<ExportData> {
match self {
Self::Json => Ok(serde_json::from_str(content)?),
Self::Toml => {
let toml_val: toml::Value = toml::from_str(content)
.map_err(|e| anyhow::anyhow!("TOML parse error: {}", e))?;
let json_val = toml_to_json_value(&toml_val);
Ok(serde_json::from_value(json_val)?)
}
Self::Yaml => serde_yaml::from_str(content)
.map_err(|e| anyhow::anyhow!("YAML parse error: {}", e)),
}
}
}
/// Top-level structure for export/import files.
#[derive(Debug, Serialize, Deserialize)]
pub struct ExportData {
pub version: u32,
pub exported_at: String,
pub entries: Vec<ExportEntry>,
}
/// A single entry with decrypted secrets for export/import.
#[derive(Debug, Serialize, Deserialize)]
pub struct ExportEntry {
pub name: String,
#[serde(default)]
pub folder: String,
#[serde(default, rename = "type")]
pub entry_type: String,
#[serde(default)]
pub notes: String,
#[serde(default)]
pub tags: Vec<String>,
#[serde(default)]
pub metadata: Value,
/// Decrypted secret fields. None means no secrets in this export (--no-secrets).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub secrets: Option<BTreeMap<String, Value>>,
}
// ── Multi-user models ──────────────────────────────────────────────────────────
/// A registered user (created on first OAuth login).
#[derive(Debug, Serialize, Deserialize, sqlx::FromRow)]
pub struct User {
pub id: Uuid,
pub email: Option<String>,
pub name: String,
pub avatar_url: Option<String>,
/// PBKDF2 salt (32 B). NULL until user sets up passphrase.
pub key_salt: Option<Vec<u8>>,
/// AES-256-GCM encryption of the known constant "secrets-mcp-key-check".
/// Used to verify the passphrase without storing the key itself.
pub key_check: Option<Vec<u8>>,
/// Key derivation parameters, e.g. {"alg":"pbkdf2-sha256","iterations":600000}.
pub key_params: Option<serde_json::Value>,
/// Plaintext API key for MCP Bearer authentication. Auto-created on first login.
pub api_key: Option<String>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
/// An OAuth account linked to a user.
#[derive(Debug, Serialize, Deserialize, sqlx::FromRow)]
pub struct OauthAccount {
pub id: Uuid,
pub user_id: Uuid,
pub provider: String,
pub provider_id: String,
pub email: Option<String>,
pub name: Option<String>,
pub avatar_url: Option<String>,
pub created_at: DateTime<Utc>,
}
/// A single audit log row, optionally scoped to a business user.
#[derive(Debug, Serialize, Deserialize, sqlx::FromRow)]
pub struct AuditLogEntry {
pub id: i64,
pub user_id: Option<Uuid>,
pub action: String,
pub folder: String,
#[serde(rename = "type")]
#[sqlx(rename = "type")]
pub entry_type: String,
pub name: String,
pub detail: Value,
pub created_at: DateTime<Utc>,
}
// ── TOML ↔ JSON value conversion ──────────────────────────────────────────────
/// Convert a serde_json Value to a toml Value.
/// `null` values are filtered out (TOML does not support null).
/// Mixed-type arrays are serialised as JSON strings.
pub fn json_to_toml_value(v: &Value) -> anyhow::Result<toml::Value> {
match v {
Value::Null => anyhow::bail!("TOML does not support null values"),
Value::Bool(b) => Ok(toml::Value::Boolean(*b)),
Value::Number(n) => {
if let Some(i) = n.as_i64() {
Ok(toml::Value::Integer(i))
} else if let Some(f) = n.as_f64() {
Ok(toml::Value::Float(f))
} else {
anyhow::bail!("unsupported number: {}", n)
}
}
Value::String(s) => Ok(toml::Value::String(s.clone())),
Value::Array(arr) => {
let items: anyhow::Result<Vec<toml::Value>> =
arr.iter().map(json_to_toml_value).collect();
match items {
Ok(vals) => Ok(toml::Value::Array(vals)),
Err(e) => {
tracing::debug!(error = %e, "mixed-type array; falling back to JSON string");
Ok(toml::Value::String(serde_json::to_string(v)?))
}
}
}
Value::Object(map) => {
let mut toml_map = toml::map::Map::new();
for (k, val) in map {
if val.is_null() {
// Skip null entries
continue;
}
match json_to_toml_value(val) {
Ok(tv) => {
toml_map.insert(k.clone(), tv);
}
Err(e) => {
tracing::debug!(key = %k, error = %e, "field not representable in TOML; falling back to JSON string");
toml_map
.insert(k.clone(), toml::Value::String(serde_json::to_string(val)?));
}
}
}
Ok(toml::Value::Table(toml_map))
}
}
}
/// Convert a toml Value back to a serde_json Value.
pub fn toml_to_json_value(v: &toml::Value) -> Value {
match v {
toml::Value::Boolean(b) => Value::Bool(*b),
toml::Value::Integer(i) => Value::Number((*i).into()),
toml::Value::Float(f) => serde_json::Number::from_f64(*f)
.map(Value::Number)
.unwrap_or(Value::Null),
toml::Value::String(s) => Value::String(s.clone()),
toml::Value::Datetime(dt) => Value::String(dt.to_string()),
toml::Value::Array(arr) => Value::Array(arr.iter().map(toml_to_json_value).collect()),
toml::Value::Table(map) => {
let obj: serde_json::Map<String, Value> = map
.iter()
.map(|(k, v)| (k.clone(), toml_to_json_value(v)))
.collect();
Value::Object(obj)
}
}
}

View File

@@ -0,0 +1,413 @@
use anyhow::Result;
use serde_json::{Map, Value};
use sqlx::PgPool;
use std::fs;
use uuid::Uuid;
use crate::crypto;
use crate::db;
use crate::models::EntryRow;
// ── Key/value parsing helpers ─────────────────────────────────────────────────
pub fn parse_kv(entry: &str) -> Result<(Vec<String>, Value)> {
if let Some((key, json_str)) = entry.split_once(":=") {
let val: Value = serde_json::from_str(json_str).map_err(|e| {
anyhow::anyhow!(
"Invalid JSON value for key '{}': {} (use key=value for plain strings)",
key,
e
)
})?;
return Ok((parse_key_path(key)?, val));
}
if let Some((key, raw_val)) = entry.split_once('=') {
let value = if let Some(path) = raw_val.strip_prefix('@') {
fs::read_to_string(path)
.map_err(|e| anyhow::anyhow!("Failed to read file '{}': {}", path, e))?
} else {
raw_val.to_string()
};
return Ok((parse_key_path(key)?, Value::String(value)));
}
if let Some((key, path)) = entry.split_once('@') {
let value = fs::read_to_string(path)
.map_err(|e| anyhow::anyhow!("Failed to read file '{}': {}", path, e))?;
return Ok((parse_key_path(key)?, Value::String(value)));
}
anyhow::bail!(
"Invalid format '{}'. Expected: key=value, key=@file, nested:key@file, or key:=<json>",
entry
)
}
pub fn build_json(entries: &[String]) -> Result<Value> {
let mut map = Map::new();
for entry in entries {
let (path, value) = parse_kv(entry)?;
insert_path(&mut map, &path, value)?;
}
Ok(Value::Object(map))
}
pub fn key_path_to_string(path: &[String]) -> String {
path.join(":")
}
pub fn collect_key_paths(entries: &[String]) -> Result<Vec<String>> {
entries
.iter()
.map(|entry| parse_kv(entry).map(|(path, _)| key_path_to_string(&path)))
.collect()
}
pub fn collect_field_paths(entries: &[String]) -> Result<Vec<String>> {
entries
.iter()
.map(|entry| parse_key_path(entry).map(|path| key_path_to_string(&path)))
.collect()
}
pub fn parse_key_path(key: &str) -> Result<Vec<String>> {
let path: Vec<String> = key
.split(':')
.map(str::trim)
.map(ToOwned::to_owned)
.collect();
if path.is_empty() || path.iter().any(|part| part.is_empty()) {
anyhow::bail!(
"Invalid key path '{}'. Use non-empty segments like 'credentials:content'.",
key
);
}
Ok(path)
}
pub fn insert_path(map: &mut Map<String, Value>, path: &[String], value: Value) -> Result<()> {
if path.is_empty() {
anyhow::bail!("Key path cannot be empty");
}
if path.len() == 1 {
map.insert(path[0].clone(), value);
return Ok(());
}
let head = path[0].clone();
let tail = &path[1..];
match map.entry(head.clone()) {
serde_json::map::Entry::Vacant(entry) => {
let mut child = Map::new();
insert_path(&mut child, tail, value)?;
entry.insert(Value::Object(child));
}
serde_json::map::Entry::Occupied(mut entry) => match entry.get_mut() {
Value::Object(child) => insert_path(child, tail, value)?,
_ => {
anyhow::bail!(
"Cannot set nested key '{}' because '{}' is already a non-object value",
key_path_to_string(path),
head
);
}
},
}
Ok(())
}
pub fn remove_path(map: &mut Map<String, Value>, path: &[String]) -> Result<bool> {
if path.is_empty() {
anyhow::bail!("Key path cannot be empty");
}
if path.len() == 1 {
return Ok(map.remove(&path[0]).is_some());
}
let Some(value) = map.get_mut(&path[0]) else {
return Ok(false);
};
let Value::Object(child) = value else {
return Ok(false);
};
let removed = remove_path(child, &path[1..])?;
if child.is_empty() {
map.remove(&path[0]);
}
Ok(removed)
}
pub fn flatten_json_fields(prefix: &str, value: &Value) -> Vec<(String, Value)> {
match value {
Value::Object(map) => {
let mut out = Vec::new();
for (k, v) in map {
let full_key = if prefix.is_empty() {
k.clone()
} else {
format!("{}.{}", prefix, k)
};
out.extend(flatten_json_fields(&full_key, v));
}
out
}
other => vec![(prefix.to_string(), other.clone())],
}
}
// ── AddResult ─────────────────────────────────────────────────────────────────
#[derive(Debug, serde::Serialize)]
pub struct AddResult {
pub name: String,
pub folder: String,
#[serde(rename = "type")]
pub entry_type: String,
pub tags: Vec<String>,
pub meta_keys: Vec<String>,
pub secret_keys: Vec<String>,
}
pub struct AddParams<'a> {
pub name: &'a str,
pub folder: &'a str,
pub entry_type: &'a str,
pub notes: &'a str,
pub tags: &'a [String],
pub meta_entries: &'a [String],
pub secret_entries: &'a [String],
/// Optional user_id for multi-user isolation (None = single-user CLI mode)
pub user_id: Option<Uuid>,
}
pub async fn run(pool: &PgPool, params: AddParams<'_>, master_key: &[u8; 32]) -> Result<AddResult> {
let metadata = build_json(params.meta_entries)?;
let secret_json = build_json(params.secret_entries)?;
let meta_keys = collect_key_paths(params.meta_entries)?;
let secret_keys = collect_key_paths(params.secret_entries)?;
let mut tx = pool.begin().await?;
// Fetch existing entry by (user_id, folder, name) — the natural unique key
let existing: Option<EntryRow> = if let Some(uid) = params.user_id {
sqlx::query_as(
"SELECT id, version, folder, type, tags, metadata, notes FROM entries \
WHERE user_id = $1 AND folder = $2 AND name = $3",
)
.bind(uid)
.bind(params.folder)
.bind(params.name)
.fetch_optional(&mut *tx)
.await?
} else {
sqlx::query_as(
"SELECT id, version, folder, type, tags, metadata, notes FROM entries \
WHERE user_id IS NULL AND folder = $1 AND name = $2",
)
.bind(params.folder)
.bind(params.name)
.fetch_optional(&mut *tx)
.await?
};
if let Some(ref ex) = existing
&& let Err(e) = db::snapshot_entry_history(
&mut tx,
db::EntrySnapshotParams {
entry_id: ex.id,
user_id: params.user_id,
folder: params.folder,
entry_type: params.entry_type,
name: params.name,
version: ex.version,
action: "add",
tags: &ex.tags,
metadata: &ex.metadata,
},
)
.await
{
tracing::warn!(error = %e, "failed to snapshot entry history before upsert");
}
let entry_id: Uuid = if let Some(uid) = params.user_id {
sqlx::query_scalar(
r#"INSERT INTO entries (user_id, folder, type, name, notes, tags, metadata, version, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, 1, NOW())
ON CONFLICT (user_id, folder, name) WHERE user_id IS NOT NULL
DO UPDATE SET
folder = EXCLUDED.folder,
type = EXCLUDED.type,
notes = EXCLUDED.notes,
tags = EXCLUDED.tags,
metadata = EXCLUDED.metadata,
version = entries.version + 1,
updated_at = NOW()
RETURNING id"#,
)
.bind(uid)
.bind(params.folder)
.bind(params.entry_type)
.bind(params.name)
.bind(params.notes)
.bind(params.tags)
.bind(&metadata)
.fetch_one(&mut *tx)
.await?
} else {
sqlx::query_scalar(
r#"INSERT INTO entries (folder, type, name, notes, tags, metadata, version, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, 1, NOW())
ON CONFLICT (folder, name) WHERE user_id IS NULL
DO UPDATE SET
folder = EXCLUDED.folder,
type = EXCLUDED.type,
notes = EXCLUDED.notes,
tags = EXCLUDED.tags,
metadata = EXCLUDED.metadata,
version = entries.version + 1,
updated_at = NOW()
RETURNING id"#,
)
.bind(params.folder)
.bind(params.entry_type)
.bind(params.name)
.bind(params.notes)
.bind(params.tags)
.bind(&metadata)
.fetch_one(&mut *tx)
.await?
};
let new_entry_version: i64 = sqlx::query_scalar("SELECT version FROM entries WHERE id = $1")
.bind(entry_id)
.fetch_one(&mut *tx)
.await?;
if existing.is_none()
&& let Err(e) = db::snapshot_entry_history(
&mut tx,
db::EntrySnapshotParams {
entry_id,
user_id: params.user_id,
folder: params.folder,
entry_type: params.entry_type,
name: params.name,
version: new_entry_version,
action: "create",
tags: params.tags,
metadata: &metadata,
},
)
.await
{
tracing::warn!(error = %e, "failed to snapshot entry history on create");
}
if existing.is_some() {
#[derive(sqlx::FromRow)]
struct ExistingField {
id: Uuid,
field_name: String,
encrypted: Vec<u8>,
}
let existing_fields: Vec<ExistingField> =
sqlx::query_as("SELECT id, field_name, encrypted FROM secrets WHERE entry_id = $1")
.bind(entry_id)
.fetch_all(&mut *tx)
.await?;
for f in &existing_fields {
if let Err(e) = db::snapshot_secret_history(
&mut tx,
db::SecretSnapshotParams {
entry_id,
secret_id: f.id,
entry_version: new_entry_version - 1,
field_name: &f.field_name,
encrypted: &f.encrypted,
action: "add",
},
)
.await
{
tracing::warn!(error = %e, "failed to snapshot secret field history");
}
}
sqlx::query("DELETE FROM secrets WHERE entry_id = $1")
.bind(entry_id)
.execute(&mut *tx)
.await?;
}
let flat_fields = flatten_json_fields("", &secret_json);
for (field_name, field_value) in &flat_fields {
let encrypted = crypto::encrypt_json(master_key, field_value)?;
sqlx::query("INSERT INTO secrets (entry_id, field_name, encrypted) VALUES ($1, $2, $3)")
.bind(entry_id)
.bind(field_name)
.bind(&encrypted)
.execute(&mut *tx)
.await?;
}
crate::audit::log_tx(
&mut tx,
params.user_id,
"add",
params.folder,
params.entry_type,
params.name,
serde_json::json!({
"tags": params.tags,
"meta_keys": meta_keys,
"secret_keys": secret_keys,
}),
)
.await;
tx.commit().await?;
Ok(AddResult {
name: params.name.to_string(),
folder: params.folder.to_string(),
entry_type: params.entry_type.to_string(),
tags: params.tags.to_vec(),
meta_keys,
secret_keys,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_nested_file_shorthand() {
use std::io::Write;
let mut f = tempfile::NamedTempFile::new().unwrap();
writeln!(f, "line1\nline2").unwrap();
let path = f.path().to_str().unwrap().to_string();
let entry = format!("credentials:content@{}", path);
let (path_parts, value) = parse_kv(&entry).unwrap();
assert_eq!(key_path_to_string(&path_parts), "credentials:content");
assert!(matches!(value, Value::String(_)));
}
#[test]
fn flatten_json_fields_nested() {
let v = serde_json::json!({
"username": "root",
"credentials": {
"type": "ssh",
"content": "pem"
}
});
let mut fields = flatten_json_fields("", &v);
fields.sort_by(|a, b| a.0.cmp(&b.0));
assert_eq!(fields[0].0, "credentials.content");
assert_eq!(fields[1].0, "credentials.type");
assert_eq!(fields[2].0, "username");
}
}

View File

@@ -0,0 +1,55 @@
use anyhow::Result;
use sqlx::PgPool;
use uuid::Uuid;
const KEY_PREFIX: &str = "sk_";
/// Generate a new API key: `sk_<64 hex chars>` = 67 characters total.
pub fn generate_api_key() -> String {
use rand::RngExt;
let mut bytes = [0u8; 32];
rand::rng().fill(&mut bytes);
let hex: String = bytes.iter().map(|b| format!("{:02x}", b)).collect();
format!("{}{}", KEY_PREFIX, hex)
}
/// Return the user's existing API key, or generate and store a new one if NULL.
pub async fn ensure_api_key(pool: &PgPool, user_id: Uuid) -> Result<String> {
let existing: Option<(Option<String>,)> =
sqlx::query_as("SELECT api_key FROM users WHERE id = $1")
.bind(user_id)
.fetch_optional(pool)
.await?;
if let Some((Some(key),)) = existing {
return Ok(key);
}
let new_key = generate_api_key();
sqlx::query("UPDATE users SET api_key = $1 WHERE id = $2")
.bind(&new_key)
.bind(user_id)
.execute(pool)
.await?;
Ok(new_key)
}
/// Generate a fresh API key for the user, replacing the old one.
pub async fn regenerate_api_key(pool: &PgPool, user_id: Uuid) -> Result<String> {
let new_key = generate_api_key();
sqlx::query("UPDATE users SET api_key = $1 WHERE id = $2")
.bind(&new_key)
.bind(user_id)
.execute(pool)
.await?;
Ok(new_key)
}
/// Validate a Bearer token. Returns the `user_id` if the key matches.
pub async fn validate_api_key(pool: &PgPool, raw_key: &str) -> Result<Option<Uuid>> {
let row: Option<(Uuid,)> = sqlx::query_as("SELECT id FROM users WHERE api_key = $1")
.bind(raw_key)
.fetch_optional(pool)
.await?;
Ok(row.map(|(id,)| id))
}

View File

@@ -0,0 +1,23 @@
use anyhow::Result;
use sqlx::PgPool;
use uuid::Uuid;
use crate::models::AuditLogEntry;
pub async fn list_for_user(pool: &PgPool, user_id: Uuid, limit: i64) -> Result<Vec<AuditLogEntry>> {
let limit = limit.clamp(1, 200);
let rows = sqlx::query_as(
"SELECT id, user_id, action, folder, type, name, detail, created_at \
FROM audit_log \
WHERE user_id = $1 \
ORDER BY created_at DESC, id DESC \
LIMIT $2",
)
.bind(user_id)
.bind(limit)
.fetch_all(pool)
.await?;
Ok(rows)
}

View File

@@ -0,0 +1,909 @@
use anyhow::Result;
use serde_json::json;
use sqlx::PgPool;
use uuid::Uuid;
use crate::db;
use crate::models::{EntryRow, EntryWriteRow, SecretFieldRow};
#[derive(Debug, serde::Serialize)]
pub struct DeletedEntry {
pub name: String,
pub folder: String,
#[serde(rename = "type")]
pub entry_type: String,
}
#[derive(Debug, serde::Serialize)]
pub struct DeleteResult {
pub deleted: Vec<DeletedEntry>,
pub migrated: Vec<String>,
pub dry_run: bool,
}
pub struct DeleteParams<'a> {
/// If set, delete a single entry by name.
pub name: Option<&'a str>,
/// Folder filter for bulk delete.
pub folder: Option<&'a str>,
/// Type filter for bulk delete.
pub entry_type: Option<&'a str>,
pub dry_run: bool,
pub user_id: Option<Uuid>,
}
#[derive(Debug, sqlx::FromRow)]
struct KeyReferrer {
id: Uuid,
folder: String,
#[sqlx(rename = "type")]
entry_type: String,
name: String,
}
fn ref_label(r: &KeyReferrer) -> String {
format!("{}/{} ({})", r.folder, r.name, r.entry_type)
}
fn ref_path(r: &KeyReferrer) -> String {
format!("{}/{}", r.folder, r.name)
}
async fn fetch_key_referrers_pool(
pool: &PgPool,
key_entry_id: Uuid,
key_folder: &str,
key_name: &str,
user_id: Option<Uuid>,
) -> Result<Vec<KeyReferrer>> {
let qualified = format!("{}/{}", key_folder, key_name);
let refs: Vec<KeyReferrer> = if let Some(uid) = user_id {
sqlx::query_as(
"SELECT id, folder, type, name FROM entries \
WHERE user_id = $1 AND id <> $2 \
AND (metadata->>'key_ref' = $3 OR metadata->>'key_ref' = $4) \
ORDER BY folder, type, name",
)
.bind(uid)
.bind(key_entry_id)
.bind(key_name)
.bind(&qualified)
.fetch_all(pool)
.await?
} else {
sqlx::query_as(
"SELECT id, folder, type, name FROM entries \
WHERE user_id IS NULL AND id <> $1 \
AND (metadata->>'key_ref' = $2 OR metadata->>'key_ref' = $3) \
ORDER BY folder, type, name",
)
.bind(key_entry_id)
.bind(key_name)
.bind(&qualified)
.fetch_all(pool)
.await?
};
Ok(refs)
}
async fn migrate_key_refs_if_needed(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
key_row: &EntryRow,
key_name: &str,
user_id: Option<Uuid>,
dry_run: bool,
) -> Result<Vec<String>> {
let qualified = format!("{}/{}", key_row.folder, key_name);
let refs: Vec<KeyReferrer> = if let Some(uid) = user_id {
sqlx::query_as(
"SELECT id, folder, type, name FROM entries \
WHERE user_id = $1 AND id <> $2 \
AND (metadata->>'key_ref' = $3 OR metadata->>'key_ref' = $4) \
ORDER BY folder, type, name",
)
.bind(uid)
.bind(key_row.id)
.bind(key_name)
.bind(&qualified)
.fetch_all(&mut **tx)
.await?
} else {
sqlx::query_as(
"SELECT id, folder, type, name FROM entries \
WHERE user_id IS NULL AND id <> $1 \
AND (metadata->>'key_ref' = $2 OR metadata->>'key_ref' = $3) \
ORDER BY folder, type, name",
)
.bind(key_row.id)
.bind(key_name)
.bind(&qualified)
.fetch_all(&mut **tx)
.await?
};
if refs.is_empty() {
return Ok(vec![]);
}
if dry_run {
return Ok(refs.iter().map(ref_label).collect());
}
let owner = &refs[0];
let owner_path = ref_path(owner);
let key_fields: Vec<SecretFieldRow> =
sqlx::query_as("SELECT id, field_name, encrypted FROM secrets WHERE entry_id = $1")
.bind(key_row.id)
.fetch_all(&mut **tx)
.await?;
for f in &key_fields {
sqlx::query(
"INSERT INTO secrets (entry_id, field_name, encrypted) VALUES ($1, $2, $3) \
ON CONFLICT (entry_id, field_name) DO NOTHING",
)
.bind(owner.id)
.bind(&f.field_name)
.bind(&f.encrypted)
.execute(&mut **tx)
.await?;
}
sqlx::query(
"UPDATE entries SET metadata = metadata - 'key_ref', \
version = version + 1, updated_at = NOW() WHERE id = $1",
)
.bind(owner.id)
.execute(&mut **tx)
.await?;
crate::audit::log_tx(
tx,
user_id,
"key_migrate",
&owner.folder,
&owner.entry_type,
&owner.name,
json!({
"from_key": format!("{}/{}", key_row.folder, key_name),
"role": "new_owner",
"redirect_target": owner_path,
}),
)
.await;
for r in refs.iter().skip(1) {
sqlx::query(
"UPDATE entries SET metadata = jsonb_set(metadata, '{key_ref}', to_jsonb($2::text), true), \
version = version + 1, updated_at = NOW() WHERE id = $1",
)
.bind(r.id)
.bind(&owner_path)
.execute(&mut **tx)
.await?;
crate::audit::log_tx(
tx,
user_id,
"key_migrate",
&r.folder,
&r.entry_type,
&r.name,
json!({
"from_key": format!("{}/{}", key_row.folder, key_name),
"role": "redirected_ref",
"redirect_to": owner_path,
}),
)
.await;
}
Ok(refs.iter().map(ref_label).collect())
}
/// Delete a single entry by id (multi-tenant: `user_id` must match). Cascades `secrets` via FK.
pub async fn delete_by_id(pool: &PgPool, entry_id: Uuid, user_id: Uuid) -> Result<DeleteResult> {
let mut tx = pool.begin().await?;
let row: Option<EntryWriteRow> = sqlx::query_as(
"SELECT id, version, folder, type, name, tags, metadata, notes FROM entries \
WHERE id = $1 AND user_id = $2 FOR UPDATE",
)
.bind(entry_id)
.bind(user_id)
.fetch_optional(&mut *tx)
.await?;
let row = match row {
Some(r) => r,
None => {
tx.rollback().await?;
anyhow::bail!("Entry not found");
}
};
let folder = row.folder.clone();
let entry_type = row.entry_type.clone();
let name = row.name.clone();
let entry_row: EntryRow = (&row).into();
let migrated =
migrate_key_refs_if_needed(&mut tx, &entry_row, &name, Some(user_id), false).await?;
snapshot_and_delete(
&mut tx,
&folder,
&entry_type,
&name,
&entry_row,
Some(user_id),
)
.await?;
crate::audit::log_tx(
&mut tx,
Some(user_id),
"delete",
&folder,
&entry_type,
&name,
json!({ "source": "web", "entry_id": entry_id }),
)
.await;
tx.commit().await?;
Ok(DeleteResult {
deleted: vec![DeletedEntry {
name,
folder,
entry_type,
}],
migrated,
dry_run: false,
})
}
pub async fn run(pool: &PgPool, params: DeleteParams<'_>) -> Result<DeleteResult> {
match params.name {
Some(name) => delete_one(pool, name, params.folder, params.dry_run, params.user_id).await,
None => {
if params.folder.is_none() && params.entry_type.is_none() {
anyhow::bail!(
"Bulk delete requires at least one of: name, folder, or type filter."
);
}
delete_bulk(
pool,
params.folder,
params.entry_type,
params.dry_run,
params.user_id,
)
.await
}
}
}
async fn delete_one(
pool: &PgPool,
name: &str,
folder: Option<&str>,
dry_run: bool,
user_id: Option<Uuid>,
) -> Result<DeleteResult> {
if dry_run {
// Dry-run uses the same disambiguation logic as actual delete:
// - 0 matches → nothing to delete
// - 1 match → show what would be deleted (with correct folder/type)
// - 2+ matches → disambiguation error (same as non-dry-run)
#[derive(sqlx::FromRow)]
struct DryRunRow {
id: Uuid,
folder: String,
#[sqlx(rename = "type")]
entry_type: String,
}
let rows: Vec<DryRunRow> = if let Some(uid) = user_id {
if let Some(f) = folder {
sqlx::query_as(
"SELECT id, folder, type FROM entries WHERE user_id = $1 AND folder = $2 AND name = $3",
)
.bind(uid)
.bind(f)
.bind(name)
.fetch_all(pool)
.await?
} else {
sqlx::query_as(
"SELECT id, folder, type FROM entries WHERE user_id = $1 AND name = $2",
)
.bind(uid)
.bind(name)
.fetch_all(pool)
.await?
}
} else if let Some(f) = folder {
sqlx::query_as(
"SELECT id, folder, type FROM entries WHERE user_id IS NULL AND folder = $1 AND name = $2",
)
.bind(f)
.bind(name)
.fetch_all(pool)
.await?
} else {
sqlx::query_as(
"SELECT id, folder, type FROM entries WHERE user_id IS NULL AND name = $1",
)
.bind(name)
.fetch_all(pool)
.await?
};
return match rows.len() {
0 => Ok(DeleteResult {
deleted: vec![],
migrated: vec![],
dry_run: true,
}),
1 => {
let row = rows.into_iter().next().unwrap();
let refs =
fetch_key_referrers_pool(pool, row.id, &row.folder, name, user_id).await?;
Ok(DeleteResult {
deleted: vec![DeletedEntry {
name: name.to_string(),
folder: row.folder,
entry_type: row.entry_type,
}],
migrated: refs.iter().map(ref_label).collect(),
dry_run: true,
})
}
_ => {
let folders: Vec<&str> = rows.iter().map(|r| r.folder.as_str()).collect();
anyhow::bail!(
"Ambiguous: {} entries named '{}' found in folders: [{}]. \
Specify 'folder' to disambiguate.",
rows.len(),
name,
folders.join(", ")
)
}
};
}
let mut tx = pool.begin().await?;
// Fetch matching rows with FOR UPDATE; use folder when provided to resolve ambiguity.
let rows: Vec<EntryRow> = if let Some(uid) = user_id {
if let Some(f) = folder {
sqlx::query_as(
"SELECT id, version, folder, type, tags, metadata, notes FROM entries \
WHERE user_id = $1 AND folder = $2 AND name = $3 FOR UPDATE",
)
.bind(uid)
.bind(f)
.bind(name)
.fetch_all(&mut *tx)
.await?
} else {
sqlx::query_as(
"SELECT id, version, folder, type, tags, metadata, notes FROM entries \
WHERE user_id = $1 AND name = $2 FOR UPDATE",
)
.bind(uid)
.bind(name)
.fetch_all(&mut *tx)
.await?
}
} else if let Some(f) = folder {
sqlx::query_as(
"SELECT id, version, folder, type, tags, metadata, notes FROM entries \
WHERE user_id IS NULL AND folder = $1 AND name = $2 FOR UPDATE",
)
.bind(f)
.bind(name)
.fetch_all(&mut *tx)
.await?
} else {
sqlx::query_as(
"SELECT id, version, folder, type, tags, metadata, notes FROM entries \
WHERE user_id IS NULL AND name = $1 FOR UPDATE",
)
.bind(name)
.fetch_all(&mut *tx)
.await?
};
let row = match rows.len() {
0 => {
tx.rollback().await?;
return Ok(DeleteResult {
deleted: vec![],
migrated: vec![],
dry_run: false,
});
}
1 => rows.into_iter().next().unwrap(),
_ => {
tx.rollback().await?;
let folders: Vec<&str> = rows.iter().map(|r| r.folder.as_str()).collect();
anyhow::bail!(
"Ambiguous: {} entries named '{}' found in folders: [{}]. \
Specify 'folder' to disambiguate.",
rows.len(),
name,
folders.join(", ")
)
}
};
let folder = row.folder.clone();
let entry_type = row.entry_type.clone();
let migrated = migrate_key_refs_if_needed(&mut tx, &row, name, user_id, false).await?;
snapshot_and_delete(&mut tx, &folder, &entry_type, name, &row, user_id).await?;
crate::audit::log_tx(
&mut tx,
user_id,
"delete",
&folder,
&entry_type,
name,
json!({}),
)
.await;
tx.commit().await?;
Ok(DeleteResult {
deleted: vec![DeletedEntry {
name: name.to_string(),
folder,
entry_type,
}],
migrated,
dry_run: false,
})
}
async fn delete_bulk(
pool: &PgPool,
folder: Option<&str>,
entry_type: Option<&str>,
dry_run: bool,
user_id: Option<Uuid>,
) -> Result<DeleteResult> {
#[derive(Debug, sqlx::FromRow)]
struct FullEntryRow {
id: Uuid,
version: i64,
folder: String,
#[sqlx(rename = "type")]
entry_type: String,
name: String,
metadata: serde_json::Value,
tags: Vec<String>,
notes: String,
}
let mut conditions: Vec<String> = Vec::new();
let mut idx: i32 = 1;
if user_id.is_some() {
conditions.push(format!("user_id = ${}", idx));
idx += 1;
} else {
conditions.push("user_id IS NULL".to_string());
}
if folder.is_some() {
conditions.push(format!("folder = ${}", idx));
idx += 1;
}
if entry_type.is_some() {
conditions.push(format!("type = ${}", idx));
}
let where_clause = format!("WHERE {}", conditions.join(" AND "));
let sql = format!(
"SELECT id, version, folder, type, name, metadata, tags, notes \
FROM entries {where_clause} ORDER BY type, name"
);
let mut q = sqlx::query_as::<_, FullEntryRow>(&sql);
if let Some(uid) = user_id {
q = q.bind(uid);
}
if let Some(f) = folder {
q = q.bind(f);
}
if let Some(t) = entry_type {
q = q.bind(t);
}
let rows = q.fetch_all(pool).await?;
if dry_run {
let mut migrated: Vec<String> = Vec::new();
for row in &rows {
let refs =
fetch_key_referrers_pool(pool, row.id, &row.folder, &row.name, user_id).await?;
migrated.extend(refs.iter().map(ref_label));
}
let deleted = rows
.iter()
.map(|r| DeletedEntry {
name: r.name.clone(),
folder: r.folder.clone(),
entry_type: r.entry_type.clone(),
})
.collect();
return Ok(DeleteResult {
deleted,
migrated,
dry_run: true,
});
}
let mut deleted = Vec::with_capacity(rows.len());
let mut migrated: Vec<String> = Vec::new();
for row in &rows {
let entry_row = EntryRow {
id: row.id,
version: row.version,
folder: row.folder.clone(),
entry_type: row.entry_type.clone(),
tags: row.tags.clone(),
metadata: row.metadata.clone(),
notes: row.notes.clone(),
};
let mut tx = pool.begin().await?;
let m = migrate_key_refs_if_needed(&mut tx, &entry_row, &row.name, user_id, false).await?;
migrated.extend(m);
snapshot_and_delete(
&mut tx,
&row.folder,
&row.entry_type,
&row.name,
&entry_row,
user_id,
)
.await?;
crate::audit::log_tx(
&mut tx,
user_id,
"delete",
&row.folder,
&row.entry_type,
&row.name,
json!({"bulk": true}),
)
.await;
tx.commit().await?;
deleted.push(DeletedEntry {
name: row.name.clone(),
folder: row.folder.clone(),
entry_type: row.entry_type.clone(),
});
}
Ok(DeleteResult {
deleted,
migrated,
dry_run: false,
})
}
async fn snapshot_and_delete(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
folder: &str,
entry_type: &str,
name: &str,
row: &EntryRow,
user_id: Option<Uuid>,
) -> Result<()> {
if let Err(e) = db::snapshot_entry_history(
tx,
db::EntrySnapshotParams {
entry_id: row.id,
user_id,
folder,
entry_type,
name,
version: row.version,
action: "delete",
tags: &row.tags,
metadata: &row.metadata,
},
)
.await
{
tracing::warn!(error = %e, "failed to snapshot entry history before delete");
}
let fields: Vec<SecretFieldRow> =
sqlx::query_as("SELECT id, field_name, encrypted FROM secrets WHERE entry_id = $1")
.bind(row.id)
.fetch_all(&mut **tx)
.await?;
for f in &fields {
if let Err(e) = db::snapshot_secret_history(
tx,
db::SecretSnapshotParams {
entry_id: row.id,
secret_id: f.id,
entry_version: row.version,
field_name: &f.field_name,
encrypted: &f.encrypted,
action: "delete",
},
)
.await
{
tracing::warn!(error = %e, "failed to snapshot secret history before delete");
}
}
sqlx::query("DELETE FROM entries WHERE id = $1")
.bind(row.id)
.execute(&mut **tx)
.await?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
async fn maybe_test_pool() -> Option<PgPool> {
let Ok(url) = std::env::var("SECRETS_DATABASE_URL") else {
eprintln!("skip delete migration tests: SECRETS_DATABASE_URL is not set");
return None;
};
let Ok(pool) = PgPool::connect(&url).await else {
eprintln!("skip delete migration tests: cannot connect to database");
return None;
};
if let Err(e) = crate::db::migrate(&pool).await {
eprintln!("skip delete migration tests: migrate failed: {e}");
return None;
}
Some(pool)
}
async fn insert_entry(
pool: &PgPool,
id: Uuid,
user_id: Uuid,
folder: &str,
entry_type: &str,
name: &str,
metadata: serde_json::Value,
) -> Result<()> {
sqlx::query(
"INSERT INTO entries (id, user_id, folder, type, name, notes, tags, metadata, version) \
VALUES ($1, $2, $3, $4, $5, '', ARRAY[]::text[], $6, 1)",
)
.bind(id)
.bind(user_id)
.bind(folder)
.bind(entry_type)
.bind(name)
.bind(metadata)
.execute(pool)
.await?;
Ok(())
}
#[tokio::test]
async fn delete_shared_key_dry_run_reports_migration_without_writes() -> Result<()> {
let Some(pool) = maybe_test_pool().await else {
return Ok(());
};
let user_id = Uuid::from_u128(rand::random());
let key_id = Uuid::from_u128(rand::random());
let ref_a = Uuid::from_u128(rand::random());
let ref_b = Uuid::from_u128(rand::random());
insert_entry(
&pool,
key_id,
user_id,
"kfolder",
"key",
"shared-key",
json!({}),
)
.await?;
sqlx::query("INSERT INTO secrets (entry_id, field_name, encrypted) VALUES ($1, $2, $3)")
.bind(key_id)
.bind("pem")
.bind(vec![1_u8, 2, 3])
.execute(&pool)
.await?;
insert_entry(
&pool,
ref_a,
user_id,
"afolder",
"server",
"srv-a",
json!({"key_ref":"kfolder/shared-key"}),
)
.await?;
insert_entry(
&pool,
ref_b,
user_id,
"bfolder",
"server",
"srv-b",
json!({"key_ref":"shared-key"}),
)
.await?;
let result = run(
&pool,
DeleteParams {
name: Some("shared-key"),
folder: Some("kfolder"),
entry_type: None,
dry_run: true,
user_id: Some(user_id),
},
)
.await?;
assert!(result.dry_run);
assert_eq!(result.deleted.len(), 1);
assert_eq!(result.migrated.len(), 2);
let key_exists: bool = sqlx::query_scalar(
"SELECT EXISTS(SELECT 1 FROM entries WHERE id = $1 AND user_id = $2)",
)
.bind(key_id)
.bind(user_id)
.fetch_one(&pool)
.await?;
assert!(key_exists);
let ref_a_key_ref: Option<String> =
sqlx::query_scalar("SELECT metadata->>'key_ref' FROM entries WHERE id = $1")
.bind(ref_a)
.fetch_one(&pool)
.await?;
let ref_b_key_ref: Option<String> =
sqlx::query_scalar("SELECT metadata->>'key_ref' FROM entries WHERE id = $1")
.bind(ref_b)
.fetch_one(&pool)
.await?;
assert_eq!(ref_a_key_ref.as_deref(), Some("kfolder/shared-key"));
assert_eq!(ref_b_key_ref.as_deref(), Some("shared-key"));
sqlx::query("DELETE FROM entries WHERE user_id = $1")
.bind(user_id)
.execute(&pool)
.await?;
Ok(())
}
#[tokio::test]
async fn delete_shared_key_auto_migrates_single_copy_and_redirects_refs() -> Result<()> {
let Some(pool) = maybe_test_pool().await else {
return Ok(());
};
let user_id = Uuid::from_u128(rand::random());
let key_id = Uuid::from_u128(rand::random());
let ref_a = Uuid::from_u128(rand::random());
let ref_b = Uuid::from_u128(rand::random());
let ref_c = Uuid::from_u128(rand::random());
insert_entry(
&pool,
key_id,
user_id,
"kfolder",
"key",
"shared-key",
json!({}),
)
.await?;
sqlx::query("INSERT INTO secrets (entry_id, field_name, encrypted) VALUES ($1, $2, $3)")
.bind(key_id)
.bind("pem")
.bind(vec![7_u8, 8, 9])
.execute(&pool)
.await?;
// owner candidate (sorted first by folder)
insert_entry(
&pool,
ref_a,
user_id,
"afolder",
"server",
"srv-a",
json!({"key_ref":"kfolder/shared-key"}),
)
.await?;
insert_entry(
&pool,
ref_b,
user_id,
"bfolder",
"server",
"srv-b",
json!({"key_ref":"shared-key"}),
)
.await?;
insert_entry(
&pool,
ref_c,
user_id,
"cfolder",
"service",
"svc-c",
json!({"key_ref":"kfolder/shared-key"}),
)
.await?;
let result = run(
&pool,
DeleteParams {
name: Some("shared-key"),
folder: Some("kfolder"),
entry_type: None,
dry_run: false,
user_id: Some(user_id),
},
)
.await?;
assert!(!result.dry_run);
assert_eq!(result.deleted.len(), 1);
assert_eq!(result.migrated.len(), 3);
let key_exists: bool = sqlx::query_scalar(
"SELECT EXISTS(SELECT 1 FROM entries WHERE id = $1 AND user_id = $2)",
)
.bind(key_id)
.bind(user_id)
.fetch_one(&pool)
.await?;
assert!(!key_exists);
let owner_key_ref: Option<String> =
sqlx::query_scalar("SELECT metadata->>'key_ref' FROM entries WHERE id = $1")
.bind(ref_a)
.fetch_one(&pool)
.await?;
let ref_b_key_ref: Option<String> =
sqlx::query_scalar("SELECT metadata->>'key_ref' FROM entries WHERE id = $1")
.bind(ref_b)
.fetch_one(&pool)
.await?;
let ref_c_key_ref: Option<String> =
sqlx::query_scalar("SELECT metadata->>'key_ref' FROM entries WHERE id = $1")
.bind(ref_c)
.fetch_one(&pool)
.await?;
assert_eq!(owner_key_ref, None);
assert_eq!(ref_b_key_ref.as_deref(), Some("afolder/srv-a"));
assert_eq!(ref_c_key_ref.as_deref(), Some("afolder/srv-a"));
let owner_has_copied: bool = sqlx::query_scalar(
"SELECT EXISTS(SELECT 1 FROM secrets WHERE entry_id = $1 AND field_name = 'pem')",
)
.bind(ref_a)
.fetch_one(&pool)
.await?;
assert!(owner_has_copied);
sqlx::query("DELETE FROM entries WHERE user_id = $1")
.bind(user_id)
.execute(&pool)
.await?;
Ok(())
}
}

View File

@@ -0,0 +1,129 @@
use anyhow::Result;
use serde_json::Value;
use sqlx::PgPool;
use std::collections::HashMap;
use uuid::Uuid;
use crate::crypto;
use crate::models::Entry;
use crate::service::search::{fetch_entries, fetch_secrets_for_entries};
/// Build an env variable map from entry secrets (for dry-run preview or injection).
#[allow(clippy::too_many_arguments)]
pub async fn build_env_map(
pool: &PgPool,
folder: Option<&str>,
entry_type: Option<&str>,
name: Option<&str>,
tags: &[String],
only_fields: &[String],
prefix: &str,
master_key: &[u8; 32],
user_id: Option<Uuid>,
) -> Result<HashMap<String, String>> {
let entries = fetch_entries(pool, folder, entry_type, name, tags, None, user_id).await?;
let mut combined: HashMap<String, String> = HashMap::new();
for entry in &entries {
let entry_map =
build_entry_env_map(pool, entry, only_fields, prefix, master_key, user_id).await?;
combined.extend(entry_map);
}
Ok(combined)
}
async fn build_entry_env_map(
pool: &PgPool,
entry: &Entry,
only_fields: &[String],
prefix: &str,
master_key: &[u8; 32],
user_id: Option<Uuid>,
) -> Result<HashMap<String, String>> {
let entry_ids = vec![entry.id];
let secrets_map = fetch_secrets_for_entries(pool, &entry_ids).await?;
let all_fields = secrets_map.get(&entry.id).map(Vec::as_slice).unwrap_or(&[]);
let fields: Vec<_> = if only_fields.is_empty() {
all_fields.iter().collect()
} else {
all_fields
.iter()
.filter(|f| only_fields.contains(&f.field_name))
.collect()
};
let effective_prefix = env_prefix(entry, prefix);
let mut map = HashMap::new();
for f in fields {
let decrypted = crypto::decrypt_json(master_key, &f.encrypted)?;
let key = format!(
"{}_{}",
effective_prefix,
f.field_name.to_uppercase().replace(['-', '.'], "_")
);
map.insert(key, json_to_env_string(&decrypted));
}
// Resolve key_ref. Supported formats: "name" or "folder/name".
if let Some(key_ref) = entry.metadata.get("key_ref").and_then(|v| v.as_str()) {
let (ref_folder, ref_name) = if let Some((f, n)) = key_ref.split_once('/') {
(Some(f), n)
} else {
(None, key_ref)
};
let key_entries =
fetch_entries(pool, ref_folder, None, Some(ref_name), &[], None, user_id).await?;
if key_entries.len() > 1 {
anyhow::bail!(
"key_ref '{}' matched {} entries; qualify with folder/name to resolve the ambiguity",
key_ref,
key_entries.len()
);
}
if let Some(key_entry) = key_entries.first() {
let key_ids = vec![key_entry.id];
let key_fields_map = fetch_secrets_for_entries(pool, &key_ids).await?;
let empty = vec![];
let key_fields = key_fields_map.get(&key_entry.id).unwrap_or(&empty);
let key_prefix = env_prefix(key_entry, prefix);
for f in key_fields {
let decrypted = crypto::decrypt_json(master_key, &f.encrypted)?;
let key_var = format!(
"{}_{}",
key_prefix,
f.field_name.to_uppercase().replace(['-', '.'], "_")
);
map.insert(key_var, json_to_env_string(&decrypted));
}
} else {
tracing::warn!(key_ref, ?user_id, "key_ref target not found");
}
}
Ok(map)
}
fn env_prefix(entry: &Entry, prefix: &str) -> String {
let name_part = entry.name.to_uppercase().replace(['-', '.', ' '], "_");
if prefix.is_empty() {
name_part
} else {
let normalized = prefix.to_uppercase().replace(['-', '.', ' '], "_");
let normalized = normalized.trim_end_matches('_');
format!("{}_{}", normalized, name_part)
}
}
fn json_to_env_string(v: &Value) -> String {
match v {
Value::String(s) => s.clone(),
Value::Null => String::new(),
other => other.to_string(),
}
}

View File

@@ -0,0 +1,140 @@
use anyhow::Result;
use serde_json::Value;
use sqlx::PgPool;
use std::collections::{BTreeMap, HashMap};
use uuid::Uuid;
use crate::crypto;
use crate::models::{ExportData, ExportEntry, ExportFormat};
use crate::service::search::{fetch_entries, fetch_secrets_for_entries};
pub struct ExportParams<'a> {
pub folder: Option<&'a str>,
pub entry_type: Option<&'a str>,
pub name: Option<&'a str>,
pub tags: &'a [String],
pub query: Option<&'a str>,
pub no_secrets: bool,
pub user_id: Option<Uuid>,
}
pub async fn export(
pool: &PgPool,
params: ExportParams<'_>,
master_key: Option<&[u8; 32]>,
) -> Result<ExportData> {
let entries = fetch_entries(
pool,
params.folder,
params.entry_type,
params.name,
params.tags,
params.query,
params.user_id,
)
.await?;
let entry_ids: Vec<Uuid> = entries.iter().map(|e| e.id).collect();
let secrets_map: HashMap<Uuid, Vec<_>> = if !params.no_secrets && !entry_ids.is_empty() {
fetch_secrets_for_entries(pool, &entry_ids).await?
} else {
HashMap::new()
};
let mut export_entries: Vec<ExportEntry> = Vec::with_capacity(entries.len());
for entry in &entries {
let secrets = if params.no_secrets {
None
} else {
let fields = secrets_map.get(&entry.id).map(Vec::as_slice).unwrap_or(&[]);
if fields.is_empty() {
Some(BTreeMap::new())
} else {
let mk = master_key
.ok_or_else(|| anyhow::anyhow!("master key required to decrypt secrets"))?;
let mut map = BTreeMap::new();
for f in fields {
let decrypted = crypto::decrypt_json(mk, &f.encrypted)?;
map.insert(f.field_name.clone(), decrypted);
}
Some(map)
}
};
export_entries.push(ExportEntry {
name: entry.name.clone(),
folder: entry.folder.clone(),
entry_type: entry.entry_type.clone(),
notes: entry.notes.clone(),
tags: entry.tags.clone(),
metadata: entry.metadata.clone(),
secrets,
});
}
Ok(ExportData {
version: 1,
exported_at: chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string(),
entries: export_entries,
})
}
pub async fn export_to_file(
pool: &PgPool,
params: ExportParams<'_>,
master_key: Option<&[u8; 32]>,
file_path: &str,
format_override: Option<&str>,
) -> Result<usize> {
let format = if let Some(f) = format_override {
f.parse::<ExportFormat>()?
} else {
ExportFormat::from_extension(file_path).unwrap_or(ExportFormat::Json)
};
let data = export(pool, params, master_key).await?;
let count = data.entries.len();
let serialized = format.serialize(&data)?;
std::fs::write(file_path, &serialized)?;
Ok(count)
}
pub async fn export_to_string(
pool: &PgPool,
params: ExportParams<'_>,
master_key: Option<&[u8; 32]>,
format: &str,
) -> Result<String> {
let fmt = format.parse::<ExportFormat>()?;
let data = export(pool, params, master_key).await?;
fmt.serialize(&data)
}
// ── Build helpers for re-encoding values as CLI-style entries ─────────────────
pub fn build_meta_entries(metadata: &Value) -> Vec<String> {
let mut entries = Vec::new();
if let Some(obj) = metadata.as_object() {
for (k, v) in obj {
entries.push(value_to_kv_entry(k, v));
}
}
entries
}
pub fn build_secret_entries(secrets: Option<&BTreeMap<String, Value>>) -> Vec<String> {
let mut entries = Vec::new();
if let Some(map) = secrets {
for (k, v) in map {
entries.push(value_to_kv_entry(k, v));
}
}
entries
}
pub fn value_to_kv_entry(key: &str, value: &Value) -> String {
match value {
Value::String(s) => format!("{}={}", key, s),
other => format!("{}:={}", key, other),
}
}

View File

@@ -0,0 +1,104 @@
use anyhow::Result;
use serde_json::Value;
use sqlx::PgPool;
use std::collections::HashMap;
use uuid::Uuid;
use crate::crypto;
use crate::service::search::{fetch_secrets_for_entries, resolve_entry, resolve_entry_by_id};
/// Decrypt a single named field from an entry.
/// `folder` is optional; if omitted and multiple entries share the name, an error is returned.
pub async fn get_secret_field(
pool: &PgPool,
name: &str,
folder: Option<&str>,
field_name: &str,
master_key: &[u8; 32],
user_id: Option<Uuid>,
) -> Result<Value> {
let entry = resolve_entry(pool, name, folder, user_id).await?;
let entry_ids = vec![entry.id];
let secrets_map = fetch_secrets_for_entries(pool, &entry_ids).await?;
let fields = secrets_map.get(&entry.id).map(Vec::as_slice).unwrap_or(&[]);
let field = fields
.iter()
.find(|f| f.field_name == field_name)
.ok_or_else(|| anyhow::anyhow!("Secret field '{}' not found", field_name))?;
crypto::decrypt_json(master_key, &field.encrypted)
}
/// Decrypt all secret fields from an entry. Returns a map field_name → decrypted Value.
/// `folder` is optional; if omitted and multiple entries share the name, an error is returned.
pub async fn get_all_secrets(
pool: &PgPool,
name: &str,
folder: Option<&str>,
master_key: &[u8; 32],
user_id: Option<Uuid>,
) -> Result<HashMap<String, Value>> {
let entry = resolve_entry(pool, name, folder, user_id).await?;
let entry_ids = vec![entry.id];
let secrets_map = fetch_secrets_for_entries(pool, &entry_ids).await?;
let fields = secrets_map.get(&entry.id).map(Vec::as_slice).unwrap_or(&[]);
let mut map = HashMap::new();
for f in fields {
let decrypted = crypto::decrypt_json(master_key, &f.encrypted)?;
map.insert(f.field_name.clone(), decrypted);
}
Ok(map)
}
/// Decrypt a single named field from an entry, located by its UUID.
pub async fn get_secret_field_by_id(
pool: &PgPool,
entry_id: Uuid,
field_name: &str,
master_key: &[u8; 32],
user_id: Option<Uuid>,
) -> Result<Value> {
resolve_entry_by_id(pool, entry_id, user_id)
.await
.map_err(|_| anyhow::anyhow!("Entry with id '{}' not found", entry_id))?;
let entry_ids = vec![entry_id];
let secrets_map = fetch_secrets_for_entries(pool, &entry_ids).await?;
let fields = secrets_map.get(&entry_id).map(Vec::as_slice).unwrap_or(&[]);
let field = fields
.iter()
.find(|f| f.field_name == field_name)
.ok_or_else(|| anyhow::anyhow!("Secret field '{}' not found", field_name))?;
crypto::decrypt_json(master_key, &field.encrypted)
}
/// Decrypt all secret fields from an entry, located by its UUID.
/// Returns a map field_name → decrypted Value.
pub async fn get_all_secrets_by_id(
pool: &PgPool,
entry_id: Uuid,
master_key: &[u8; 32],
user_id: Option<Uuid>,
) -> Result<HashMap<String, Value>> {
// Validate entry exists (and that it belongs to the requesting user)
resolve_entry_by_id(pool, entry_id, user_id)
.await
.map_err(|_| anyhow::anyhow!("Entry with id '{}' not found", entry_id))?;
let entry_ids = vec![entry_id];
let secrets_map = fetch_secrets_for_entries(pool, &entry_ids).await?;
let fields = secrets_map.get(&entry_id).map(Vec::as_slice).unwrap_or(&[]);
let mut map = HashMap::new();
for f in fields {
let decrypted = crypto::decrypt_json(master_key, &f.encrypted)?;
map.insert(f.field_name.clone(), decrypted);
}
Ok(map)
}

View File

@@ -0,0 +1,61 @@
use anyhow::Result;
use serde_json::Value;
use sqlx::PgPool;
use uuid::Uuid;
use crate::service::search::resolve_entry;
#[derive(Debug, serde::Serialize)]
pub struct HistoryEntry {
pub version: i64,
pub action: String,
pub created_at: String,
}
/// Return version history for the entry identified by `name`.
/// `folder` is optional; if omitted and multiple entries share the name, an error is returned.
pub async fn run(
pool: &PgPool,
name: &str,
folder: Option<&str>,
limit: u32,
user_id: Option<Uuid>,
) -> Result<Vec<HistoryEntry>> {
#[derive(sqlx::FromRow)]
struct Row {
version: i64,
action: String,
created_at: chrono::DateTime<chrono::Utc>,
}
let entry = resolve_entry(pool, name, folder, user_id).await?;
let rows: Vec<Row> = sqlx::query_as(
"SELECT version, action, created_at FROM entries_history \
WHERE entry_id = $1 ORDER BY id DESC LIMIT $2",
)
.bind(entry.id)
.bind(limit as i64)
.fetch_all(pool)
.await?;
Ok(rows
.into_iter()
.map(|r| HistoryEntry {
version: r.version,
action: r.action,
created_at: r.created_at.format("%Y-%m-%dT%H:%M:%SZ").to_string(),
})
.collect())
}
pub async fn run_json(
pool: &PgPool,
name: &str,
folder: Option<&str>,
limit: u32,
user_id: Option<Uuid>,
) -> Result<Value> {
let entries = run(pool, name, folder, limit, user_id).await?;
Ok(serde_json::to_value(entries)?)
}

View File

@@ -0,0 +1,119 @@
use anyhow::Result;
use sqlx::PgPool;
use uuid::Uuid;
use crate::models::ExportFormat;
use crate::service::add::{AddParams, run as add_run};
use crate::service::export::{build_meta_entries, build_secret_entries};
#[derive(Debug, serde::Serialize)]
pub struct ImportSummary {
pub total: usize,
pub inserted: usize,
pub skipped: usize,
pub failed: usize,
pub dry_run: bool,
}
pub struct ImportParams<'a> {
pub file: &'a str,
pub force: bool,
pub dry_run: bool,
pub user_id: Option<Uuid>,
}
pub async fn run(
pool: &PgPool,
params: ImportParams<'_>,
master_key: &[u8; 32],
) -> Result<ImportSummary> {
let format = ExportFormat::from_extension(params.file)?;
let content = std::fs::read_to_string(params.file)
.map_err(|e| anyhow::anyhow!("Cannot read file '{}': {}", params.file, e))?;
let data = format.deserialize(&content)?;
if data.version != 1 {
anyhow::bail!(
"Unsupported export version {}. Only version 1 is supported.",
data.version
);
}
let total = data.entries.len();
let mut inserted = 0usize;
let mut skipped = 0usize;
let mut failed = 0usize;
for entry in &data.entries {
let exists: bool = sqlx::query_scalar(
"SELECT EXISTS(SELECT 1 FROM entries \
WHERE folder = $1 AND name = $2 AND user_id IS NOT DISTINCT FROM $3)",
)
.bind(&entry.folder)
.bind(&entry.name)
.bind(params.user_id)
.fetch_one(pool)
.await
.unwrap_or(false);
if exists && !params.force {
return Err(anyhow::anyhow!(
"Import aborted: conflict on '{}'",
entry.name
));
}
if params.dry_run {
if exists {
skipped += 1;
} else {
inserted += 1;
}
continue;
}
let secret_entries = build_secret_entries(entry.secrets.as_ref());
let meta_entries = build_meta_entries(&entry.metadata);
match add_run(
pool,
AddParams {
name: &entry.name,
folder: &entry.folder,
entry_type: &entry.entry_type,
notes: &entry.notes,
tags: &entry.tags,
meta_entries: &meta_entries,
secret_entries: &secret_entries,
user_id: params.user_id,
},
master_key,
)
.await
{
Ok(_) => {
inserted += 1;
}
Err(e) => {
tracing::error!(
name = entry.name,
error = %e,
"failed to import entry"
);
failed += 1;
}
}
}
if failed > 0 {
return Err(anyhow::anyhow!("{} record(s) failed to import", failed));
}
Ok(ImportSummary {
total,
inserted,
skipped,
failed,
dry_run: params.dry_run,
})
}

View File

@@ -0,0 +1,13 @@
pub mod add;
pub mod api_key;
pub mod audit_log;
pub mod delete;
pub mod env_map;
pub mod export;
pub mod get_secret;
pub mod history;
pub mod import;
pub mod rollback;
pub mod search;
pub mod update;
pub mod user;

View File

@@ -0,0 +1,339 @@
use anyhow::Result;
use serde_json::Value;
use sqlx::PgPool;
use uuid::Uuid;
use crate::crypto;
use crate::db;
#[derive(Debug, serde::Serialize)]
pub struct RollbackResult {
pub name: String,
pub folder: String,
#[serde(rename = "type")]
pub entry_type: String,
pub restored_version: i64,
}
/// Roll back entry `name` to `to_version` (or the most recent snapshot if None).
/// `folder` is optional; if omitted and multiple entries share the name, an error is returned.
pub async fn run(
pool: &PgPool,
name: &str,
folder: Option<&str>,
to_version: Option<i64>,
master_key: &[u8; 32],
user_id: Option<Uuid>,
) -> Result<RollbackResult> {
#[derive(sqlx::FromRow)]
struct EntryHistoryRow {
entry_id: Uuid,
folder: String,
#[sqlx(rename = "type")]
entry_type: String,
version: i64,
action: String,
tags: Vec<String>,
metadata: Value,
}
// Disambiguate: find the unique entry_id for (name, folder).
// Query entries_history by entry_id once we know it; first resolve via name + optional folder.
let entry_id: Option<Uuid> = if let Some(uid) = user_id {
if let Some(f) = folder {
sqlx::query_scalar(
"SELECT DISTINCT entry_id FROM entries_history \
WHERE name = $1 AND folder = $2 AND user_id = $3 LIMIT 1",
)
.bind(name)
.bind(f)
.bind(uid)
.fetch_optional(pool)
.await?
} else {
let ids: Vec<Uuid> = sqlx::query_scalar(
"SELECT DISTINCT entry_id FROM entries_history \
WHERE name = $1 AND user_id = $2",
)
.bind(name)
.bind(uid)
.fetch_all(pool)
.await?;
match ids.len() {
0 => None,
1 => Some(ids[0]),
_ => {
let folders: Vec<String> = sqlx::query_scalar(
"SELECT DISTINCT folder FROM entries_history \
WHERE name = $1 AND user_id = $2",
)
.bind(name)
.bind(uid)
.fetch_all(pool)
.await?;
anyhow::bail!(
"Ambiguous: entries named '{}' exist in folders: [{}]. \
Specify 'folder' to disambiguate.",
name,
folders.join(", ")
)
}
}
}
} else if let Some(f) = folder {
sqlx::query_scalar(
"SELECT DISTINCT entry_id FROM entries_history \
WHERE name = $1 AND folder = $2 AND user_id IS NULL LIMIT 1",
)
.bind(name)
.bind(f)
.fetch_optional(pool)
.await?
} else {
let ids: Vec<Uuid> = sqlx::query_scalar(
"SELECT DISTINCT entry_id FROM entries_history \
WHERE name = $1 AND user_id IS NULL",
)
.bind(name)
.fetch_all(pool)
.await?;
match ids.len() {
0 => None,
1 => Some(ids[0]),
_ => {
let folders: Vec<String> = sqlx::query_scalar(
"SELECT DISTINCT folder FROM entries_history \
WHERE name = $1 AND user_id IS NULL",
)
.bind(name)
.fetch_all(pool)
.await?;
anyhow::bail!(
"Ambiguous: entries named '{}' exist in folders: [{}]. \
Specify 'folder' to disambiguate.",
name,
folders.join(", ")
)
}
}
};
let entry_id = entry_id.ok_or_else(|| anyhow::anyhow!("No history found for '{}'", name))?;
let snap: Option<EntryHistoryRow> = if let Some(ver) = to_version {
sqlx::query_as(
"SELECT entry_id, folder, type, version, action, tags, metadata \
FROM entries_history \
WHERE entry_id = $1 AND version = $2 ORDER BY id DESC LIMIT 1",
)
.bind(entry_id)
.bind(ver)
.fetch_optional(pool)
.await?
} else {
sqlx::query_as(
"SELECT entry_id, folder, type, version, action, tags, metadata \
FROM entries_history \
WHERE entry_id = $1 ORDER BY id DESC LIMIT 1",
)
.bind(entry_id)
.fetch_optional(pool)
.await?
};
let snap = snap.ok_or_else(|| {
anyhow::anyhow!(
"No history found for '{}'{}.",
name,
to_version
.map(|v| format!(" at version {}", v))
.unwrap_or_default()
)
})?;
#[derive(sqlx::FromRow)]
struct SecretHistoryRow {
field_name: String,
encrypted: Vec<u8>,
action: String,
}
let field_snaps: Vec<SecretHistoryRow> = sqlx::query_as(
"SELECT field_name, encrypted, action FROM secrets_history \
WHERE entry_id = $1 AND entry_version = $2 ORDER BY field_name",
)
.bind(snap.entry_id)
.bind(snap.version)
.fetch_all(pool)
.await?;
for f in &field_snaps {
if f.action != "delete" && !f.encrypted.is_empty() {
crypto::decrypt_json(master_key, &f.encrypted).map_err(|e| {
anyhow::anyhow!(
"Cannot decrypt snapshot for field '{}': {}",
f.field_name,
e
)
})?;
}
}
let mut tx = pool.begin().await?;
#[derive(sqlx::FromRow)]
struct LiveEntry {
id: Uuid,
version: i64,
folder: String,
#[sqlx(rename = "type")]
entry_type: String,
tags: Vec<String>,
metadata: Value,
#[allow(dead_code)]
notes: String,
}
// Lock the live entry if it exists (matched by entry_id for precision).
let live: Option<LiveEntry> = sqlx::query_as(
"SELECT id, version, folder, type, tags, metadata, notes FROM entries \
WHERE id = $1 FOR UPDATE",
)
.bind(entry_id)
.fetch_optional(&mut *tx)
.await?;
let live_entry_id = if let Some(ref lr) = live {
if let Err(e) = db::snapshot_entry_history(
&mut tx,
db::EntrySnapshotParams {
entry_id: lr.id,
user_id,
folder: &lr.folder,
entry_type: &lr.entry_type,
name,
version: lr.version,
action: "rollback",
tags: &lr.tags,
metadata: &lr.metadata,
},
)
.await
{
tracing::warn!(error = %e, "failed to snapshot entry before rollback");
}
#[derive(sqlx::FromRow)]
struct LiveField {
id: Uuid,
field_name: String,
encrypted: Vec<u8>,
}
let live_fields: Vec<LiveField> =
sqlx::query_as("SELECT id, field_name, encrypted FROM secrets WHERE entry_id = $1")
.bind(lr.id)
.fetch_all(&mut *tx)
.await?;
for f in &live_fields {
if let Err(e) = db::snapshot_secret_history(
&mut tx,
db::SecretSnapshotParams {
entry_id: lr.id,
secret_id: f.id,
entry_version: lr.version,
field_name: &f.field_name,
encrypted: &f.encrypted,
action: "rollback",
},
)
.await
{
tracing::warn!(error = %e, "failed to snapshot secret field before rollback");
}
}
sqlx::query(
"UPDATE entries SET tags = $1, metadata = $2, version = version + 1, \
updated_at = NOW() WHERE id = $3",
)
.bind(&snap.tags)
.bind(&snap.metadata)
.bind(lr.id)
.execute(&mut *tx)
.await?;
lr.id
} else {
if let Some(uid) = user_id {
sqlx::query_scalar(
"INSERT INTO entries \
(user_id, folder, type, name, notes, tags, metadata, version, updated_at) \
VALUES ($1, $2, $3, $4, '', $5, $6, $7, NOW()) RETURNING id",
)
.bind(uid)
.bind(&snap.folder)
.bind(&snap.entry_type)
.bind(name)
.bind(&snap.tags)
.bind(&snap.metadata)
.bind(snap.version)
.fetch_one(&mut *tx)
.await?
} else {
sqlx::query_scalar(
"INSERT INTO entries \
(folder, type, name, notes, tags, metadata, version, updated_at) \
VALUES ($1, $2, $3, '', $4, $5, $6, NOW()) RETURNING id",
)
.bind(&snap.folder)
.bind(&snap.entry_type)
.bind(name)
.bind(&snap.tags)
.bind(&snap.metadata)
.bind(snap.version)
.fetch_one(&mut *tx)
.await?
}
};
sqlx::query("DELETE FROM secrets WHERE entry_id = $1")
.bind(live_entry_id)
.execute(&mut *tx)
.await?;
for f in &field_snaps {
if f.action == "delete" {
continue;
}
sqlx::query("INSERT INTO secrets (entry_id, field_name, encrypted) VALUES ($1, $2, $3)")
.bind(live_entry_id)
.bind(&f.field_name)
.bind(&f.encrypted)
.execute(&mut *tx)
.await?;
}
crate::audit::log_tx(
&mut tx,
user_id,
"rollback",
&snap.folder,
&snap.entry_type,
name,
serde_json::json!({
"restored_version": snap.version,
"original_action": snap.action,
}),
)
.await;
tx.commit().await?;
Ok(RollbackResult {
name: name.to_string(),
folder: snap.folder,
entry_type: snap.entry_type,
restored_version: snap.version,
})
}

View File

@@ -0,0 +1,347 @@
use anyhow::Result;
use serde_json::Value;
use sqlx::PgPool;
use std::collections::HashMap;
use uuid::Uuid;
use crate::models::{Entry, SecretField};
pub const FETCH_ALL_LIMIT: u32 = 100_000;
pub struct SearchParams<'a> {
pub folder: Option<&'a str>,
pub entry_type: Option<&'a str>,
pub name: Option<&'a str>,
pub tags: &'a [String],
pub query: Option<&'a str>,
pub sort: &'a str,
pub limit: u32,
pub offset: u32,
/// Multi-user: filter by this user_id. None = single-user / no filter.
pub user_id: Option<Uuid>,
}
#[derive(Debug, serde::Serialize)]
pub struct SearchResult {
pub entries: Vec<Entry>,
pub secret_schemas: HashMap<Uuid, Vec<SecretField>>,
}
/// List `entries` rows matching params (paged, ordered per `params.sort`).
/// Does not read the `secrets` table.
pub async fn list_entries(pool: &PgPool, params: SearchParams<'_>) -> Result<Vec<Entry>> {
fetch_entries_paged(pool, &params).await
}
/// Count `entries` rows matching the same filters as [`list_entries`] (ignores `sort` / `limit` / `offset`).
/// Does not read the `secrets` table.
pub async fn count_entries(pool: &PgPool, a: &SearchParams<'_>) -> Result<i64> {
let (where_clause, _) = entry_where_clause_and_next_idx(a);
let sql = format!("SELECT COUNT(*)::bigint FROM entries {where_clause}");
let mut q = sqlx::query_scalar::<_, i64>(&sql);
if let Some(uid) = a.user_id {
q = q.bind(uid);
}
if let Some(v) = a.folder {
q = q.bind(v);
}
if let Some(v) = a.entry_type {
q = q.bind(v);
}
if let Some(v) = a.name {
q = q.bind(v);
}
for tag in a.tags {
q = q.bind(tag);
}
if let Some(v) = a.query {
let pattern = format!("%{}%", v.replace('%', "\\%").replace('_', "\\_"));
q = q.bind(pattern);
}
let n = q.fetch_one(pool).await?;
Ok(n)
}
/// Shared WHERE clause and the next `$n` index (for LIMIT/OFFSET in paged queries).
fn entry_where_clause_and_next_idx(a: &SearchParams<'_>) -> (String, i32) {
let mut conditions: Vec<String> = Vec::new();
let mut idx: i32 = 1;
if a.user_id.is_some() {
conditions.push(format!("user_id = ${}", idx));
idx += 1;
} else {
conditions.push("user_id IS NULL".to_string());
}
if a.folder.is_some() {
conditions.push(format!("folder = ${}", idx));
idx += 1;
}
if a.entry_type.is_some() {
conditions.push(format!("type = ${}", idx));
idx += 1;
}
if a.name.is_some() {
conditions.push(format!("name = ${}", idx));
idx += 1;
}
if !a.tags.is_empty() {
let placeholders: Vec<String> = a
.tags
.iter()
.map(|_| {
let p = format!("${}", idx);
idx += 1;
p
})
.collect();
conditions.push(format!(
"tags @> ARRAY[{}]::text[]",
placeholders.join(", ")
));
}
if a.query.is_some() {
conditions.push(format!(
"(name ILIKE ${i} ESCAPE '\\' OR folder ILIKE ${i} ESCAPE '\\' \
OR type ILIKE ${i} ESCAPE '\\' OR notes ILIKE ${i} ESCAPE '\\' \
OR metadata::text ILIKE ${i} ESCAPE '\\' \
OR EXISTS (SELECT 1 FROM unnest(tags) t WHERE t ILIKE ${i} ESCAPE '\\'))",
i = idx
));
idx += 1;
}
let where_clause = if conditions.is_empty() {
String::new()
} else {
format!("WHERE {}", conditions.join(" AND "))
};
(where_clause, idx)
}
pub async fn run(pool: &PgPool, params: SearchParams<'_>) -> Result<SearchResult> {
let entries = fetch_entries_paged(pool, &params).await?;
let entry_ids: Vec<Uuid> = entries.iter().map(|e| e.id).collect();
let secret_schemas = if !entry_ids.is_empty() {
fetch_secret_schemas(pool, &entry_ids).await?
} else {
HashMap::new()
};
Ok(SearchResult {
entries,
secret_schemas,
})
}
/// Fetch entries matching the given filters — returns all matching entries up to FETCH_ALL_LIMIT.
pub async fn fetch_entries(
pool: &PgPool,
folder: Option<&str>,
entry_type: Option<&str>,
name: Option<&str>,
tags: &[String],
query: Option<&str>,
user_id: Option<Uuid>,
) -> Result<Vec<Entry>> {
let params = SearchParams {
folder,
entry_type,
name,
tags,
query,
sort: "name",
limit: FETCH_ALL_LIMIT,
offset: 0,
user_id,
};
list_entries(pool, params).await
}
async fn fetch_entries_paged(pool: &PgPool, a: &SearchParams<'_>) -> Result<Vec<Entry>> {
let (where_clause, idx) = entry_where_clause_and_next_idx(a);
let order = match a.sort {
"updated" => "updated_at DESC",
"created" => "created_at DESC",
_ => "name ASC",
};
let limit_idx = idx;
let offset_idx = idx + 1;
let sql = format!(
"SELECT id, user_id, folder, type, name, notes, tags, metadata, version, \
created_at, updated_at \
FROM entries {where_clause} ORDER BY {order} LIMIT ${limit_idx} OFFSET ${offset_idx}"
);
let mut q = sqlx::query_as::<_, EntryRaw>(&sql);
if let Some(uid) = a.user_id {
q = q.bind(uid);
}
if let Some(v) = a.folder {
q = q.bind(v);
}
if let Some(v) = a.entry_type {
q = q.bind(v);
}
if let Some(v) = a.name {
q = q.bind(v);
}
for tag in a.tags {
q = q.bind(tag);
}
if let Some(v) = a.query {
let pattern = format!("%{}%", v.replace('%', "\\%").replace('_', "\\_"));
q = q.bind(pattern);
}
q = q.bind(a.limit as i64).bind(a.offset as i64);
let rows = q.fetch_all(pool).await?;
Ok(rows.into_iter().map(Entry::from).collect())
}
/// Fetch secret field names for a set of entry ids (no decryption).
pub async fn fetch_secret_schemas(
pool: &PgPool,
entry_ids: &[Uuid],
) -> Result<HashMap<Uuid, Vec<SecretField>>> {
if entry_ids.is_empty() {
return Ok(HashMap::new());
}
let fields: Vec<SecretField> = sqlx::query_as(
"SELECT * FROM secrets WHERE entry_id = ANY($1) ORDER BY entry_id, field_name",
)
.bind(entry_ids)
.fetch_all(pool)
.await?;
let mut map: HashMap<Uuid, Vec<SecretField>> = HashMap::new();
for f in fields {
map.entry(f.entry_id).or_default().push(f);
}
Ok(map)
}
/// Fetch all secret fields (including encrypted bytes) for a set of entry ids.
pub async fn fetch_secrets_for_entries(
pool: &PgPool,
entry_ids: &[Uuid],
) -> Result<HashMap<Uuid, Vec<SecretField>>> {
if entry_ids.is_empty() {
return Ok(HashMap::new());
}
let fields: Vec<SecretField> = sqlx::query_as(
"SELECT * FROM secrets WHERE entry_id = ANY($1) ORDER BY entry_id, field_name",
)
.bind(entry_ids)
.fetch_all(pool)
.await?;
let mut map: HashMap<Uuid, Vec<SecretField>> = HashMap::new();
for f in fields {
map.entry(f.entry_id).or_default().push(f);
}
Ok(map)
}
/// Resolve exactly one entry by its UUID primary key.
///
/// Returns an error if the entry does not exist or does not belong to the given user.
pub async fn resolve_entry_by_id(
pool: &PgPool,
id: Uuid,
user_id: Option<Uuid>,
) -> Result<crate::models::Entry> {
let row: Option<EntryRaw> = if let Some(uid) = user_id {
sqlx::query_as(
"SELECT id, user_id, folder, type, name, notes, tags, metadata, version, \
created_at, updated_at FROM entries WHERE id = $1 AND user_id = $2",
)
.bind(id)
.bind(uid)
.fetch_optional(pool)
.await?
} else {
sqlx::query_as(
"SELECT id, user_id, folder, type, name, notes, tags, metadata, version, \
created_at, updated_at FROM entries WHERE id = $1 AND user_id IS NULL",
)
.bind(id)
.fetch_optional(pool)
.await?
};
row.map(Entry::from)
.ok_or_else(|| anyhow::anyhow!("Entry with id '{}' not found", id))
}
/// Resolve exactly one entry by name, with optional folder for disambiguation.
///
/// - If `folder` is provided: exact `(folder, name)` match.
/// - If `folder` is None and exactly one entry matches: returns it.
/// - If `folder` is None and multiple entries match: returns an error listing
/// the folders and asking the caller to specify one.
pub async fn resolve_entry(
pool: &PgPool,
name: &str,
folder: Option<&str>,
user_id: Option<Uuid>,
) -> Result<crate::models::Entry> {
let entries = fetch_entries(pool, folder, None, Some(name), &[], None, user_id).await?;
match entries.len() {
0 => {
if let Some(f) = folder {
anyhow::bail!("Not found: '{}' in folder '{}'", name, f)
} else {
anyhow::bail!("Not found: '{}'", name)
}
}
1 => Ok(entries.into_iter().next().unwrap()),
_ => {
let folders: Vec<&str> = entries.iter().map(|e| e.folder.as_str()).collect();
anyhow::bail!(
"Ambiguous: {} entries named '{}' found in folders: [{}]. \
Specify 'folder' to disambiguate.",
entries.len(),
name,
folders.join(", ")
)
}
}
}
// ── Internal raw row (because user_id is nullable in DB) ─────────────────────
#[derive(sqlx::FromRow)]
struct EntryRaw {
id: Uuid,
user_id: Option<Uuid>,
folder: String,
#[sqlx(rename = "type")]
entry_type: String,
name: String,
notes: String,
tags: Vec<String>,
metadata: Value,
version: i64,
created_at: chrono::DateTime<chrono::Utc>,
updated_at: chrono::DateTime<chrono::Utc>,
}
impl From<EntryRaw> for Entry {
fn from(r: EntryRaw) -> Self {
Entry {
id: r.id,
user_id: r.user_id,
folder: r.folder,
entry_type: r.entry_type,
name: r.name,
notes: r.notes,
tags: r.tags,
metadata: r.metadata,
version: r.version,
created_at: r.created_at,
updated_at: r.updated_at,
}
}
}

View File

@@ -0,0 +1,423 @@
use anyhow::Result;
use serde_json::{Map, Value};
use sqlx::PgPool;
use uuid::Uuid;
use crate::crypto;
use crate::db;
use crate::models::{EntryRow, EntryWriteRow};
use crate::service::add::{
collect_field_paths, collect_key_paths, flatten_json_fields, insert_path, parse_key_path,
parse_kv, remove_path,
};
#[derive(Debug, serde::Serialize)]
pub struct UpdateResult {
pub name: String,
pub folder: String,
#[serde(rename = "type")]
pub entry_type: String,
pub add_tags: Vec<String>,
pub remove_tags: Vec<String>,
pub meta_keys: Vec<String>,
pub remove_meta: Vec<String>,
pub secret_keys: Vec<String>,
pub remove_secrets: Vec<String>,
}
pub struct UpdateParams<'a> {
pub name: &'a str,
/// Optional folder for disambiguation when multiple entries share the same name.
pub folder: Option<&'a str>,
pub notes: Option<&'a str>,
pub add_tags: &'a [String],
pub remove_tags: &'a [String],
pub meta_entries: &'a [String],
pub remove_meta: &'a [String],
pub secret_entries: &'a [String],
pub remove_secrets: &'a [String],
pub user_id: Option<Uuid>,
}
pub async fn run(
pool: &PgPool,
params: UpdateParams<'_>,
master_key: &[u8; 32],
) -> Result<UpdateResult> {
let mut tx = pool.begin().await?;
// Fetch matching rows with FOR UPDATE; use folder when provided to resolve ambiguity.
let rows: Vec<EntryRow> = if let Some(uid) = params.user_id {
if let Some(folder) = params.folder {
sqlx::query_as(
"SELECT id, version, folder, type, tags, metadata, notes FROM entries \
WHERE user_id = $1 AND folder = $2 AND name = $3 FOR UPDATE",
)
.bind(uid)
.bind(folder)
.bind(params.name)
.fetch_all(&mut *tx)
.await?
} else {
sqlx::query_as(
"SELECT id, version, folder, type, tags, metadata, notes FROM entries \
WHERE user_id = $1 AND name = $2 FOR UPDATE",
)
.bind(uid)
.bind(params.name)
.fetch_all(&mut *tx)
.await?
}
} else if let Some(folder) = params.folder {
sqlx::query_as(
"SELECT id, version, folder, type, tags, metadata, notes FROM entries \
WHERE user_id IS NULL AND folder = $1 AND name = $2 FOR UPDATE",
)
.bind(folder)
.bind(params.name)
.fetch_all(&mut *tx)
.await?
} else {
sqlx::query_as(
"SELECT id, version, folder, type, tags, metadata, notes FROM entries \
WHERE user_id IS NULL AND name = $1 FOR UPDATE",
)
.bind(params.name)
.fetch_all(&mut *tx)
.await?
};
let row = match rows.len() {
0 => {
tx.rollback().await?;
anyhow::bail!(
"Not found: '{}'. Use `add` to create it first.",
params.name
)
}
1 => rows.into_iter().next().unwrap(),
_ => {
tx.rollback().await?;
let folders: Vec<&str> = rows.iter().map(|r| r.folder.as_str()).collect();
anyhow::bail!(
"Ambiguous: {} entries named '{}' found in folders: [{}]. \
Specify 'folder' to disambiguate.",
rows.len(),
params.name,
folders.join(", ")
)
}
};
if let Err(e) = db::snapshot_entry_history(
&mut tx,
db::EntrySnapshotParams {
entry_id: row.id,
user_id: params.user_id,
folder: &row.folder,
entry_type: &row.entry_type,
name: params.name,
version: row.version,
action: "update",
tags: &row.tags,
metadata: &row.metadata,
},
)
.await
{
tracing::warn!(error = %e, "failed to snapshot entry history before update");
}
let mut tags: Vec<String> = row.tags.clone();
for t in params.add_tags {
if !tags.contains(t) {
tags.push(t.clone());
}
}
tags.retain(|t| !params.remove_tags.contains(t));
let mut meta_map: Map<String, Value> = match row.metadata.clone() {
Value::Object(m) => m,
_ => Map::new(),
};
for entry in params.meta_entries {
let (path, value) = parse_kv(entry)?;
insert_path(&mut meta_map, &path, value)?;
}
for key in params.remove_meta {
let path = parse_key_path(key)?;
remove_path(&mut meta_map, &path)?;
}
let metadata = Value::Object(meta_map);
let new_notes = params.notes.unwrap_or(&row.notes);
let result = sqlx::query(
"UPDATE entries SET tags = $1, metadata = $2, notes = $3, \
version = version + 1, updated_at = NOW() \
WHERE id = $4 AND version = $5",
)
.bind(&tags)
.bind(&metadata)
.bind(new_notes)
.bind(row.id)
.bind(row.version)
.execute(&mut *tx)
.await?;
if result.rows_affected() == 0 {
tx.rollback().await?;
anyhow::bail!(
"Concurrent modification detected for '{}'. Please retry.",
params.name
);
}
let new_version = row.version + 1;
for entry in params.secret_entries {
let (path, field_value) = parse_kv(entry)?;
let flat = flatten_json_fields("", &{
let mut m = Map::new();
insert_path(&mut m, &path, field_value)?;
Value::Object(m)
});
for (field_name, fv) in &flat {
let encrypted = crypto::encrypt_json(master_key, fv)?;
#[derive(sqlx::FromRow)]
struct ExistingField {
id: Uuid,
encrypted: Vec<u8>,
}
let ef: Option<ExistingField> = sqlx::query_as(
"SELECT id, encrypted FROM secrets WHERE entry_id = $1 AND field_name = $2",
)
.bind(row.id)
.bind(field_name)
.fetch_optional(&mut *tx)
.await?;
if let Some(ef) = &ef
&& let Err(e) = db::snapshot_secret_history(
&mut tx,
db::SecretSnapshotParams {
entry_id: row.id,
secret_id: ef.id,
entry_version: row.version,
field_name,
encrypted: &ef.encrypted,
action: "update",
},
)
.await
{
tracing::warn!(error = %e, "failed to snapshot secret field history");
}
sqlx::query(
"INSERT INTO secrets (entry_id, field_name, encrypted) VALUES ($1, $2, $3) \
ON CONFLICT (entry_id, field_name) DO UPDATE SET \
encrypted = EXCLUDED.encrypted, version = secrets.version + 1, updated_at = NOW()",
)
.bind(row.id)
.bind(field_name)
.bind(&encrypted)
.execute(&mut *tx)
.await?;
}
}
for key in params.remove_secrets {
let path = parse_key_path(key)?;
let field_name = path.join(".");
#[derive(sqlx::FromRow)]
struct FieldToDelete {
id: Uuid,
encrypted: Vec<u8>,
}
let field: Option<FieldToDelete> = sqlx::query_as(
"SELECT id, encrypted FROM secrets WHERE entry_id = $1 AND field_name = $2",
)
.bind(row.id)
.bind(&field_name)
.fetch_optional(&mut *tx)
.await?;
if let Some(f) = field {
if let Err(e) = db::snapshot_secret_history(
&mut tx,
db::SecretSnapshotParams {
entry_id: row.id,
secret_id: f.id,
entry_version: new_version,
field_name: &field_name,
encrypted: &f.encrypted,
action: "delete",
},
)
.await
{
tracing::warn!(error = %e, "failed to snapshot secret field history before delete");
}
sqlx::query("DELETE FROM secrets WHERE id = $1")
.bind(f.id)
.execute(&mut *tx)
.await?;
}
}
let meta_keys = collect_key_paths(params.meta_entries)?;
let remove_meta_keys = collect_field_paths(params.remove_meta)?;
let secret_keys = collect_key_paths(params.secret_entries)?;
let remove_secret_keys = collect_field_paths(params.remove_secrets)?;
crate::audit::log_tx(
&mut tx,
params.user_id,
"update",
"",
"",
params.name,
serde_json::json!({
"add_tags": params.add_tags,
"remove_tags": params.remove_tags,
"meta_keys": meta_keys,
"remove_meta": remove_meta_keys,
"secret_keys": secret_keys,
"remove_secrets": remove_secret_keys,
}),
)
.await;
tx.commit().await?;
Ok(UpdateResult {
name: params.name.to_string(),
folder: row.folder.clone(),
entry_type: row.entry_type.clone(),
add_tags: params.add_tags.to_vec(),
remove_tags: params.remove_tags.to_vec(),
meta_keys,
remove_meta: remove_meta_keys,
secret_keys,
remove_secrets: remove_secret_keys,
})
}
/// Update non-sensitive entry columns by primary key (multi-tenant: `user_id` must match).
/// Does not read or modify `secrets` rows.
pub struct UpdateEntryFieldsByIdParams<'a> {
pub folder: &'a str,
pub entry_type: &'a str,
pub name: &'a str,
pub notes: &'a str,
pub tags: &'a [String],
pub metadata: &'a serde_json::Value,
}
pub async fn update_fields_by_id(
pool: &PgPool,
entry_id: Uuid,
user_id: Uuid,
params: UpdateEntryFieldsByIdParams<'_>,
) -> Result<()> {
if params.folder.len() > 128 {
anyhow::bail!("folder must be at most 128 characters");
}
if params.entry_type.len() > 64 {
anyhow::bail!("type must be at most 64 characters");
}
if params.name.len() > 256 {
anyhow::bail!("name must be at most 256 characters");
}
let mut tx = pool.begin().await?;
let row: Option<EntryWriteRow> = sqlx::query_as(
"SELECT id, version, folder, type, name, tags, metadata, notes FROM entries \
WHERE id = $1 AND user_id = $2 FOR UPDATE",
)
.bind(entry_id)
.bind(user_id)
.fetch_optional(&mut *tx)
.await?;
let row = match row {
Some(r) => r,
None => {
tx.rollback().await?;
anyhow::bail!("Entry not found");
}
};
if let Err(e) = db::snapshot_entry_history(
&mut tx,
db::EntrySnapshotParams {
entry_id: row.id,
user_id: Some(user_id),
folder: &row.folder,
entry_type: &row.entry_type,
name: &row.name,
version: row.version,
action: "update",
tags: &row.tags,
metadata: &row.metadata,
},
)
.await
{
tracing::warn!(error = %e, "failed to snapshot entry history before web update");
}
let res = sqlx::query(
"UPDATE entries SET folder = $1, type = $2, name = $3, notes = $4, tags = $5, metadata = $6, \
version = version + 1, updated_at = NOW() \
WHERE id = $7 AND version = $8",
)
.bind(params.folder)
.bind(params.entry_type)
.bind(params.name)
.bind(params.notes)
.bind(params.tags)
.bind(params.metadata)
.bind(row.id)
.bind(row.version)
.execute(&mut *tx)
.await
.map_err(|e| {
if let sqlx::Error::Database(ref d) = e
&& d.code().as_deref() == Some("23505")
{
return anyhow::anyhow!(
"An entry with this folder and name already exists for your account."
);
}
e.into()
})?;
if res.rows_affected() == 0 {
tx.rollback().await?;
anyhow::bail!("Concurrent modification detected. Please refresh and try again.");
}
crate::audit::log_tx(
&mut tx,
Some(user_id),
"update",
params.folder,
params.entry_type,
params.name,
serde_json::json!({
"source": "web",
"entry_id": entry_id,
"fields": ["folder", "type", "name", "notes", "tags", "metadata"],
}),
)
.await;
tx.commit().await?;
Ok(())
}

View File

@@ -0,0 +1,213 @@
use anyhow::Result;
use serde_json::Value;
use sqlx::PgPool;
use uuid::Uuid;
use crate::models::{OauthAccount, User};
pub struct OAuthProfile {
pub provider: String,
pub provider_id: String,
pub email: Option<String>,
pub name: Option<String>,
pub avatar_url: Option<String>,
}
/// Find or create a user from an OAuth profile.
/// Returns (user, is_new) where is_new indicates first-time registration.
pub async fn find_or_create_user(pool: &PgPool, profile: OAuthProfile) -> Result<(User, bool)> {
// Check if this OAuth account already exists
let existing: Option<OauthAccount> = sqlx::query_as(
"SELECT id, user_id, provider, provider_id, email, name, avatar_url, created_at \
FROM oauth_accounts WHERE provider = $1 AND provider_id = $2",
)
.bind(&profile.provider)
.bind(&profile.provider_id)
.fetch_optional(pool)
.await?;
if let Some(oa) = existing {
let user: User = sqlx::query_as(
"SELECT id, email, name, avatar_url, key_salt, key_check, key_params, api_key, created_at, updated_at \
FROM users WHERE id = $1",
)
.bind(oa.user_id)
.fetch_one(pool)
.await?;
return Ok((user, false));
}
// New user — create records (no key yet; user sets passphrase on dashboard)
let display_name = profile
.name
.clone()
.unwrap_or_else(|| profile.email.clone().unwrap_or_else(|| "User".to_string()));
let mut tx = pool.begin().await?;
let user: User = sqlx::query_as(
"INSERT INTO users (email, name, avatar_url) \
VALUES ($1, $2, $3) \
RETURNING id, email, name, avatar_url, key_salt, key_check, key_params, api_key, created_at, updated_at",
)
.bind(&profile.email)
.bind(&display_name)
.bind(&profile.avatar_url)
.fetch_one(&mut *tx)
.await?;
sqlx::query(
"INSERT INTO oauth_accounts (user_id, provider, provider_id, email, name, avatar_url) \
VALUES ($1, $2, $3, $4, $5, $6)",
)
.bind(user.id)
.bind(&profile.provider)
.bind(&profile.provider_id)
.bind(&profile.email)
.bind(&profile.name)
.bind(&profile.avatar_url)
.execute(&mut *tx)
.await?;
tx.commit().await?;
Ok((user, true))
}
/// Store the PBKDF2 salt, key_check, and params for a user's passphrase setup.
pub async fn update_user_key_setup(
pool: &PgPool,
user_id: Uuid,
key_salt: &[u8],
key_check: &[u8],
key_params: &Value,
) -> Result<()> {
sqlx::query(
"UPDATE users SET key_salt = $1, key_check = $2, key_params = $3, updated_at = NOW() \
WHERE id = $4",
)
.bind(key_salt)
.bind(key_check)
.bind(key_params)
.bind(user_id)
.execute(pool)
.await?;
Ok(())
}
/// Fetch a user by ID.
pub async fn get_user_by_id(pool: &PgPool, user_id: Uuid) -> Result<Option<User>> {
let user = sqlx::query_as(
"SELECT id, email, name, avatar_url, key_salt, key_check, key_params, api_key, created_at, updated_at \
FROM users WHERE id = $1",
)
.bind(user_id)
.fetch_optional(pool)
.await?;
Ok(user)
}
/// List all OAuth accounts linked to a user.
pub async fn list_oauth_accounts(pool: &PgPool, user_id: Uuid) -> Result<Vec<OauthAccount>> {
let accounts = sqlx::query_as(
"SELECT id, user_id, provider, provider_id, email, name, avatar_url, created_at \
FROM oauth_accounts WHERE user_id = $1 ORDER BY created_at",
)
.bind(user_id)
.fetch_all(pool)
.await?;
Ok(accounts)
}
/// Bind an additional OAuth account to an existing user.
pub async fn bind_oauth_account(
pool: &PgPool,
user_id: Uuid,
profile: OAuthProfile,
) -> Result<OauthAccount> {
// Check if this provider_id is already linked to someone else
let conflict: Option<(Uuid,)> = sqlx::query_as(
"SELECT user_id FROM oauth_accounts WHERE provider = $1 AND provider_id = $2",
)
.bind(&profile.provider)
.bind(&profile.provider_id)
.fetch_optional(pool)
.await?;
if let Some((existing_user_id,)) = conflict {
if existing_user_id != user_id {
anyhow::bail!(
"This {} account is already linked to a different user",
profile.provider
);
}
anyhow::bail!(
"This {} account is already linked to your account",
profile.provider
);
}
let existing_provider_for_user: Option<(String,)> = sqlx::query_as(
"SELECT provider_id FROM oauth_accounts WHERE user_id = $1 AND provider = $2",
)
.bind(user_id)
.bind(&profile.provider)
.fetch_optional(pool)
.await?;
if existing_provider_for_user.is_some() {
anyhow::bail!(
"You already linked a {} account. Unlink the other provider instead of binding multiple {} accounts.",
profile.provider,
profile.provider
);
}
let account: OauthAccount = sqlx::query_as(
"INSERT INTO oauth_accounts (user_id, provider, provider_id, email, name, avatar_url) \
VALUES ($1, $2, $3, $4, $5, $6) \
RETURNING id, user_id, provider, provider_id, email, name, avatar_url, created_at",
)
.bind(user_id)
.bind(&profile.provider)
.bind(&profile.provider_id)
.bind(&profile.email)
.bind(&profile.name)
.bind(&profile.avatar_url)
.fetch_one(pool)
.await?;
Ok(account)
}
/// Unbind an OAuth account. Ensures at least one remains and blocks unlinking the current login provider.
pub async fn unbind_oauth_account(
pool: &PgPool,
user_id: Uuid,
provider: &str,
current_login_provider: Option<&str>,
) -> Result<()> {
if current_login_provider == Some(provider) {
anyhow::bail!(
"Cannot unlink the {} account you are currently using to sign in",
provider
);
}
let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM oauth_accounts WHERE user_id = $1")
.bind(user_id)
.fetch_one(pool)
.await?;
if count <= 1 {
anyhow::bail!("Cannot unbind the last OAuth account. Please link another account first.");
}
sqlx::query("DELETE FROM oauth_accounts WHERE user_id = $1 AND provider = $2")
.bind(user_id)
.bind(provider)
.execute(pool)
.await?;
Ok(())
}

View File

@@ -0,0 +1,46 @@
[package]
name = "secrets-mcp"
version = "0.3.7"
edition.workspace = true
[[bin]]
name = "secrets-mcp"
path = "src/main.rs"
[dependencies]
secrets-core = { path = "../secrets-core" }
# MCP
rmcp = { version = "1", features = ["server", "macros", "transport-streamable-http-server", "schemars"] }
# Web framework
axum = "0.8"
axum-extra = { version = "0.10", features = ["typed-header"] }
tower = "0.5"
tower-http = { version = "0.6", features = ["cors", "trace"] }
tower-sessions = "0.14"
tower-sessions-sqlx-store-chrono = { version = "0.14", features = ["postgres"] }
time = "0.3"
# OAuth (manual token exchange via reqwest)
reqwest.workspace = true
# Templating - render templates manually to avoid integration crate issues
askama = "0.13"
# Common
anyhow.workspace = true
chrono.workspace = true
serde.workspace = true
serde_json.workspace = true
sha2.workspace = true
rand.workspace = true
sqlx.workspace = true
tokio.workspace = true
tracing.workspace = true
tracing-subscriber.workspace = true
uuid.workspace = true
dotenvy.workspace = true
urlencoding = "2"
schemars = "1"
http = "1"

View File

@@ -0,0 +1,114 @@
use std::net::SocketAddr;
use axum::{
extract::{ConnectInfo, Request, State},
http::StatusCode,
middleware::Next,
response::Response,
};
use sqlx::PgPool;
use uuid::Uuid;
use secrets_core::service::api_key::validate_api_key;
/// Injected into request extensions after Bearer token validation.
#[derive(Clone, Debug)]
pub struct AuthUser {
pub user_id: Uuid,
}
fn log_client_ip(req: &Request) -> Option<String> {
if let Some(first) = req
.headers()
.get("x-forwarded-for")
.and_then(|v| v.to_str().ok())
.and_then(|s| s.split(',').next())
{
let s = first.trim();
if !s.is_empty() {
return Some(s.to_string());
}
}
req.extensions()
.get::<ConnectInfo<SocketAddr>>()
.map(|c| c.ip().to_string())
}
/// Axum middleware that validates Bearer API keys for the /mcp route.
/// Passes all non-MCP paths through without authentication.
pub async fn bearer_auth_middleware(
State(pool): State<PgPool>,
req: Request,
next: Next,
) -> Result<Response, StatusCode> {
let path = req.uri().path();
let method = req.method().as_str();
let client_ip = log_client_ip(&req);
// Only authenticate /mcp paths
if !path.starts_with("/mcp") {
return Ok(next.run(req).await);
}
// Allow OPTIONS (CORS preflight) through
if req.method() == axum::http::Method::OPTIONS {
return Ok(next.run(req).await);
}
let auth_header = req
.headers()
.get(axum::http::header::AUTHORIZATION)
.and_then(|v| v.to_str().ok());
let raw_key = match auth_header {
Some(h) if h.starts_with("Bearer ") => h.trim_start_matches("Bearer ").trim(),
Some(_) => {
tracing::warn!(
method,
path,
client_ip = client_ip.as_deref(),
"invalid Authorization header format on /mcp (expected Bearer …)"
);
return Err(StatusCode::UNAUTHORIZED);
}
None => {
tracing::warn!(
method,
path,
client_ip = client_ip.as_deref(),
"missing Authorization header on /mcp"
);
return Err(StatusCode::UNAUTHORIZED);
}
};
match validate_api_key(&pool, raw_key).await {
Ok(Some(user_id)) => {
tracing::debug!(?user_id, "api key authenticated");
let mut req = req;
req.extensions_mut().insert(AuthUser { user_id });
Ok(next.run(req).await)
}
Ok(None) => {
tracing::warn!(
method,
path,
client_ip = client_ip.as_deref(),
key_prefix = %&raw_key.chars().take(12).collect::<String>(),
key_len = raw_key.len(),
"invalid api key (not found in database — e.g. revoked key or DB was reset; update MCP client Bearer token)"
);
Err(StatusCode::UNAUTHORIZED)
}
Err(e) => {
tracing::error!(
method,
path,
client_ip = client_ip.as_deref(),
error = %e,
"api key validation error"
);
Err(StatusCode::INTERNAL_SERVER_ERROR)
}
}
}

View File

@@ -0,0 +1,262 @@
use std::net::SocketAddr;
use std::time::Instant;
use axum::{
body::{Body, Bytes, to_bytes},
extract::{ConnectInfo, Request},
http::{
HeaderMap, Method, StatusCode,
header::{CONTENT_LENGTH, CONTENT_TYPE, USER_AGENT},
},
middleware::Next,
response::{IntoResponse, Response},
};
/// Axum middleware that logs structured info for every HTTP request.
///
/// All requests: method, path, status, latency_ms, client_ip, user_agent.
/// POST /mcp requests: additionally parses JSON-RPC body for jsonrpc_method,
/// tool_name, jsonrpc_id, mcp_session, batch_size.
///
/// Sensitive headers (Authorization, X-Encryption-Key) and secret values
/// are never logged.
pub async fn request_logging_middleware(req: Request, next: Next) -> Response {
let method = req.method().clone();
let path = req.uri().path().to_string();
let ip = client_ip(&req);
let ua = header_str(req.headers(), USER_AGENT);
let content_len = header_str(req.headers(), CONTENT_LENGTH).and_then(|v| v.parse::<u64>().ok());
let mcp_session = req
.headers()
.get("mcp-session-id")
.or_else(|| req.headers().get("x-mcp-session"))
.and_then(|v| v.to_str().ok())
.map(|s| s.to_string());
let is_mcp_post = path.starts_with("/mcp") && method == Method::POST;
let is_json = header_str(req.headers(), CONTENT_TYPE)
.map(|ct| ct.contains("application/json"))
.unwrap_or(false);
let start = Instant::now();
// For MCP JSON-RPC POST requests, buffer body to extract JSON-RPC metadata.
// We cap at 512 KiB to avoid buffering large payloads.
if is_mcp_post && is_json {
let cap = content_len.unwrap_or(0);
if cap <= 512 * 1024 {
let (parts, body) = req.into_parts();
match to_bytes(body, 512 * 1024).await {
Ok(bytes) => {
let rpc = parse_jsonrpc_meta(&bytes);
let req = Request::from_parts(parts, Body::from(bytes));
let resp = next.run(req).await;
let status = resp.status().as_u16();
let elapsed = start.elapsed().as_millis();
log_mcp_request(
&method,
&path,
status,
elapsed,
ip.as_deref(),
ua.as_deref(),
content_len,
mcp_session.as_deref(),
&rpc,
);
return resp;
}
Err(e) => {
tracing::warn!(path, error = %e, "failed to buffer MCP request body for logging");
let elapsed = start.elapsed().as_millis();
tracing::info!(
method = method.as_str(),
path,
status = StatusCode::INTERNAL_SERVER_ERROR.as_u16(),
elapsed_ms = elapsed,
client_ip = ip.as_deref(),
ua = ua.as_deref(),
content_length = content_len,
mcp_session = mcp_session.as_deref(),
"mcp request",
);
return (
StatusCode::INTERNAL_SERVER_ERROR,
"failed to read request body",
)
.into_response();
}
}
}
}
let resp = next.run(req).await;
let status = resp.status().as_u16();
let elapsed = start.elapsed().as_millis();
// Known client probe patterns that legitimately 404 — downgrade to debug to
// avoid noise in production logs. These are:
// • GET /.well-known/* — OAuth/OIDC discovery by MCP clients (RFC 8414 / RFC 9728)
// • GET /mcp → 404 — old SSE-transport compatibility probe by clients
let is_expected_probe_404 = status == 404
&& (path.starts_with("/.well-known/")
|| (method == Method::GET && path.starts_with("/mcp")));
if is_expected_probe_404 {
tracing::debug!(
method = method.as_str(),
path,
status,
elapsed_ms = elapsed,
client_ip = ip.as_deref(),
ua = ua.as_deref(),
"probe request (not found — expected)",
);
} else {
log_http_request(
&method,
&path,
status,
elapsed,
ip.as_deref(),
ua.as_deref(),
content_len,
);
}
resp
}
// ── Logging helpers ───────────────────────────────────────────────────────────
fn log_http_request(
method: &Method,
path: &str,
status: u16,
elapsed_ms: u128,
client_ip: Option<&str>,
ua: Option<&str>,
content_length: Option<u64>,
) {
tracing::info!(
method = method.as_str(),
path,
status,
elapsed_ms,
client_ip,
ua,
content_length,
"http request",
);
}
#[allow(clippy::too_many_arguments)]
fn log_mcp_request(
method: &Method,
path: &str,
status: u16,
elapsed_ms: u128,
client_ip: Option<&str>,
ua: Option<&str>,
content_length: Option<u64>,
mcp_session: Option<&str>,
rpc: &JsonRpcMeta,
) {
tracing::info!(
method = method.as_str(),
path,
status,
elapsed_ms,
client_ip,
ua,
content_length,
mcp_session,
jsonrpc = rpc.rpc_method.as_deref(),
tool = rpc.tool_name.as_deref(),
jsonrpc_id = rpc.request_id.as_deref(),
batch_size = rpc.batch_size,
"mcp request",
);
}
// ── JSON-RPC body parsing ─────────────────────────────────────────────────────
#[derive(Debug, Default)]
struct JsonRpcMeta {
request_id: Option<String>,
rpc_method: Option<String>,
tool_name: Option<String>,
batch_size: Option<usize>,
}
fn parse_jsonrpc_meta(bytes: &Bytes) -> JsonRpcMeta {
let Ok(value) = serde_json::from_slice::<serde_json::Value>(bytes) else {
return JsonRpcMeta::default();
};
if let Some(arr) = value.as_array() {
// Batch request: summarise method(s) from first element only
let first = arr.first().map(parse_single).unwrap_or_default();
return JsonRpcMeta {
batch_size: Some(arr.len()),
..first
};
}
parse_single(&value)
}
fn parse_single(value: &serde_json::Value) -> JsonRpcMeta {
let request_id = value.get("id").and_then(json_to_string);
let rpc_method = value
.get("method")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let tool_name = value
.pointer("/params/name")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
JsonRpcMeta {
request_id,
rpc_method,
tool_name,
batch_size: None,
}
}
fn json_to_string(value: &serde_json::Value) -> Option<String> {
match value {
serde_json::Value::Null => None,
serde_json::Value::String(s) => Some(s.clone()),
serde_json::Value::Number(n) => Some(n.to_string()),
serde_json::Value::Bool(b) => Some(b.to_string()),
other => Some(other.to_string()),
}
}
// ── Header helpers ────────────────────────────────────────────────────────────
fn header_str(headers: &HeaderMap, name: impl axum::http::header::AsHeaderName) -> Option<String> {
headers
.get(name)
.and_then(|v| v.to_str().ok())
.map(|s| s.to_string())
}
fn client_ip(req: &Request) -> Option<String> {
if let Some(first) = req
.headers()
.get("x-forwarded-for")
.and_then(|v| v.to_str().ok())
.and_then(|s| s.split(',').next())
{
let s = first.trim();
if !s.is_empty() {
return Some(s.to_string());
}
}
req.extensions()
.get::<ConnectInfo<SocketAddr>>()
.map(|c| c.ip().to_string())
}

View File

@@ -0,0 +1,191 @@
mod auth;
mod logging;
mod oauth;
mod tools;
mod web;
use std::net::SocketAddr;
use std::sync::Arc;
use anyhow::{Context, Result};
use axum::Router;
use rmcp::transport::streamable_http_server::{
StreamableHttpService, session::local::LocalSessionManager,
};
use sqlx::PgPool;
use tower_http::cors::{Any, CorsLayer};
use tower_sessions::cookie::SameSite;
use tower_sessions::session_store::ExpiredDeletion;
use tower_sessions::{Expiry, SessionManagerLayer};
use tower_sessions_sqlx_store_chrono::PostgresStore;
use tracing_subscriber::EnvFilter;
use tracing_subscriber::fmt::time::FormatTime;
use secrets_core::config::resolve_db_config;
use secrets_core::db::{create_pool, migrate};
use crate::oauth::OAuthConfig;
use crate::tools::SecretsService;
/// Shared application state injected into web routes and middleware.
#[derive(Clone)]
pub struct AppState {
pub pool: PgPool,
pub google_config: Option<OAuthConfig>,
pub base_url: String,
pub http_client: reqwest::Client,
}
fn load_env_var(name: &str) -> Option<String> {
std::env::var(name).ok().filter(|s| !s.is_empty())
}
fn load_oauth_config(prefix: &str, base_url: &str, path: &str) -> Option<OAuthConfig> {
let client_id = load_env_var(&format!("{}_CLIENT_ID", prefix))?;
let client_secret = load_env_var(&format!("{}_CLIENT_SECRET", prefix))?;
Some(OAuthConfig {
client_id,
client_secret,
redirect_uri: format!("{}{}", base_url, path),
})
}
/// Log line timestamps in the process local timezone (honors `TZ` / system zone).
#[derive(Clone, Copy, Default)]
struct LocalRfc3339Time;
impl FormatTime for LocalRfc3339Time {
fn format_time(&self, w: &mut tracing_subscriber::fmt::format::Writer<'_>) -> std::fmt::Result {
write!(
w,
"{}",
chrono::Local::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, false)
)
}
}
#[tokio::main]
async fn main() -> Result<()> {
// Load .env if present
let _ = dotenvy::dotenv();
tracing_subscriber::fmt()
.with_timer(LocalRfc3339Time)
.with_env_filter(
EnvFilter::try_from_default_env()
.unwrap_or_else(|_| "secrets_mcp=info,tower_http=info".into()),
)
.init();
// ── Database ──────────────────────────────────────────────────────────────
let db_config = resolve_db_config("")
.context("Database not configured. Set SECRETS_DATABASE_URL environment variable.")?;
let pool = create_pool(&db_config)
.await
.context("failed to connect to database")?;
migrate(&pool)
.await
.context("failed to run database migrations")?;
tracing::info!("Database connected and migrated");
// ── Configuration ─────────────────────────────────────────────────────────
let base_url = load_env_var("BASE_URL").unwrap_or_else(|| "http://localhost:9315".to_string());
let bind_addr =
load_env_var("SECRETS_MCP_BIND").unwrap_or_else(|| "127.0.0.1:9315".to_string());
// ── OAuth providers ───────────────────────────────────────────────────────
let google_config = load_oauth_config("GOOGLE", &base_url, "/auth/google/callback");
if google_config.is_none() {
tracing::warn!(
"No OAuth providers configured. Set GOOGLE_CLIENT_ID/GOOGLE_CLIENT_SECRET to enable login."
);
}
// ── Session store (PostgreSQL-backed) ─────────────────────────────────────
let session_store = PostgresStore::new(pool.clone());
session_store
.migrate()
.await
.context("failed to run session table migration")?;
// Prune expired rows every hour; task is aborted when the server shuts down.
let session_cleanup = tokio::spawn(
session_store
.clone()
.continuously_delete_expired(tokio::time::Duration::from_secs(3600)),
);
// Strict would drop the session cookie on redirect from Google → our origin (cross-site nav).
let session_layer = SessionManagerLayer::new(session_store)
.with_secure(base_url.starts_with("https://"))
.with_same_site(SameSite::Lax)
.with_expiry(Expiry::OnInactivity(time::Duration::days(14)));
// ── App state ─────────────────────────────────────────────────────────────
let app_state = AppState {
pool: pool.clone(),
google_config,
base_url: base_url.clone(),
http_client: reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(15))
.build()
.context("failed to build HTTP client")?,
};
// ── MCP service ───────────────────────────────────────────────────────────
let pool_arc = Arc::new(pool.clone());
let mcp_service = StreamableHttpService::new(
move || {
let p = pool_arc.clone();
Ok(SecretsService::new(p))
},
LocalSessionManager::default().into(),
Default::default(),
);
// ── Router ────────────────────────────────────────────────────────────────
let cors = CorsLayer::new()
.allow_origin(Any)
.allow_methods(Any)
.allow_headers(Any);
let router = Router::new()
.merge(web::web_router())
.nest_service("/mcp", mcp_service)
.layer(axum::middleware::from_fn(
logging::request_logging_middleware,
))
.layer(axum::middleware::from_fn_with_state(
pool,
auth::bearer_auth_middleware,
))
.layer(session_layer)
.layer(cors)
.with_state(app_state);
// ── Start server ──────────────────────────────────────────────────────────
let listener = tokio::net::TcpListener::bind(&bind_addr)
.await
.with_context(|| format!("failed to bind to {}", bind_addr))?;
tracing::info!("Secrets MCP Server listening on http://{}", bind_addr);
tracing::info!("MCP endpoint: {}/mcp", base_url);
axum::serve(
listener,
router.into_make_service_with_connect_info::<SocketAddr>(),
)
.with_graceful_shutdown(shutdown_signal())
.await
.context("server error")?;
session_cleanup.abort();
Ok(())
}
async fn shutdown_signal() {
tokio::signal::ctrl_c()
.await
.expect("failed to install CTRL+C signal handler");
tracing::info!("Shutting down gracefully...");
}

View File

@@ -0,0 +1,66 @@
use anyhow::{Context, Result};
use serde::Deserialize;
use super::{OAuthConfig, OAuthUserInfo};
#[derive(Deserialize)]
struct TokenResponse {
access_token: String,
#[allow(dead_code)]
token_type: String,
#[allow(dead_code)]
id_token: Option<String>,
}
#[derive(Deserialize)]
struct UserInfo {
sub: String,
email: Option<String>,
name: Option<String>,
picture: Option<String>,
}
/// 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
.post("https://oauth2.googleapis.com/token")
.form(&[
("code", code),
("client_id", &config.client_id),
("client_secret", &config.client_secret),
("redirect_uri", &config.redirect_uri),
("grant_type", "authorization_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")?;
let user: UserInfo = client
.get("https://openidconnect.googleapis.com/v1/userinfo")
.bearer_auth(&token_resp.access_token)
.send()
.await
.context("failed to fetch Google userinfo")?
.error_for_status()
.context("Google userinfo endpoint error")?
.json()
.await
.context("failed to parse Google userinfo")?;
Ok(OAuthUserInfo {
provider: "google".to_string(),
provider_id: user.sub,
email: user.email,
name: user.name,
avatar_url: user.picture,
})
}

View File

@@ -0,0 +1,45 @@
pub mod google;
pub mod wechat; // not yet implemented — placeholder for future WeChat integration
use serde::{Deserialize, Serialize};
/// Normalized OAuth user profile from any provider.
#[derive(Debug, Clone)]
pub struct OAuthUserInfo {
pub provider: String,
pub provider_id: String,
pub email: Option<String>,
pub name: Option<String>,
pub avatar_url: Option<String>,
}
/// OAuth provider configuration.
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct OAuthConfig {
pub client_id: String,
pub client_secret: String,
pub redirect_uri: String,
}
/// Build the Google authorization URL.
pub fn google_auth_url(config: &OAuthConfig, state: &str) -> String {
format!(
"https://accounts.google.com/o/oauth2/v2/auth\
?client_id={}\
&redirect_uri={}\
&response_type=code\
&scope=openid%20email%20profile\
&state={}\
&access_type=offline",
urlencoding::encode(&config.client_id),
urlencoding::encode(&config.redirect_uri),
urlencoding::encode(state),
)
}
pub fn random_state() -> String {
use rand::RngExt;
let mut bytes = [0u8; 16];
rand::rng().fill(&mut bytes);
bytes.iter().map(|b| format!("{:02x}", b)).collect()
}

View File

@@ -0,0 +1,18 @@
use super::{OAuthConfig, OAuthUserInfo};
/// WeChat OAuth — not yet implemented.
///
/// This module is a placeholder for future WeChat Open Platform integration.
/// When ready, implement `exchange_code` following the non-standard WeChat OAuth 2.0 flow:
/// - Token exchange uses a GET request (not POST)
/// - Preferred user identifier is `unionid` (cross-app), falling back to `openid`
/// - Docs: https://developers.weixin.qq.com/doc/oplatform/Website_App/WeChat_Login/Wechat_Login.html
use anyhow::{Result, bail};
#[allow(dead_code)]
pub async fn exchange_code(
_client: &reqwest::Client,
_config: &OAuthConfig,
_code: &str,
) -> Result<OAuthUserInfo> {
bail!("WeChat login is not yet implemented")
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<text x="16" y="23" text-anchor="middle" font-family="system-ui,Segoe UI,sans-serif" font-size="22" font-weight="700" fill="#58a6ff">S</text>
</svg>

After

Width:  |  Height:  |  Size: 212 B

View File

@@ -0,0 +1,28 @@
# Secrets MCP
> 给 AI 与自动化工具的简要说明。本站是 **secrets-mcp**Streamable HTTP **MCP**Model Context Protocol与 **Web 控制台** 的组合,用于在多租户场景下存储条目元数据与加密后的秘密字段;持久化在 PostgreSQL。用户通过 OAuth如已配置登录 WebMCP 调用使用 API Key 与加密相关请求头。
## 公开页面
- **`/`**:公开首页,说明安全架构(客户端密钥派生、密文存储、多租户与审计等),无需登录。
## 不应抓取或索引的内容
- **`/mcp`**MCP 流式 HTTP 端点JSON-RPC 等),**不是** HTML 文档,也不适合作为公开知识库来源。
- **`/api/*`**:会话或 API Key 相关的 HTTP API。
- **`/login`**:登录入口页(`noindex` / robots 通常 disallow
- **`/dashboard`、`/audit`、`/auth/*`、`/account/*`**:需浏览器会话,属于用户私有界面与 OAuth 流程。
## 给 AI 助手的实用提示
- 向用户说明连接方式时MCP 基址为 `{BASE_URL}/mcp``BASE_URL` 由部署方设置),通常需要 `Authorization: Bearer <api_key>`;读写加密秘密时还需按部署文档传递 `X-Encryption-Key` 等头(与客户端模式有关)。
- **不要编造**本实例的数据库 URL、OAuth 密钥、回调地址或任何凭据;一律以用户环境变量与运维文档为准。
- Web 端在浏览器内用密码短语派生密钥完成端到端加密MCP 路径下服务端可能在请求周期内临时使用客户端提供的密钥处理密文(架构细节见项目 README「加密架构」
## 延伸阅读
- 源码仓库:<https://gitea.refining.dev/refining/secrets>`README.md`、`AGENTS.md` 含环境变量、表结构与运维约定)。
## 关于本文件
- 遵循常见的 **`/llms.txt`** 约定,便于人类与 LLM 快速了解站点性质与抓取边界;同文可在 **`/ai.txt`** 获取。

View File

@@ -0,0 +1,31 @@
# Secrets MCP — robots.txt
# 本站为需登录的私密控制台与 MCP API以下路径请勿抓取以免浪费配额并避免误索引敏感端点。
# This host serves an authenticated dashboard and machine APIs; please skip crawling the paths below.
User-agent: *
Disallow: /mcp
Disallow: /api/
Disallow: /dashboard
Disallow: /audit
Disallow: /auth/
Disallow: /login
Disallow: /account/
# 首页 `/` 为公开安全说明页,允许抓取。
# 面向 AI / LLM 的机器可读站点说明Markdown/llms.txt
# Human & AI-readable site summary: /llms.txt (also /ai.txt)
User-agent: GPTBot
User-agent: Google-Extended
User-agent: anthropic-ai
User-agent: Claude-Web
User-agent: PerplexityBot
User-agent: Bytespider
Disallow: /mcp
Disallow: /api/
Disallow: /dashboard
Disallow: /audit
Disallow: /auth/
Disallow: /login
Disallow: /account/

View File

@@ -0,0 +1,155 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="icon" href="/favicon.svg?v={{ version }}" type="image/svg+xml">
<title>Secrets — Audit</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; --surface2: #21262d;
--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; }
.layout { display: flex; min-height: 100vh; }
.sidebar {
width: 220px; flex-shrink: 0; background: var(--surface); border-right: 1px solid var(--border);
padding: 24px 16px; display: flex; flex-direction: column; gap: 20px;
}
.sidebar-logo { font-family: 'JetBrains Mono', monospace; font-size: 16px; font-weight: 600;
color: var(--text); text-decoration: none; padding: 0 10px; }
.sidebar-logo span { color: var(--accent); }
.sidebar-menu { display: flex; flex-direction: column; gap: 6px; }
.sidebar-link {
padding: 10px 12px; border-radius: 8px; color: var(--text-muted); text-decoration: none;
border: 1px solid transparent; font-size: 13px; font-weight: 500;
}
.sidebar-link:hover { background: var(--surface2); color: var(--text); }
.sidebar-link.active {
background: rgba(88,166,255,0.12); color: var(--text); border-color: rgba(88,166,255,0.35);
}
.content-shell { flex: 1; min-width: 0; display: flex; flex-direction: column; }
.topbar {
background: var(--surface); border-bottom: 1px solid var(--border); padding: 0 24px;
display: flex; align-items: center; gap: 12px; min-height: 52px;
}
.topbar-spacer { flex: 1; }
.nav-user { font-size: 13px; color: var(--text-muted); }
.btn-sign-out {
padding: 5px 12px; border-radius: 6px; border: 1px solid var(--border);
background: none; color: var(--text); font-size: 12px; text-decoration: none; cursor: pointer;
}
.btn-sign-out:hover { background: var(--surface2); }
.main { padding: 32px 24px 40px; flex: 1; }
.card { background: var(--surface); border: 1px solid var(--border); border-radius: 12px;
padding: 24px; width: 100%; max-width: 1180px; margin: 0 auto; }
.card-title { font-size: 20px; font-weight: 600; margin-bottom: 8px; }
.card-subtitle { color: var(--text-muted); font-size: 13px; margin-bottom: 20px; }
.empty { color: var(--text-muted); font-size: 14px; padding: 20px 0; }
table { width: 100%; border-collapse: collapse; }
th, td { text-align: left; vertical-align: top; padding: 12px 10px; border-top: 1px solid var(--border); }
th { color: var(--text-muted); font-size: 12px; font-weight: 600; }
td { font-size: 13px; }
.mono { font-family: 'JetBrains Mono', monospace; }
.detail {
background: var(--bg); border: 1px solid var(--border); border-radius: 8px;
padding: 10px; white-space: pre-wrap; word-break: break-word; font-size: 12px;
max-width: 460px;
}
@media (max-width: 900px) {
.layout { flex-direction: column; }
.sidebar {
width: 100%; border-right: none; border-bottom: 1px solid var(--border);
padding: 16px; gap: 14px;
}
.sidebar-menu { flex-direction: row; }
.sidebar-link { flex: 1; text-align: center; }
.main { padding: 20px 12px 28px; }
.card { padding: 16px; }
.topbar { padding: 12px 16px; flex-wrap: wrap; }
table, thead, tbody, th, td, tr { display: block; }
thead { display: none; }
tr { border-top: 1px solid var(--border); padding: 12px 0; }
td { border-top: none; padding: 6px 0; }
td::before {
display: block; color: var(--text-muted); font-size: 11px;
margin-bottom: 4px; text-transform: uppercase;
}
td.col-time::before { content: "Time"; }
td.col-action::before { content: "Action"; }
td.col-target::before { content: "Target"; }
td.col-detail::before { content: "Detail"; }
.detail { max-width: none; }
}
</style>
</head>
<body>
<div class="layout">
<aside class="sidebar">
<a href="/dashboard" class="sidebar-logo"><span>secrets</span></a>
<nav class="sidebar-menu">
<a href="/dashboard" class="sidebar-link">MCP</a>
<a href="/entries" class="sidebar-link">条目</a>
<a href="/audit" class="sidebar-link active">审计</a>
</nav>
</aside>
<div class="content-shell">
<div class="topbar">
<span class="topbar-spacer"></span>
<span class="nav-user">{{ user_name }}{% if !user_email.is_empty() %} · {{ user_email }}{% endif %}</span>
<form action="/auth/logout" method="post" style="display:inline">
<button type="submit" class="btn-sign-out">退出</button>
</form>
</div>
<main class="main">
<section class="card">
<div class="card-title">我的审计</div>
<div class="card-subtitle">展示最近 100 条与当前用户相关的新审计记录。时间为浏览器本地时区。</div>
{% if entries.is_empty() %}
<div class="empty">暂无审计记录。</div>
{% else %}
<table>
<thead>
<tr>
<th>时间</th>
<th>动作</th>
<th>目标</th>
<th>详情</th>
</tr>
</thead>
<tbody>
{% for entry in entries %}
<tr>
<td class="col-time mono"><time class="audit-local-time" datetime="{{ entry.created_at_iso }}">{{ entry.created_at_iso }}</time></td>
<td class="col-action mono">{{ entry.action }}</td>
<td class="col-target mono">{{ entry.target }}</td>
<td class="col-detail"><pre class="detail">{{ entry.detail }}</pre></td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
</section>
</main>
</div>
</div>
<script>
(function () {
document.querySelectorAll('time.audit-local-time[datetime]').forEach(function (el) {
var raw = el.getAttribute('datetime');
var d = raw ? new Date(raw) : null;
if (d && !isNaN(d.getTime())) {
el.textContent = d.toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'medium' });
el.title = raw + ' (UTC)';
}
});
})();
</script>
</body>
</html>

View File

@@ -0,0 +1,940 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="icon" href="/favicon.svg?v={{ version }}" type="image/svg+xml">
<title>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; --surface2: #21262d;
--border: #30363d; --text: #e6edf3; --text-muted: #8b949e;
--accent: #58a6ff; --accent-hover: #79b8ff;
--danger: #f85149; --success: #3fb950; --warn: #d29922;
}
body { background: var(--bg); color: var(--text); font-family: 'Inter', sans-serif; min-height: 100vh; }
.layout { display: flex; min-height: 100vh; }
.sidebar {
width: 220px; flex-shrink: 0; background: var(--surface); border-right: 1px solid var(--border);
padding: 24px 16px; display: flex; flex-direction: column; gap: 20px;
}
.sidebar-logo { font-family: 'JetBrains Mono', monospace; font-size: 16px; font-weight: 600;
color: var(--text); text-decoration: none; padding: 0 10px; }
.sidebar-logo span { color: var(--accent); }
.sidebar-menu { display: flex; flex-direction: column; gap: 6px; }
.sidebar-link {
padding: 10px 12px; border-radius: 8px; color: var(--text-muted); text-decoration: none;
border: 1px solid transparent; font-size: 13px; font-weight: 500;
}
.sidebar-link:hover { background: var(--surface2); color: var(--text); }
.sidebar-link.active {
background: rgba(88,166,255,0.12); color: var(--text); border-color: rgba(88,166,255,0.35);
}
.content-shell { flex: 1; min-width: 0; display: flex; flex-direction: column; }
.topbar {
background: var(--surface); border-bottom: 1px solid var(--border); padding: 0 24px;
display: flex; align-items: center; gap: 12px; min-height: 52px;
}
.topbar-spacer { flex: 1; }
.nav-user { font-size: 13px; color: var(--text-muted); }
.lang-bar { display: flex; gap: 2px; background: var(--surface2); border-radius: 6px; padding: 2px; }
.lang-btn { padding: 3px 9px; border: none; background: none; color: var(--text-muted);
font-size: 12px; cursor: pointer; border-radius: 4px; }
.lang-btn.active { background: var(--border); color: var(--text); }
.btn-sign-out { padding: 5px 12px; border-radius: 6px; border: 1px solid var(--border);
background: none; color: var(--text); font-size: 12px; cursor: pointer; }
.btn-sign-out:hover { background: var(--surface2); }
/* Main content column */
.main { display: flex; flex-direction: column; align-items: center;
padding: 24px 20px 8px; min-height: 0; }
.app-footer {
margin-top: auto;
text-align: center;
padding: 4px 20px 12px;
font-size: 12px;
color: #9da7b3;
font-family: 'JetBrains Mono', monospace;
}
.card { background: var(--surface); border: 1px solid var(--border); border-radius: 12px;
padding: 24px; width: 100%; max-width: 980px; }
.card-title { font-size: 18px; font-weight: 600; margin-bottom: 24px; }
/* Form */
.field { margin-bottom: 12px; }
.field label { display: block; font-size: 12px; color: var(--text-muted); margin-bottom: 5px; }
.field input { width: 100%; background: var(--bg); border: 1px solid var(--border);
color: var(--text); padding: 9px 12px; border-radius: 6px;
font-size: 13px; outline: none; }
.field input:focus { border-color: var(--accent); }
.pw-field { position: relative; }
.pw-field > input { padding-right: 42px; }
.pw-toggle {
position: absolute; right: 6px; top: 50%; transform: translateY(-50%);
display: flex; align-items: center; justify-content: center;
width: 32px; height: 32px; border: none; border-radius: 6px;
background: transparent; color: var(--text-muted); cursor: pointer;
}
.pw-toggle:hover { color: var(--text); background: var(--surface2); }
.pw-toggle:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.pw-icon svg { display: block; }
.pw-icon.hidden { display: none; }
.error-msg { color: var(--danger); font-size: 12px; margin-top: 6px; display: none; }
/* Buttons */
.btn-primary { display: inline-flex; align-items: center; gap: 6px; width: 100%;
justify-content: center; padding: 10px 20px; border-radius: 7px;
border: none; background: var(--accent); color: #0d1117;
font-size: 14px; font-weight: 600; cursor: pointer; transition: background 0.15s; }
.btn-primary:hover { background: var(--accent-hover); }
.btn-primary:disabled { opacity: 0.5; cursor: not-allowed; }
.btn-sm { display: inline-flex; align-items: center; gap: 4px; padding: 5px 12px;
border-radius: 5px; border: 1px solid var(--border); background: none;
color: var(--text-muted); font-size: 12px; cursor: pointer; }
.btn-sm:hover { color: var(--text); border-color: var(--text-muted); }
.btn-copy { display: flex; align-items: center; gap: 8px; width: 100%; justify-content: center;
padding: 11px 20px; border-radius: 7px; border: 1px solid var(--success);
background: rgba(63,185,80,0.1); color: var(--success);
font-size: 14px; font-weight: 600; cursor: pointer; transition: all 0.15s; }
.btn-copy:hover { background: rgba(63,185,80,0.2); }
.btn-copy.copied { background: var(--success); color: #0d1117; border-color: var(--success); }
/* Config format switcher */
.config-tabs { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; margin-bottom: 12px; }
.config-tab { padding: 12px 14px; border-radius: 10px; border: 1px solid var(--border);
background: var(--surface2); color: var(--text-muted); cursor: pointer;
font-family: inherit; text-align: left; transition: border-color 0.15s, background 0.15s, transform 0.15s; }
.config-tab:hover { color: var(--text); border-color: var(--accent); transform: translateY(-1px); }
.config-tab.active { background: rgba(88,166,255,0.1); color: var(--text); border-color: var(--accent); }
.config-tab-title { display: block; font-size: 13px; font-weight: 600; color: inherit; }
/* Config box */
.config-wrap { position: relative; margin-bottom: 14px; }
.config-box { background: var(--bg); border: 1px solid var(--border); border-radius: 8px;
padding: 16px; font-family: 'JetBrains Mono', monospace; font-size: 11px;
line-height: 1.7; color: var(--text); overflow-x: auto; white-space: pre; }
.config-box.locked { color: var(--text-muted); filter: blur(3px); user-select: none;
pointer-events: none; }
.config-key { color: #79c0ff; }
.config-str { color: #a5d6ff; }
.config-val { color: var(--accent); }
/* Divider */
.divider { border: none; border-top: 1px solid var(--border); margin: 20px 0; }
/* Actions row */
.actions-row { display: flex; gap: 8px; flex-wrap: wrap; justify-content: center; }
/* Spinner */
.spinner { display: inline-block; width: 14px; height: 14px; border: 2px solid rgba(13,17,23,0.3);
border-top-color: #0d1117; border-radius: 50%; animation: spin 0.7s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
/* Modal */
.modal-bd { display: none; position: fixed; inset: 0; background: rgba(0,0,0,0.75);
z-index: 100; align-items: center; justify-content: center; }
.modal-bd.open { display: flex; }
.modal { background: var(--surface); border: 1px solid var(--border); border-radius: 12px;
padding: 28px; width: 100%; max-width: 420px; }
.modal h3 { font-size: 16px; font-weight: 600; margin-bottom: 16px; }
.modal-actions { display: flex; gap: 8px; margin-top: 16px; }
.btn-modal-ok { flex: 1; padding: 8px; border-radius: 6px; border: none;
background: var(--accent); color: #0d1117; font-size: 13px;
font-weight: 600; cursor: pointer; }
.btn-modal-ok:hover { background: var(--accent-hover); }
.btn-modal-cancel { padding: 8px 16px; border-radius: 6px; border: 1px solid var(--border);
background: none; color: var(--text); font-size: 13px; cursor: pointer; }
.btn-modal-cancel:hover { background: var(--surface2); }
@media (max-width: 900px) {
.layout { flex-direction: column; }
.sidebar {
width: 100%; border-right: none; border-bottom: 1px solid var(--border);
padding: 16px; gap: 14px;
}
.sidebar-menu { flex-direction: row; }
.sidebar-link { flex: 1; text-align: center; }
}
@media (max-width: 720px) {
.config-tabs { grid-template-columns: 1fr; }
.topbar { padding: 12px 16px; flex-wrap: wrap; }
.main { padding: 16px 12px 6px; }
.app-footer { padding: 4px 12px 10px; }
.card { padding: 18px; }
}
</style>
</head>
<body data-has-passphrase="{{ has_passphrase }}" data-base-url="{{ base_url }}">
<div class="layout">
<aside class="sidebar">
<a href="/dashboard" class="sidebar-logo"><span>secrets</span></a>
<nav class="sidebar-menu">
<a href="/dashboard" class="sidebar-link active">MCP</a>
<a href="/entries" class="sidebar-link">条目</a>
<a href="/audit" class="sidebar-link">审计</a>
</nav>
</aside>
<div class="content-shell">
<div class="topbar">
<span class="topbar-spacer"></span>
<span class="nav-user">{{ user_name }}{% if !user_email.is_empty() %} · {{ user_email }}{% endif %}</span>
<div class="lang-bar">
<button class="lang-btn" onclick="setLang('zh-CN')"></button>
<button class="lang-btn" onclick="setLang('zh-TW')"></button>
<button class="lang-btn" onclick="setLang('en')">EN</button>
</div>
<form action="/auth/logout" method="post" style="display:inline">
<button type="submit" class="btn-sign-out" data-i18n="signOut">退出</button>
</form>
</div>
<div class="main">
<div class="card">
<!-- ── Locked state ──────────────────────────────────────────────────── -->
<div id="locked-view">
<div class="card-title" data-i18n="lockedTitle">获取 MCP 配置</div>
<!-- placeholder config -->
<div class="config-wrap">
<div class="config-box locked" id="placeholder-config"></div>
</div>
<!-- Setup form (no passphrase yet) -->
<div id="setup-form" style="display:none">
<div class="field">
<label data-i18n="labelPassphrase">加密密码</label>
<div class="pw-field">
<input type="password" id="setup-pass1" data-i18n-ph="phPassphrase" autocomplete="new-password">
<button type="button" class="pw-toggle" data-target="setup-pass1" aria-pressed="false"
onclick="togglePwVisibility(this)" aria-label="">
<span class="pw-icon pw-icon-show" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg></span>
<span class="pw-icon pw-icon-hide hidden" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24"/><line x1="1" y1="1" x2="23" y2="23"/></svg></span>
</button>
</div>
</div>
<div class="field">
<label data-i18n="labelConfirm">确认密码</label>
<div class="pw-field">
<input type="password" id="setup-pass2" data-i18n-ph="phConfirm" autocomplete="new-password">
<button type="button" class="pw-toggle" data-target="setup-pass2" aria-pressed="false"
onclick="togglePwVisibility(this)" aria-label="">
<span class="pw-icon pw-icon-show" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg></span>
<span class="pw-icon pw-icon-hide hidden" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24"/><line x1="1" y1="1" x2="23" y2="23"/></svg></span>
</button>
</div>
</div>
<div class="error-msg" id="setup-error"></div>
<button class="btn-primary" id="setup-btn" onclick="doSetup()">
<span data-i18n="btnSetup">设置并获取配置</span>
</button>
<p style="font-size:11px;color:var(--text-muted);text-align:center;margin-top:10px" data-i18n="setupNote">
密码不会上传服务器。遗忘后数据将无法恢复。
</p>
</div>
<!-- Unlock form (passphrase already set) -->
<div id="unlock-form" style="display:none">
<div class="field">
<label data-i18n="labelPassphrase">加密密码</label>
<div class="pw-field">
<input type="password" id="unlock-pass" data-i18n-ph="phPassphrase" autocomplete="current-password">
<button type="button" class="pw-toggle" data-target="unlock-pass" aria-pressed="false"
onclick="togglePwVisibility(this)" aria-label="">
<span class="pw-icon pw-icon-show" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg></span>
<span class="pw-icon pw-icon-hide hidden" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24"/><line x1="1" y1="1" x2="23" y2="23"/></svg></span>
</button>
</div>
</div>
<div class="error-msg" id="unlock-error"></div>
<button class="btn-primary" id="unlock-btn" onclick="doUnlock()">
<span data-i18n="btnUnlock">解锁并获取配置</span>
</button>
</div>
</div>
<!-- ── Unlocked state ────────────────────────────────────────────────── -->
<div id="unlocked-view" style="display:none">
<div class="card-title" data-i18n="unlockedTitle" style="margin-bottom:16px">MCP 配置</div>
<div class="config-tabs" role="tablist" aria-label="Config format">
<button type="button" class="config-tab active" role="tab" id="tab-mcp" aria-selected="true"
onclick="setConfigFormat('mcp')">
<span class="config-tab-title" data-i18n="tabMcp">Cursor、Claude Code、Codex、Gemini CLI</span>
</button>
<button type="button" class="config-tab" role="tab" id="tab-opencode" aria-selected="false"
onclick="setConfigFormat('opencode')">
<span class="config-tab-title" data-i18n="tabOpencode">OpenCode</span>
</button>
</div>
<div class="config-wrap">
<pre class="config-box" id="real-config"></pre>
</div>
<div style="display:flex;gap:10px;flex-wrap:wrap">
<button class="btn-copy" id="copy-full-btn" onclick="copyFullConfig()" style="flex:1">
<span id="copy-full-text">复制完整 mcp.json</span>
</button>
<button class="btn-copy" id="copy-secrets-btn" onclick="copySecretsConfig()" style="flex:1">
<span id="copy-secrets-text">仅复制 secrets 节点</span>
</button>
</div>
<hr class="divider">
<div class="actions-row">
<button class="btn-sm" onclick="clearAndLock()" data-i18n="btnClear">清除密钥</button>
<button class="btn-sm" onclick="openChangeModal()" data-i18n="btnChangePass">更换密码</button>
<button class="btn-sm" onclick="confirmRegenerate()" data-i18n="btnRegen">重置 API Key</button>
</div>
</div>
</div>
</div>
<footer class="app-footer">{{ version }}</footer>
</div>
</div>
</div>
<!-- ── Change passphrase modal ──────────────────────────────────────────────── -->
<div class="modal-bd" id="change-modal">
<div class="modal">
<h3 data-i18n="changeTitle">更换密码</h3>
<div class="field">
<label data-i18n="labelNew">新密码</label>
<div class="pw-field">
<input type="password" id="change-pass1" data-i18n-ph="phPassphrase" autocomplete="new-password">
<button type="button" class="pw-toggle" data-target="change-pass1" aria-pressed="false"
onclick="togglePwVisibility(this)" aria-label="">
<span class="pw-icon pw-icon-show" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg></span>
<span class="pw-icon pw-icon-hide hidden" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24"/><line x1="1" y1="1" x2="23" y2="23"/></svg></span>
</button>
</div>
</div>
<div class="field">
<label data-i18n="labelConfirm">确认</label>
<div class="pw-field">
<input type="password" id="change-pass2" data-i18n-ph="phConfirm" autocomplete="new-password">
<button type="button" class="pw-toggle" data-target="change-pass2" aria-pressed="false"
onclick="togglePwVisibility(this)" aria-label="">
<span class="pw-icon pw-icon-show" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg></span>
<span class="pw-icon pw-icon-hide hidden" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24"/><line x1="1" y1="1" x2="23" y2="23"/></svg></span>
</button>
</div>
</div>
<div class="error-msg" id="change-error"></div>
<div class="modal-actions">
<button class="btn-modal-ok" id="change-btn" onclick="doChange()" data-i18n="btnChange">确认更换</button>
<button class="btn-modal-cancel" onclick="closeChangeModal()" data-i18n="btnCancel">取消</button>
</div>
</div>
</div>
<script>
// ── i18n ───────────────────────────────────────────────────────────────────────
const T = {
'zh-CN': {
signOut: '退出',
lockedTitle: '获取 MCP 配置',
labelPassphrase: '加密密码',
labelConfirm: '确认密码',
labelNew: '新密码',
phPassphrase: '输入密码…',
phConfirm: '再次输入…',
btnSetup: '设置并获取配置',
btnUnlock: '解锁并获取配置',
setupNote: '密码不会上传服务器。遗忘后数据将无法恢复。',
errEmpty: '密码不能为空。',
errShort: '密码至少需要 8 个字符。',
errMismatch: '两次输入不一致。',
errWrong: '密码错误,请重试。',
unlockedTitle: 'MCP 配置',
tabMcp: 'Cursor、Claude Code、Codex、Gemini CLI',
tabOpencode: 'OpenCode',
btnCopyFull: '复制完整 mcp.json',
btnCopySecrets: '仅复制 secrets 节点',
btnCopyFullOpencode: '复制完整 mcp.json',
btnCopySecretsOpencode: '仅复制 secrets 节点',
btnCopied: '已复制!',
btnClear: '清除密钥',
btnChangePass: '更换密码',
btnRegen: '重置 API Key',
changeTitle: '更换密码',
btnChange: '确认更换',
btnCancel: '取消',
regenConfirm: '重置 API Key 后,当前 Key 立即失效,需同步更新 AI 客户端配置。确认继续?',
regenFailed: '重置失败,请刷新页面重试。',
ariaShowPw: '显示密码',
ariaHidePw: '隐藏密码',
},
'zh-TW': {
signOut: '登出',
lockedTitle: '取得 MCP 設定',
labelPassphrase: '加密密碼',
labelConfirm: '確認密碼',
labelNew: '新密碼',
phPassphrase: '輸入密碼…',
phConfirm: '再次輸入…',
btnSetup: '設定並取得設定',
btnUnlock: '解鎖並取得設定',
setupNote: '密碼不會上傳伺服器。遺忘後資料將無法復原。',
errEmpty: '密碼不能為空。',
errShort: '密碼至少需要 8 個字元。',
errMismatch: '兩次輸入不一致。',
errWrong: '密碼錯誤,請重試。',
unlockedTitle: 'MCP 設定',
tabMcp: 'Cursor、Claude Code、Codex、Gemini CLI',
tabOpencode: 'OpenCode',
btnCopyFull: '複製完整 mcp.json',
btnCopySecrets: '僅複製 secrets 節點',
btnCopyFullOpencode: '複製完整 mcp.json',
btnCopySecretsOpencode: '僅複製 secrets 節點',
btnCopied: '已複製!',
btnClear: '清除密鑰',
btnChangePass: '更換密碼',
btnRegen: '重置 API Key',
changeTitle: '更換密碼',
btnChange: '確認更換',
btnCancel: '取消',
regenConfirm: '重置 API Key 後,目前 Key 立即失效,需同步更新 AI 用戶端設定。確認繼續?',
regenFailed: '重置失敗,請重新整理頁面再試。',
ariaShowPw: '顯示密碼',
ariaHidePw: '隱藏密碼',
},
'en': {
signOut: 'Sign out',
lockedTitle: 'Get MCP Config',
labelPassphrase: 'Encryption password',
labelConfirm: 'Confirm password',
labelNew: 'New password',
phPassphrase: 'Enter password…',
phConfirm: 'Repeat password…',
btnSetup: 'Set up & get config',
btnUnlock: 'Unlock & get config',
setupNote: 'Your password never leaves this device. If forgotten, encrypted data cannot be recovered.',
errEmpty: 'Password cannot be empty.',
errShort: 'Password must be at least 8 characters.',
errMismatch: 'Passwords do not match.',
errWrong: 'Incorrect password, please try again.',
unlockedTitle: 'MCP Config',
tabMcp: 'Cursor, Claude Code, Codex, Gemini CLI',
tabOpencode: 'OpenCode',
btnCopyFull: 'Copy full mcp.json',
btnCopySecrets: 'Copy only secrets node',
btnCopyFullOpencode: 'Copy full mcp.json',
btnCopySecretsOpencode: 'Copy only secrets node',
btnCopied: 'Copied!',
btnClear: 'Clear key',
btnChangePass: 'Change password',
btnRegen: 'Reset API key',
changeTitle: 'Change password',
btnChange: 'Confirm',
btnCancel: 'Cancel',
regenConfirm: 'Resetting will immediately invalidate your current API key. You will need to update your AI client config. Continue?',
regenFailed: 'Reset failed. Please refresh and try again.',
ariaShowPw: 'Show password',
ariaHidePw: 'Hide password',
}
};
let currentLang = localStorage.getItem('lang') || 'zh-CN';
function t(key) { return T[currentLang][key] || T['en'][key] || key; }
function applyLang() {
document.documentElement.lang = currentLang;
document.querySelectorAll('[data-i18n]').forEach(el => {
const key = el.getAttribute('data-i18n');
el.textContent = t(key);
});
document.querySelectorAll('[data-i18n-ph]').forEach(el => {
el.placeholder = t(el.getAttribute('data-i18n-ph'));
});
document.querySelectorAll('.lang-btn').forEach(btn => {
const map = { 'zh-CN': '简', 'zh-TW': '繁', 'en': 'EN' };
btn.classList.toggle('active', btn.textContent === map[currentLang]);
});
// Rebuild placeholder config (language affects nothing but triggers re-render)
renderPlaceholderConfig();
// Rebuild real config if unlocked
if (currentEncKey && currentApiKey) renderRealConfig();
syncPwToggleI18n();
syncConfigFormatUi();
}
function setLang(lang) {
currentLang = lang;
localStorage.setItem('lang', lang);
applyLang();
}
function syncPwToggleI18n() {
document.querySelectorAll('.pw-toggle').forEach(btn => {
const input = document.getElementById(btn.getAttribute('data-target'));
if (!input) return;
const visible = input.type === 'text';
btn.setAttribute('aria-pressed', visible ? 'true' : 'false');
btn.setAttribute('aria-label', visible ? t('ariaHidePw') : t('ariaShowPw'));
const showIc = btn.querySelector('.pw-icon-show');
const hideIc = btn.querySelector('.pw-icon-hide');
if (showIc) showIc.classList.toggle('hidden', visible);
if (hideIc) hideIc.classList.toggle('hidden', !visible);
});
}
function togglePwVisibility(btn) {
const input = document.getElementById(btn.getAttribute('data-target'));
if (!input) return;
input.type = input.type === 'password' ? 'text' : 'password';
syncPwToggleI18n();
}
// ── Constants ──────────────────────────────────────────────────────────────────
const HAS_PASSPHRASE = document.body.dataset.hasPassphrase === 'true';
const BASE_URL = document.body.dataset.baseUrl;
const KEY_CHECK_PLAINTEXT = 'secrets-mcp-key-check';
const PBKDF2_ITERATIONS = 600000;
const ENC = new TextEncoder();
let currentEncKey = null;
let currentApiKey = null;
/** @type {'mcp' | 'opencode'} */
let configFormat = 'mcp';
function redirectLoginExpired() {
sessionStorage.removeItem('enc_key');
currentEncKey = null;
currentApiKey = null;
window.location.replace('/');
}
/** Like fetch; on 401 clears local state and navigates to login (await does not complete). */
async function fetchAuth(input, init) {
const resp = await fetch(input, init);
if (resp.status === 401) {
redirectLoginExpired();
await new Promise(() => {});
}
return resp;
}
// ── Placeholder config ─────────────────────────────────────────────────────────
function renderPlaceholderConfig() {
document.getElementById('placeholder-config').textContent =
buildConfigText('sk_' + '•'.repeat(64), '•'.repeat(64));
}
function buildBaseServerConfig(apiKey, encKey) {
return {
url: BASE_URL + '/mcp',
headers: {
Authorization: 'Bearer ' + apiKey,
'X-Encryption-Key': encKey
}
};
}
function buildSecretsEntryObject(apiKey, encKey) {
return buildBaseServerConfig(apiKey, encKey);
}
function buildConfigText(apiKey, encKey) {
return JSON.stringify({ mcpServers: { secrets: buildSecretsEntryObject(apiKey, encKey) } }, null, 2);
}
function buildSecretsConfigText(apiKey, encKey) {
const wrapped = JSON.stringify({
secrets: buildSecretsEntryObject(apiKey, encKey)
}, null, 2);
const lines = wrapped.split('\n');
return lines.length < 3 ? wrapped : lines.slice(1, -1).join('\n');
}
/** OpenCode: local stdio bridge to Streamable HTTP MCP (mcp-remote --transport http-only). */
function buildOpencodeEntry(apiKey, encKey) {
return {
type: 'local',
command: [
'npx', '-y', 'mcp-remote',
BASE_URL + '/mcp',
'--header',
'Authorization: Bearer ' + apiKey,
'--header',
'X-Encryption-Key: ' + encKey,
'--transport',
'http-only'
]
};
}
/** Full OpenCode config: MCP servers live under top-level `mcp`. */
function buildOpencodeConfigText(apiKey, encKey) {
return JSON.stringify({ mcp: { secrets: buildOpencodeEntry(apiKey, encKey) } }, null, 2);
}
/** Strip outer `{` `}` so user can paste `secrets` under an existing `mcp` object. */
function buildOpencodeMergeSnippet(apiKey, encKey) {
const wrapped = JSON.stringify({ secrets: buildOpencodeEntry(apiKey, encKey) }, null, 2);
const lines = wrapped.split('\n');
return lines.length < 3 ? wrapped : lines.slice(1, -1).join('\n');
}
function getCopyFullKey() {
return 'btnCopyFull';
}
function getCopySecretsKey() {
return 'btnCopySecrets';
}
const CONFIG_FORMAT_STORAGE = 'dash_config_format';
function setConfigFormat(fmt) {
configFormat = fmt;
try { sessionStorage.setItem(CONFIG_FORMAT_STORAGE, fmt); } catch (_) {}
syncConfigFormatUi();
if (currentEncKey && currentApiKey) renderRealConfig();
}
/** Refresh tabs, format hint, and copy button labels (after language change or tab switch). */
function syncConfigFormatUi() {
const uv = document.getElementById('unlocked-view');
if (!uv || uv.style.display === 'none') return;
const tabMcp = document.getElementById('tab-mcp');
const tabOc = document.getElementById('tab-opencode');
if (tabMcp && tabOc) {
tabMcp.classList.toggle('active', configFormat === 'mcp');
tabOc.classList.toggle('active', configFormat === 'opencode');
tabMcp.setAttribute('aria-selected', configFormat === 'mcp' ? 'true' : 'false');
tabOc.setAttribute('aria-selected', configFormat === 'opencode' ? 'true' : 'false');
}
const cf = document.getElementById('copy-full-text');
const cs = document.getElementById('copy-secrets-text');
if (cf) cf.textContent = t(getCopyFullKey());
if (cs) cs.textContent = t(getCopySecretsKey());
}
// ── Unlock / Setup flow ───────────────────────────────────────────────────────
function showLockedView() {
document.getElementById('locked-view').style.display = '';
document.getElementById('unlocked-view').style.display = 'none';
if (HAS_PASSPHRASE) {
document.getElementById('setup-form').style.display = 'none';
document.getElementById('unlock-form').style.display = '';
setTimeout(() => document.getElementById('unlock-pass').focus(), 50);
} else {
document.getElementById('setup-form').style.display = '';
document.getElementById('unlock-form').style.display = 'none';
setTimeout(() => document.getElementById('setup-pass1').focus(), 50);
}
}
async function showUnlockedView(encKeyHex, apiKey) {
currentEncKey = encKeyHex;
currentApiKey = apiKey;
sessionStorage.setItem('enc_key', encKeyHex);
renderRealConfig();
document.getElementById('locked-view').style.display = 'none';
document.getElementById('unlocked-view').style.display = '';
syncConfigFormatUi();
}
function renderRealConfig() {
if (!currentApiKey || !currentEncKey) return;
const text = configFormat === 'mcp'
? buildConfigText(currentApiKey, currentEncKey)
: buildOpencodeConfigText(currentApiKey, currentEncKey);
document.getElementById('real-config').textContent = text;
}
function clearAndLock() {
sessionStorage.removeItem('enc_key');
currentEncKey = null;
currentApiKey = null;
showLockedView();
}
// ── Web Crypto helpers ─────────────────────────────────────────────────────────
async function deriveKey(passphrase, saltBytes, extractable = false) {
const km = await crypto.subtle.importKey('raw', ENC.encode(passphrase), 'PBKDF2', false, ['deriveKey']);
return crypto.subtle.deriveKey(
{ name: 'PBKDF2', salt: saltBytes, iterations: PBKDF2_ITERATIONS, hash: 'SHA-256' },
km, { name: 'AES-GCM', length: 256 }, extractable, ['encrypt', 'decrypt']
);
}
async function exportKeyHex(cryptoKey) {
const raw = await crypto.subtle.exportKey('raw', cryptoKey);
return Array.from(new Uint8Array(raw)).map(b => b.toString(16).padStart(2, '0')).join('');
}
function hexToBytes(hex) {
const b = new Uint8Array(hex.length / 2);
for (let i = 0; i < hex.length; i += 2) b[i / 2] = parseInt(hex.slice(i, i + 2), 16);
return b;
}
function bytesToHex(bytes) {
return Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join('');
}
async function encryptKeyCheck(cryptoKey) {
const nonce = crypto.getRandomValues(new Uint8Array(12));
const ct = await crypto.subtle.encrypt({ name: 'AES-GCM', iv: nonce }, cryptoKey, ENC.encode(KEY_CHECK_PLAINTEXT));
const out = new Uint8Array(12 + ct.byteLength);
out.set(nonce); out.set(new Uint8Array(ct), 12);
return bytesToHex(out);
}
async function verifyKeyCheck(cryptoKey, keyCheckHex) {
try {
const b = hexToBytes(keyCheckHex);
const plain = await crypto.subtle.decrypt({ name: 'AES-GCM', iv: b.slice(0, 12) }, cryptoKey, b.slice(12));
return new TextDecoder().decode(plain) === KEY_CHECK_PLAINTEXT;
} catch { return false; }
}
// ── Passphrase setup (first time) ─────────────────────────────────────────────
function setBtnLoading(id, loading, labelKey) {
const btn = document.getElementById(id);
btn.disabled = loading;
btn.innerHTML = loading
? '<span class="spinner"></span>'
: `<span data-i18n="${labelKey}">${t(labelKey)}</span>`;
}
async function doSetup() {
const pass1 = document.getElementById('setup-pass1').value;
const pass2 = document.getElementById('setup-pass2').value;
const errEl = document.getElementById('setup-error');
errEl.style.display = 'none';
if (!pass1) { showErr(errEl, t('errEmpty')); return; }
if (pass1.length < 8) { showErr(errEl, t('errShort')); return; }
if (pass1 !== pass2) { showErr(errEl, t('errMismatch')); return; }
setBtnLoading('setup-btn', true, 'btnSetup');
try {
const salt = crypto.getRandomValues(new Uint8Array(32));
const cryptoKey = await deriveKey(pass1, salt, true);
const keyCheckHex = await encryptKeyCheck(cryptoKey);
const hexKey = await exportKeyHex(cryptoKey);
const resp = await fetchAuth('/api/key-setup', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
salt: bytesToHex(salt),
key_check: keyCheckHex,
params: { alg: 'pbkdf2-sha256', iterations: PBKDF2_ITERATIONS }
})
});
if (!resp.ok) throw new Error('HTTP ' + resp.status);
const apiKey = await fetchApiKey();
await showUnlockedView(hexKey, apiKey);
} catch (e) {
showErr(errEl, 'Error: ' + e.message);
} finally {
setBtnLoading('setup-btn', false, 'btnSetup');
}
}
// ── Passphrase unlock ──────────────────────────────────────────────────────────
async function doUnlock() {
const pass = document.getElementById('unlock-pass').value;
const errEl = document.getElementById('unlock-error');
errEl.style.display = 'none';
if (!pass) { showErr(errEl, t('errEmpty')); return; }
setBtnLoading('unlock-btn', true, 'btnUnlock');
try {
const saltResp = await fetchAuth('/api/key-salt');
if (!saltResp.ok) throw new Error('HTTP ' + saltResp.status);
const saltData = await saltResp.json();
const cryptoKey = await deriveKey(pass, hexToBytes(saltData.salt), true);
const valid = await verifyKeyCheck(cryptoKey, saltData.key_check);
if (!valid) { showErr(errEl, t('errWrong')); return; }
const hexKey = await exportKeyHex(cryptoKey);
const apiKey = await fetchApiKey();
await showUnlockedView(hexKey, apiKey);
} catch (e) {
showErr(errEl, 'Error: ' + e.message);
} finally {
setBtnLoading('unlock-btn', false, 'btnUnlock');
}
}
// ── Copy config ────────────────────────────────────────────────────────────────
async function copyFullConfig() {
await copyWithFeedback(
document.getElementById('real-config').textContent,
'copy-full-btn',
'copy-full-text',
getCopyFullKey()
);
}
async function copySecretsConfig() {
const snippet = configFormat === 'mcp'
? buildSecretsConfigText(currentApiKey, currentEncKey)
: buildOpencodeMergeSnippet(currentApiKey, currentEncKey);
await copyWithFeedback(
snippet,
'copy-secrets-btn',
'copy-secrets-text',
getCopySecretsKey()
);
}
async function copyWithFeedback(text, btnId, textId, resetLabelKey) {
await navigator.clipboard.writeText(text);
const btn = document.getElementById(btnId);
const textEl = document.getElementById(textId);
btn.classList.add('copied');
textEl.textContent = t('btnCopied');
setTimeout(() => {
btn.classList.remove('copied');
textEl.textContent = t(resetLabelKey);
}, 2500);
}
// ── Reset API key ──────────────────────────────────────────────────────────────
async function confirmRegenerate() {
if (!confirm(t('regenConfirm'))) return;
try {
const resp = await fetchAuth('/api/apikey/regenerate', { method: 'POST' });
if (!resp.ok) throw new Error();
const data = await resp.json();
currentApiKey = data.api_key;
renderRealConfig();
} catch {
alert(t('regenFailed'));
}
}
// ── Change passphrase modal ────────────────────────────────────────────────────
function openChangeModal() {
document.getElementById('change-pass1').value = '';
document.getElementById('change-pass2').value = '';
document.getElementById('change-pass1').type = 'password';
document.getElementById('change-pass2').type = 'password';
document.getElementById('change-error').style.display = 'none';
document.getElementById('change-modal').classList.add('open');
syncPwToggleI18n();
setTimeout(() => document.getElementById('change-pass1').focus(), 50);
}
function closeChangeModal() {
document.getElementById('change-modal').classList.remove('open');
}
async function doChange() {
const pass1 = document.getElementById('change-pass1').value;
const pass2 = document.getElementById('change-pass2').value;
const errEl = document.getElementById('change-error');
errEl.style.display = 'none';
if (!pass1) { showErr(errEl, t('errEmpty')); return; }
if (pass1.length < 8) { showErr(errEl, t('errShort')); return; }
if (pass1 !== pass2) { showErr(errEl, t('errMismatch')); return; }
const btn = document.getElementById('change-btn');
btn.disabled = true;
btn.innerHTML = '<span class="spinner" style="border-top-color:#0d1117"></span>';
try {
const salt = crypto.getRandomValues(new Uint8Array(32));
const cryptoKey = await deriveKey(pass1, salt, true);
const keyCheckHex = await encryptKeyCheck(cryptoKey);
const hexKey = await exportKeyHex(cryptoKey);
const resp = await fetchAuth('/api/key-setup', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
salt: bytesToHex(salt),
key_check: keyCheckHex,
params: { alg: 'pbkdf2-sha256', iterations: PBKDF2_ITERATIONS }
})
});
if (!resp.ok) throw new Error('HTTP ' + resp.status);
currentEncKey = hexKey;
sessionStorage.setItem('enc_key', hexKey);
renderRealConfig();
closeChangeModal();
} catch (e) {
showErr(errEl, 'Error: ' + e.message);
} finally {
btn.disabled = false;
btn.textContent = t('btnChange');
}
}
// ── Fetch API key ──────────────────────────────────────────────────────────────
async function fetchApiKey() {
const resp = await fetchAuth('/api/apikey');
if (!resp.ok) throw new Error('Failed to load API key');
const data = await resp.json();
return data.api_key;
}
// ── Helpers ────────────────────────────────────────────────────────────────────
function showErr(el, msg) {
el.textContent = msg;
el.style.display = 'block';
}
// ── Keyboard shortcuts ─────────────────────────────────────────────────────────
document.addEventListener('keydown', e => {
if (e.key === 'Escape') closeChangeModal();
if (e.key === 'Enter') {
if (document.getElementById('change-modal').classList.contains('open')) { doChange(); return; }
if (document.getElementById('unlock-form').style.display !== 'none' &&
document.getElementById('locked-view').style.display !== 'none') { doUnlock(); return; }
if (document.getElementById('setup-form').style.display !== 'none' &&
document.getElementById('locked-view').style.display !== 'none') { doSetup(); return; }
}
});
// ── Init ───────────────────────────────────────────────────────────────────────
(async function init() {
applyLang();
try {
const sf = sessionStorage.getItem(CONFIG_FORMAT_STORAGE);
if (sf === 'mcp' || sf === 'opencode') configFormat = sf;
} catch (_) { /* ignore */ }
const savedKey = sessionStorage.getItem('enc_key');
if (savedKey) {
try {
const apiKey = await fetchApiKey();
await showUnlockedView(savedKey, apiKey);
return;
} catch { /* fall through to locked */ }
}
showLockedView();
})();
</script>
</body>
</html>

View File

@@ -0,0 +1,390 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="icon" href="/favicon.svg?v={{ version }}" type="image/svg+xml">
<title>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; --surface2: #21262d;
--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; }
.layout { display: flex; min-height: 100vh; }
.sidebar {
width: 220px; flex-shrink: 0; background: var(--surface); border-right: 1px solid var(--border);
padding: 24px 16px; display: flex; flex-direction: column; gap: 20px;
}
.sidebar-logo { font-family: 'JetBrains Mono', monospace; font-size: 16px; font-weight: 600;
color: var(--text); text-decoration: none; padding: 0 10px; }
.sidebar-logo span { color: var(--accent); }
.sidebar-menu { display: flex; flex-direction: column; gap: 6px; }
.sidebar-link {
padding: 10px 12px; border-radius: 8px; color: var(--text-muted); text-decoration: none;
border: 1px solid transparent; font-size: 13px; font-weight: 500;
}
.sidebar-link:hover { background: var(--surface2); color: var(--text); }
.sidebar-link.active {
background: rgba(88,166,255,0.12); color: var(--text); border-color: rgba(88,166,255,0.35);
}
.content-shell { flex: 1; min-width: 0; display: flex; flex-direction: column; }
.topbar {
background: var(--surface); border-bottom: 1px solid var(--border); padding: 0 24px;
display: flex; align-items: center; gap: 12px; min-height: 52px;
}
.topbar-spacer { flex: 1; }
.nav-user { font-size: 13px; color: var(--text-muted); }
.btn-sign-out {
padding: 5px 12px; border-radius: 6px; border: 1px solid var(--border);
background: none; color: var(--text); font-size: 12px; text-decoration: none; cursor: pointer;
}
.btn-sign-out:hover { background: var(--surface2); }
.main { padding: 32px 24px 40px; flex: 1; }
.card { background: var(--surface); border: 1px solid var(--border); border-radius: 12px;
padding: 24px; width: 100%; max-width: 1280px; margin: 0 auto; }
.card-title { font-size: 20px; font-weight: 600; margin-bottom: 8px; }
.card-subtitle { color: var(--text-muted); font-size: 13px; margin-bottom: 20px; }
.filter-bar {
display: flex; flex-wrap: wrap; align-items: flex-end; gap: 12px 16px;
margin-bottom: 20px; padding: 16px; background: var(--bg); border: 1px solid var(--border);
border-radius: 10px;
}
.filter-field { display: flex; flex-direction: column; gap: 6px; min-width: 140px; flex: 1; }
.filter-field label { font-size: 12px; color: var(--text-muted); font-weight: 500; }
.filter-field input {
background: var(--surface); border: 1px solid var(--border); border-radius: 6px;
color: var(--text); padding: 8px 10px; font-size: 13px; font-family: 'JetBrains Mono', monospace;
outline: none; width: 100%;
}
.filter-field input:focus { border-color: var(--accent); }
.filter-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
.btn-filter {
padding: 8px 16px; border-radius: 6px; border: none; background: var(--accent); color: #0d1117;
font-size: 13px; font-weight: 600; cursor: pointer;
}
.btn-filter:hover { background: var(--accent-hover); }
.btn-clear {
padding: 8px 14px; border-radius: 6px; border: 1px solid var(--border); background: transparent;
color: var(--text-muted); font-size: 13px; text-decoration: none; cursor: pointer;
}
.btn-clear:hover { background: var(--surface2); color: var(--text); }
.empty { color: var(--text-muted); font-size: 14px; padding: 20px 0; }
.table-wrap { overflow-x: auto; }
table { width: 100%; border-collapse: collapse; min-width: 720px; }
th, td { text-align: left; vertical-align: top; padding: 12px 10px; border-top: 1px solid var(--border); }
th { color: var(--text-muted); font-size: 12px; font-weight: 600; white-space: nowrap; }
td { font-size: 13px; }
.mono { font-family: 'JetBrains Mono', monospace; }
.cell-notes, .cell-meta {
max-width: 280px; word-break: break-word;
}
.notes-scroll {
max-height: 160px;
overflow: auto;
white-space: pre-wrap;
word-break: break-word;
padding: 8px;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 8px;
font-size: 12px;
}
.detail {
background: var(--bg); border: 1px solid var(--border); border-radius: 8px;
padding: 10px; white-space: pre-wrap; word-break: break-word; font-size: 12px;
max-width: 320px; max-height: 160px; overflow: auto;
}
.col-actions { white-space: nowrap; }
.row-actions { display: flex; flex-wrap: wrap; gap: 6px; }
.btn-row {
padding: 4px 10px; border-radius: 6px; font-size: 12px; cursor: pointer;
border: 1px solid var(--border); background: var(--surface2); color: var(--text-muted);
font-family: inherit;
}
.btn-row:hover { color: var(--text); border-color: var(--text-muted); }
.btn-row.danger:hover { border-color: #f85149; color: #f85149; }
.modal-overlay {
position: fixed; inset: 0; background: rgba(1, 4, 9, 0.65); z-index: 200;
display: flex; align-items: center; justify-content: center; padding: 16px;
}
.modal-overlay[hidden] { display: none !important; }
.modal {
background: var(--surface); border: 1px solid var(--border); border-radius: 12px;
padding: 22px; width: 100%; max-width: 520px; max-height: 90vh; overflow: auto;
box-shadow: 0 16px 48px rgba(0,0,0,0.45);
}
.modal-title { font-size: 16px; font-weight: 600; margin-bottom: 14px; }
.modal-field { margin-bottom: 12px; }
.modal-field label { display: block; font-size: 12px; color: var(--text-muted); margin-bottom: 5px; }
.modal-field input, .modal-field textarea {
width: 100%; background: var(--bg); border: 1px solid var(--border); border-radius: 6px;
color: var(--text); padding: 8px 10px; font-size: 13px; font-family: 'JetBrains Mono', monospace;
outline: none;
}
.modal-field textarea { min-height: 72px; resize: vertical; }
.modal-field textarea.metadata-edit { min-height: 140px; }
.modal-error { color: #f85149; font-size: 12px; margin-bottom: 10px; display: none; }
.modal-error.visible { display: block; }
.modal-footer { display: flex; flex-wrap: wrap; gap: 8px; justify-content: flex-end; margin-top: 16px; }
.btn-modal { padding: 8px 16px; border-radius: 6px; font-size: 13px; cursor: pointer; font-family: inherit; border: 1px solid var(--border); background: transparent; color: var(--text); }
.btn-modal.primary { background: var(--accent); color: #0d1117; border-color: transparent; font-weight: 600; }
.btn-modal.primary:hover { background: var(--accent-hover); }
.btn-modal.danger { border-color: #f85149; color: #f85149; }
@media (max-width: 900px) {
.layout { flex-direction: column; }
.sidebar {
width: 100%; border-right: none; border-bottom: 1px solid var(--border);
padding: 16px; gap: 14px;
}
.sidebar-menu { flex-direction: row; flex-wrap: wrap; }
.sidebar-link { flex: 1; text-align: center; min-width: 72px; }
.main { padding: 20px 12px 28px; }
.card { padding: 16px; }
.topbar { padding: 12px 16px; flex-wrap: wrap; }
table, thead, tbody, th, td, tr { display: block; }
thead { display: none; }
tr { border-top: 1px solid var(--border); padding: 12px 0; }
td { border-top: none; padding: 6px 0; max-width: none; }
td::before {
display: block; color: var(--text-muted); font-size: 11px;
margin-bottom: 4px; text-transform: uppercase;
}
td.col-updated::before { content: "更新"; }
td.col-folder::before { content: "Folder"; }
td.col-type::before { content: "Type"; }
td.col-name::before { content: "Name"; }
td.col-notes::before { content: "Notes"; }
td.col-tags::before { content: "Tags"; }
td.col-meta::before { content: "Metadata"; }
td.col-actions::before { content: "操作"; }
.detail { max-width: none; }
.notes-scroll { max-width: none; }
}
</style>
</head>
<body>
<div class="layout">
<aside class="sidebar">
<a href="/dashboard" class="sidebar-logo"><span>secrets</span></a>
<nav class="sidebar-menu">
<a href="/dashboard" class="sidebar-link">MCP</a>
<a href="/entries" class="sidebar-link active">条目</a>
<a href="/audit" class="sidebar-link">审计</a>
</nav>
</aside>
<div class="content-shell">
<div class="topbar">
<span class="topbar-spacer"></span>
<span class="nav-user">{{ user_name }}{% if !user_email.is_empty() %} · {{ user_email }}{% endif %}</span>
<form action="/auth/logout" method="post" style="display:inline">
<button type="submit" class="btn-sign-out">退出</button>
</form>
</div>
<main class="main">
<section class="card">
<div class="card-title">我的条目</div>
<div class="card-subtitle">在当前筛选条件下,共 <strong>{{ total_count }}</strong> 条记录;本页显示 <strong>{{ shown_count }}</strong> 条(按更新时间降序,单页最多 {{ limit }} 条)。不含密文字段。时间为浏览器本地时区。</div>
<form class="filter-bar" method="get" action="/entries">
<div class="filter-field">
<label for="filter-folder">Folder精确匹配</label>
<input id="filter-folder" name="folder" type="text" value="{{ filter_folder }}" placeholder="例如 refining" autocomplete="off">
</div>
<div class="filter-field">
<label for="filter-type">Type精确匹配</label>
<input id="filter-type" name="type" type="text" value="{{ filter_type }}" placeholder="例如 server" autocomplete="off">
</div>
<div class="filter-actions">
<button type="submit" class="btn-filter">筛选</button>
<a href="/entries" class="btn-clear">清空</a>
</div>
</form>
{% if entries.is_empty() %}
<div class="empty">暂无条目。</div>
{% else %}
<div class="table-wrap">
<table>
<thead>
<tr>
<th>更新</th>
<th>Folder</th>
<th>Type</th>
<th>Name</th>
<th>Notes</th>
<th>Tags</th>
<th>Metadata</th>
<th>操作</th>
</tr>
</thead>
<tbody>
{% for entry in entries %}
<tr data-entry-id="{{ entry.id }}">
<td class="col-updated mono"><time class="entry-local-time" datetime="{{ entry.updated_at_iso }}">{{ entry.updated_at_iso }}</time></td>
<td class="col-folder mono cell-folder">{{ entry.folder }}</td>
<td class="col-type mono cell-type">{{ entry.entry_type }}</td>
<td class="col-name mono cell-name">{{ entry.name }}</td>
<td class="col-notes cell-notes">{% if !entry.notes.is_empty() %}<div class="notes-scroll cell-notes-val">{{ entry.notes }}</div>{% endif %}</td>
<td class="col-tags mono cell-tags-val">{{ entry.tags }}</td>
<td class="col-meta cell-meta"><pre class="detail cell-meta-val">{{ entry.metadata }}</pre></td>
<td class="col-actions">
<div class="row-actions">
<button type="button" class="btn-row btn-edit">编辑</button>
<button type="button" class="btn-row danger btn-del">删除</button>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
</section>
</main>
</div>
</div>
<div id="edit-overlay" class="modal-overlay" hidden>
<div class="modal" role="dialog" aria-modal="true" aria-labelledby="edit-title">
<div class="modal-title" id="edit-title">编辑条目</div>
<div id="edit-error" class="modal-error"></div>
<div class="modal-field"><label for="edit-folder">Folder</label><input id="edit-folder" type="text" autocomplete="off"></div>
<div class="modal-field"><label for="edit-type">Type</label><input id="edit-type" type="text" autocomplete="off"></div>
<div class="modal-field"><label for="edit-name">Name</label><input id="edit-name" type="text" autocomplete="off"></div>
<div class="modal-field"><label for="edit-notes">Notes</label><textarea id="edit-notes"></textarea></div>
<div class="modal-field"><label for="edit-tags">Tags逗号分隔</label><input id="edit-tags" type="text" autocomplete="off"></div>
<div class="modal-field"><label for="edit-metadata">MetadataJSON 对象)</label><textarea id="edit-metadata" class="metadata-edit"></textarea></div>
<div class="modal-footer">
<button type="button" class="btn-modal" id="edit-cancel">取消</button>
<button type="button" class="btn-modal primary" id="edit-save">保存</button>
</div>
</div>
</div>
<script>
(function () {
document.querySelectorAll('time.entry-local-time[datetime]').forEach(function (el) {
var raw = el.getAttribute('datetime');
var d = raw ? new Date(raw) : null;
if (d && !isNaN(d.getTime())) {
el.textContent = d.toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'medium' });
el.title = raw + ' (UTC)';
}
});
var editOverlay = document.getElementById('edit-overlay');
var editError = document.getElementById('edit-error');
var editFolder = document.getElementById('edit-folder');
var editType = document.getElementById('edit-type');
var editName = document.getElementById('edit-name');
var editNotes = document.getElementById('edit-notes');
var editTags = document.getElementById('edit-tags');
var editMetadata = document.getElementById('edit-metadata');
var currentEntryId = null;
function showEditErr(msg) {
editError.textContent = msg || '';
editError.classList.toggle('visible', !!msg);
}
function openEdit(tr) {
var id = tr.getAttribute('data-entry-id');
if (!id) return;
currentEntryId = id;
showEditErr('');
editFolder.value = tr.querySelector('.cell-folder') ? tr.querySelector('.cell-folder').textContent.trim() : '';
editType.value = tr.querySelector('.cell-type') ? tr.querySelector('.cell-type').textContent.trim() : '';
editName.value = tr.querySelector('.cell-name') ? tr.querySelector('.cell-name').textContent.trim() : '';
editNotes.value = tr.querySelector('.cell-notes-val') ? tr.querySelector('.cell-notes-val').textContent : '';
var tagsText = tr.querySelector('.cell-tags-val') ? tr.querySelector('.cell-tags-val').textContent.trim() : '';
editTags.value = tagsText;
var metaPre = tr.querySelector('.cell-meta-val');
editMetadata.value = metaPre ? metaPre.textContent : '{}';
editOverlay.hidden = false;
}
function closeEdit() {
editOverlay.hidden = true;
currentEntryId = null;
showEditErr('');
}
document.getElementById('edit-cancel').addEventListener('click', closeEdit);
editOverlay.addEventListener('click', function (e) {
if (e.target === editOverlay) closeEdit();
});
document.getElementById('edit-save').addEventListener('click', function () {
if (!currentEntryId) return;
var meta;
try {
meta = JSON.parse(editMetadata.value);
} catch (err) {
showEditErr('Metadata 不是合法 JSON');
return;
}
if (meta === null || typeof meta !== 'object' || Array.isArray(meta)) {
showEditErr('Metadata 必须是 JSON 对象');
return;
}
var tags = editTags.value.split(',').map(function (s) { return s.trim(); }).filter(Boolean);
var body = {
folder: editFolder.value,
type: editType.value,
name: editName.value.trim(),
notes: editNotes.value,
tags: tags,
metadata: meta
};
showEditErr('');
fetch('/api/entries/' + encodeURIComponent(currentEntryId), {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
body: JSON.stringify(body)
}).then(function (r) {
return r.json().then(function (data) {
if (!r.ok) throw new Error(data.error || ('HTTP ' + r.status));
return data;
});
}).then(function () {
closeEdit();
window.location.reload();
}).catch(function (e) {
showEditErr(e.message || String(e));
});
});
document.querySelectorAll('tr[data-entry-id]').forEach(function (tr) {
tr.querySelector('.btn-edit').addEventListener('click', function () { openEdit(tr); });
tr.querySelector('.btn-del').addEventListener('click', function () {
var id = tr.getAttribute('data-entry-id');
var nameEl = tr.querySelector('.cell-name');
var name = nameEl ? nameEl.textContent.trim() : '';
if (!id) return;
if (!confirm('确定删除条目「' + name + '」?')) return;
fetch('/api/entries/' + encodeURIComponent(id), { method: 'DELETE', credentials: 'same-origin' })
.then(function (r) {
return r.json().then(function (data) {
if (!r.ok) throw new Error(data.error || ('HTTP ' + r.status));
return data;
});
})
.then(function (data) {
if (data && Array.isArray(data.migrated) && data.migrated.length > 0) {
alert('已自动迁移共享 key 引用:' + data.migrated.length + ' 个条目完成重定向。');
}
window.location.reload();
})
.catch(function (e) { alert(e.message || String(e)); });
});
});
})();
</script>
</body>
</html>

View File

@@ -0,0 +1,269 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="description" content="Secrets MCP基于 Model Context Protocol 的密钥与配置管理。密码短语在浏览器本地 PBKDF2 派生,密文 AES-GCM 存储,完整审计与历史版本。">
<meta name="keywords" content="secrets management,MCP,Model Context Protocol,end-to-end encryption,AES-GCM,PBKDF2,API key,密钥管理">
<meta name="robots" content="index, follow">
<link rel="canonical" href="{{ base_url }}/">
<link rel="icon" href="/favicon.svg?v={{ version }}" type="image/svg+xml">
<title>Secrets MCP — 端到端加密的密钥管理</title>
<meta property="og:type" content="website">
<meta property="og:url" content="{{ base_url }}/">
<meta property="og:title" content="Secrets MCP — 端到端加密的密钥管理">
<meta property="og:description" content="密码短语客户端派生密文存储MCP API 与 Web 控制台,多租户与审计。">
<meta name="twitter:card" content="summary">
<meta name="twitter:title" content="Secrets MCP — 端到端加密的密钥管理">
<meta name="twitter:description" content="密码短语客户端派生密文存储MCP API 与 Web 控制台,多租户与审计。">
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@500;600&family=Inter:wght@400;500;600&display=swap');
:root {
--bg: #0d1117;
--surface: #161b22;
--surface2: #21262d;
--border: #30363d;
--text: #e6edf3;
--text-muted: #8b949e;
--accent: #58a6ff;
--accent-hover: #79b8ff;
}
html, body { height: 100%; overflow: hidden; }
@supports (height: 100dvh) {
html, body { height: 100dvh; }
}
body {
background: var(--bg);
color: var(--text);
font-family: 'Inter', sans-serif;
display: flex;
flex-direction: column;
}
.nav {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 24px;
border-bottom: 1px solid var(--border);
background: var(--surface);
}
.brand {
font-family: 'JetBrains Mono', monospace;
font-size: 15px;
font-weight: 600;
color: var(--text);
text-decoration: none;
}
.brand span { color: var(--accent); }
.nav-right { display: flex; align-items: center; gap: 14px; }
.lang-bar { display: flex; gap: 2px; background: rgba(255,255,255,0.04); border-radius: 6px; padding: 2px; }
.lang-btn {
padding: 4px 10px; border: none; background: none; color: var(--text-muted);
font-size: 12px; cursor: pointer; border-radius: 4px;
}
.lang-btn.active { background: var(--border); color: var(--text); }
.cta {
display: inline-flex; align-items: center; justify-content: center;
padding: 8px 18px; border-radius: 8px; font-size: 13px; font-weight: 600;
text-decoration: none; border: 1px solid var(--accent);
background: rgba(88, 166, 255, 0.12); color: var(--accent);
transition: background 0.15s, color 0.15s;
}
.cta:hover { background: var(--accent); color: var(--bg); }
.main {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 16px 24px 12px;
gap: 20px;
}
.hero { text-align: center; max-width: 720px; }
.hero h1 { font-size: clamp(20px, 4vw, 28px); font-weight: 600; margin-bottom: 8px; line-height: 1.25; }
.hero .tagline { color: var(--text-muted); font-size: clamp(13px, 2vw, 15px); line-height: 1.5; }
.grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 12px;
width: 100%;
max-width: 900px;
}
@media (max-width: 900px) {
.grid { grid-template-columns: repeat(2, 1fr); }
}
@media (max-width: 480px) {
.grid { grid-template-columns: 1fr; gap: 8px; }
.main { justify-content: flex-start; padding-top: 12px; }
}
.card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 10px;
padding: 14px 14px 12px;
min-height: 0;
}
.card-icon {
width: 32px; height: 32px; border-radius: 8px;
background: var(--surface2);
display: flex; align-items: center; justify-content: center;
margin-bottom: 10px; color: var(--accent);
}
.card-icon svg { width: 18px; height: 18px; }
.card h2 { font-size: 13px; font-weight: 600; margin-bottom: 6px; line-height: 1.3; }
.card p { font-size: 12px; color: var(--text-muted); line-height: 1.45; }
.foot {
flex-shrink: 0;
text-align: center;
padding: 8px 16px 12px;
font-size: 11px;
color: var(--text-muted);
border-top: 1px solid var(--border);
background: var(--surface);
}
.foot a { color: var(--accent); text-decoration: none; }
.foot a:hover { text-decoration: underline; }
</style>
</head>
<body>
<header class="nav">
<a class="brand" href="/">secrets<span>-mcp</span></a>
<div class="nav-right">
<div class="lang-bar">
<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>
{% if is_logged_in %}
<a class="cta" href="/dashboard" data-i18n="ctaDashboard">进入控制台</a>
{% else %}
<a class="cta" href="/login" data-i18n="ctaLogin">登录</a>
{% endif %}
</div>
</header>
<main class="main">
<div class="hero">
<h1 data-i18n="heroTitle">端到端加密的密钥与配置管理</h1>
<p class="tagline" data-i18n="heroTagline">Streamable HTTP MCP 与 Web 控制台:元数据与密文分库存储,密钥永不离开你的客户端逻辑。</p>
</div>
<div class="grid">
<article class="card">
<div class="card-icon" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 11c1.66 0 3-1.34 3-3V5c0-1.66-1.34-3-3-3S9 3.34 9 5v3c0 1.66 1.34 3 3 3z"/><path d="M19 10v1a7 7 0 01-14 0v-1"/><path d="M12 14v7M9 18h6"/></svg>
</div>
<h2 data-i18n="c1t">客户端密钥派生</h2>
<p data-i18n="c1d">PBKDF2-SHA256约 60 万次)在浏览器本地从密码短语派生密钥;服务端仅保存盐与校验值,不持有密码或明文主密钥。</p>
</article>
<article class="card">
<div class="card-icon" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0110 0v4"/></svg>
</div>
<h2 data-i18n="c2t">AES-256-GCM 加密</h2>
<p data-i18n="c2d">敏感字段以 AES-GCM 密文落库Web 端在本地加解密,明文默认不经过服务端持久化。</p>
</article>
<article class="card">
<div class="card-icon" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><path d="M14 2v6h6M16 13H8M16 17H8M10 9H8"/></svg>
</div>
<h2 data-i18n="c3t">审计与历史</h2>
<p data-i18n="c3d">操作写入审计日志;条目与密文保留历史版本,支持按版本查看与恢复。</p>
</article>
</div>
</main>
<footer class="foot">
<span data-i18n="versionLabel">版本</span> {{ version }} ·
<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 %}
</footer>
<script>
const T = {
'zh-CN': {
docTitle: 'Secrets MCP — 端到端加密的密钥管理',
ctaDashboard: '进入控制台',
ctaLogin: '登录',
heroTitle: '端到端加密的密钥与配置管理',
heroTagline: 'Streamable HTTP MCP 与 Web 控制台:元数据与密文分库存储,密钥永不离开你的客户端逻辑。',
c1t: '客户端密钥派生',
c1d: 'PBKDF2-SHA256约 60 万次)在浏览器本地从密码短语派生密钥;服务端仅保存盐与校验值,不持有密码或明文主密钥。',
c2t: 'AES-256-GCM 加密',
c2d: '敏感字段以 AES-GCM 密文落库Web 端在本地加解密,明文默认不经过服务端持久化。',
c3t: '审计与历史',
c3d: '操作写入审计日志;条目与密文保留历史版本,支持按版本查看与恢复。',
versionLabel: '版本',
sep: ' · ',
footRepo: '源码仓库',
footLogin: '登录',
},
'zh-TW': {
docTitle: 'Secrets MCP — 端到端加密的金鑰管理',
ctaDashboard: '進入控制台',
ctaLogin: '登入',
heroTitle: '端到端加密的金鑰與設定管理',
heroTagline: 'Streamable HTTP MCP 與 Web 控制台:中繼資料與密文分庫儲存,金鑰不離開你的用戶端邏輯。',
c1t: '用戶端金鑰派生',
c1d: 'PBKDF2-SHA256約 60 萬次)在瀏覽器本地從密碼片語派生金鑰;伺服端僅保存鹽與校驗值,不持有密碼或明文主金鑰。',
c2t: 'AES-256-GCM 加密',
c2d: '敏感欄位以 AES-GCM 密文落庫Web 端在本地加解密,明文預設不經伺服端持久化。',
c3t: '稽核與歷史',
c3d: '操作寫入稽核日誌;條目與密文保留歷史版本,支援依版本檢視與還原。',
versionLabel: '版本',
sep: ' · ',
footRepo: '原始碼倉庫',
footLogin: '登入',
},
'en': {
docTitle: 'Secrets MCP — End-to-end encrypted secrets',
ctaDashboard: 'Open dashboard',
ctaLogin: 'Sign in',
heroTitle: 'End-to-end encrypted secrets and configuration',
heroTagline: 'Streamable HTTP MCP plus web console: metadata and ciphertext stored separately; keys stay on your client.',
c1t: 'Client-side key derivation',
c1d: 'PBKDF2-SHA256 (~600k iterations) derives keys from your passphrase in the browser; the server stores only salt and a verification blob, never your password or raw master key.',
c2t: 'AES-256-GCM',
c2d: 'Secret fields are stored as AES-GCM ciphertext; the web UI encrypts and decrypts locally so plaintext is not persisted server-side by default.',
c3t: 'Audit and history',
c3d: 'Operations are audited; entries and secrets keep version history for review and rollback.',
versionLabel: 'Version',
sep: ' · ',
footRepo: 'Source repository',
footLogin: 'Sign in',
}
};
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 => {
const key = el.getAttribute('data-i18n');
el.textContent = t(key);
});
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

@@ -0,0 +1,186 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="robots" content="noindex, follow">
<meta name="description" content="登录 Secrets MCP Web 控制台,安全管理跨设备加密 secrets。">
<meta name="keywords" content="Secrets MCP,登录,OAuth,密钥管理">
<link rel="canonical" href="{{ base_url }}/login">
<link rel="icon" href="/favicon.svg?v={{ version }}" type="image/svg+xml">
<title>登录 — Secrets MCP</title>
<meta property="og:type" content="website">
<meta property="og:url" content="{{ base_url }}/login">
<meta property="og:title" content="登录 — Secrets MCP">
<meta property="og:description" content="登录 Web 控制台,管理加密存储的密钥与配置。">
<meta name="twitter:card" content="summary">
<meta name="twitter:title" content="登录 — Secrets MCP">
<meta name="twitter:description" content="登录 Web 控制台,管理加密存储的密钥与配置。">
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
@import url('https://fonts.googleapis.com/css2?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;
--google: #4285f4;
--danger: #f85149;
}
body { background: var(--bg); color: var(--text); font-family: 'Inter', sans-serif;
min-height: 100vh; display: flex; align-items: center; justify-content: center; }
.card {
background: var(--surface); border: 1px solid var(--border); border-radius: 12px;
padding: 48px 40px; width: 100%; max-width: 400px;
box-shadow: 0 8px 32px rgba(0,0,0,0.4);
}
.topbar { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 20px; gap: 12px; }
.back-home {
font-size: 13px; color: var(--accent); text-decoration: none; white-space: nowrap;
}
.back-home:hover { text-decoration: underline; }
.lang-bar { display: flex; gap: 2px; background: rgba(255,255,255,0.04); border-radius: 6px; padding: 2px; flex-shrink: 0; }
.lang-btn { padding: 3px 9px; border: none; background: none; color: var(--text-muted);
font-size: 12px; cursor: pointer; border-radius: 4px; }
.lang-btn.active { background: var(--border); color: var(--text); }
.oauth-alert {
display: none;
margin-bottom: 16px; padding: 10px 12px; border-radius: 8px;
font-size: 13px; line-height: 1.4;
background: rgba(248, 81, 73, 0.12);
border: 1px solid rgba(248, 81, 73, 0.35);
color: #ffa198;
}
.oauth-alert.visible { display: block; }
h1 { font-size: 22px; font-weight: 600; margin-bottom: 8px; }
.subtitle { color: var(--text-muted); font-size: 14px; margin-bottom: 32px; }
.btn {
display: flex; align-items: center; justify-content: center; gap: 12px;
width: 100%; padding: 12px 20px; border: 1px solid var(--border); border-radius: 8px;
background: var(--surface); color: var(--text); font-size: 14px; font-weight: 500;
cursor: pointer; text-decoration: none; transition: all 0.2s;
}
.btn:hover { background: var(--border); border-color: var(--text-muted); }
.btn + .btn { margin-top: 12px; }
.btn svg { flex-shrink: 0; }
.footer { margin-top: 28px; text-align: center; color: var(--text-muted); font-size: 12px; }
.footer a { color: var(--accent); text-decoration: none; }
</style>
</head>
<body>
<div class="card">
<div class="topbar">
<a class="back-home" href="/" data-i18n="backHome">返回首页</a>
<div class="lang-bar">
<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>
<div id="oauth-alert" class="oauth-alert" role="alert"></div>
<h1 data-i18n="title">登录</h1>
<p class="subtitle" data-i18n="subtitle">安全管理你的跨设备 secrets。</p>
{% if has_google %}
<a href="/auth/google" class="btn">
<svg width="18" height="18" viewBox="0 0 18 18" fill="none">
<path d="M17.64 9.2c0-.637-.057-1.251-.164-1.84H9v3.481h4.844a4.14 4.14 0 01-1.796 2.716v2.259h2.908c1.702-1.567 2.684-3.875 2.684-6.615z" fill="#4285F4"/>
<path d="M9 18c2.43 0 4.467-.806 5.956-2.18l-2.908-2.259c-.806.54-1.837.86-3.048.86-2.344 0-4.328-1.584-5.036-3.711H.957v2.332A8.997 8.997 0 009 18z" fill="#34A853"/>
<path d="M3.964 10.71A5.41 5.41 0 013.682 9c0-.593.102-1.17.282-1.71V4.958H.957A8.996 8.996 0 000 9c0 1.452.348 2.827.957 4.042l3.007-2.332z" fill="#FBBC05"/>
<path d="M9 3.58c1.321 0 2.508.454 3.44 1.345l2.582-2.58C13.463.891 11.426 0 9 0A8.997 8.997 0 00.957 4.958L3.964 7.29C4.672 5.163 6.656 3.58 9 3.58z" fill="#EA4335"/>
</svg>
<span data-i18n="google">使用 Google 登录</span>
</a>
{% endif %}
{% if !has_google %}
<p style="text-align:center; color: var(--text-muted); font-size: 14px;" data-i18n="noProviders">
未配置登录方式,请联系管理员。
</p>
{% endif %}
</div>
<script>
const T = {
'zh-CN': {
docTitle: '登录 — Secrets MCP',
backHome: '返回首页',
title: '登录',
subtitle: '安全管理你的跨设备 secrets。',
google: '使用 Google 登录',
noProviders: '未配置登录方式,请联系管理员。',
err_oauth_error: '登录失败:授权提供方返回错误,请重试。',
err_oauth_missing_code: '登录失败:未收到授权码,请重试。',
err_oauth_missing_state: '登录失败:缺少安全校验参数,请重试。',
err_oauth_state: '登录失败:会话校验不匹配(可能因 Cookie 策略或服务器重启)。请返回首页再试。',
},
'zh-TW': {
docTitle: '登入 — Secrets MCP',
backHome: '返回首頁',
title: '登入',
subtitle: '安全管理你的跨裝置 secrets。',
google: '使用 Google 登入',
noProviders: '尚未設定登入方式,請聯絡管理員。',
err_oauth_error: '登入失敗:授權方回傳錯誤,請再試一次。',
err_oauth_missing_code: '登入失敗:未取得授權碼,請再試一次。',
err_oauth_missing_state: '登入失敗:缺少安全校驗參數,請再試一次。',
err_oauth_state: '登入失敗:工作階段校驗不符(可能與 Cookie 政策或伺服器重啟有關)。請回到首頁再試。',
},
'en': {
docTitle: 'Sign in — Secrets MCP',
backHome: 'Back to home',
title: 'Sign in',
subtitle: 'Manage your cross-device secrets securely.',
google: 'Continue with Google',
noProviders: 'No login providers configured. Please contact your administrator.',
err_oauth_error: 'Sign-in failed: the identity provider returned an error. Please try again.',
err_oauth_missing_code: 'Sign-in failed: no authorization code was returned. Please try again.',
err_oauth_missing_state: 'Sign-in failed: missing security state. Please try again.',
err_oauth_state: 'Sign-in failed: session state mismatch (often cookies or server restart). Open the home page and try again.',
}
};
let currentLang = localStorage.getItem('lang') || 'zh-CN';
function t(key) { return T[currentLang][key] || T['en'][key] || key; }
function showOAuthError() {
const params = new URLSearchParams(window.location.search);
const code = params.get('error');
const el = document.getElementById('oauth-alert');
if (!code || !code.startsWith('oauth_')) {
el.classList.remove('visible');
el.textContent = '';
return;
}
const key = 'err_' + code;
el.textContent = t(key) || t('err_oauth_error');
el.classList.add('visible');
}
function applyLang() {
document.documentElement.lang = currentLang;
document.title = t('docTitle');
document.querySelectorAll('[data-i18n]').forEach(el => {
const key = el.getAttribute('data-i18n');
el.textContent = t(key);
});
document.querySelectorAll('.lang-btn').forEach(btn => {
const map = { 'zh-CN': '简', 'zh-TW': '繁', 'en': 'EN' };
btn.classList.toggle('active', btn.textContent === map[currentLang]);
});
showOAuthError();
}
function setLang(lang) {
currentLang = lang;
localStorage.setItem('lang', lang);
applyLang();
}
applyLang();
</script>
</body>
</html>

37
deploy/.env.example Normal file
View File

@@ -0,0 +1,37 @@
# Secrets MCP Server 环境变量配置
# 复制此文件为 .env 并填写真实值
# ─── 数据库 ───────────────────────────────────────────────────────────
# Web 会话tower-sessions与业务数据共用此库启动时会自动 migrate 会话表,无需额外环境变量。
SECRETS_DATABASE_URL=postgres://postgres:PASSWORD@db.refining.ltd:5432/secrets-mcp
# 强烈建议生产使用 verify-full至少 verify-ca
SECRETS_DATABASE_SSL_MODE=verify-full
# 私有 CA 或自建链路时填写 CA 根证书路径;使用公共受信 CA 可留空
# SECRETS_DATABASE_SSL_ROOT_CERT=/etc/secrets/pg-ca.crt
# 当设为 prod/production 时,服务会拒绝弱 TLS 模式prefer/disable/allow/require
SECRETS_ENV=production
# ─── 服务地址 ─────────────────────────────────────────────────────────
# 内网监听地址Cloudflare / Nginx 反代时填内网端口)
SECRETS_MCP_BIND=127.0.0.1:9315
# 对外 HTTPS 地址(用于 OAuth 回调 URL 拼接)
BASE_URL=https://secrets.example.com
# ─── Google OAuth ─────────────────────────────────────────────────────
# Google Cloud Console → APIs & Services → Credentials
# 授权回调 URI 须配置为:${BASE_URL}/auth/google/callback
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
# ─── 微信登录(暂未开放,预留)───────────────────────────────────────
# WECHAT_APP_CLIENT_ID=
# WECHAT_APP_CLIENT_SECRET=
# ─── 日志(可选)──────────────────────────────────────────────────────
# RUST_LOG=secrets_mcp=debug
# ─── 注意 ─────────────────────────────────────────────────────────────
# SERVER_MASTER_KEY 已不再需要。
# 新架构E2EE加密密钥由用户密码短语在客户端本地派生服务端不持有原始密钥。
# 仅在需要迁移旧版 wrapped_key 数据时临时启用。

View File

@@ -0,0 +1,92 @@
# PostgreSQL TLS Hardening Runbook
This runbook applies to:
- PostgreSQL server: `47.117.131.22` (`db.refining.ltd`)
- `secrets-mcp` app server: `47.238.146.244` (`secrets.refining.app`)
## 1) Issue certificate for `db.refining.ltd` (Let's Encrypt + Cloudflare DNS-01)
Install `acme.sh` on the PostgreSQL server and use a Cloudflare API token with DNS edit permission for the target zone.
```bash
curl https://get.acme.sh | sh -s email=ops@refining.ltd
export CF_Token="your_cloudflare_dns_token"
export CF_Zone_ID="your_zone_id"
~/.acme.sh/acme.sh --issue --dns dns_cf -d db.refining.ltd --keylength ec-256
```
Install cert/key into a PostgreSQL-readable path:
```bash
sudo mkdir -p /etc/postgresql/tls
sudo ~/.acme.sh/acme.sh --install-cert -d db.refining.ltd --ecc \
--fullchain-file /etc/postgresql/tls/fullchain.pem \
--key-file /etc/postgresql/tls/privkey.pem \
--reloadcmd "systemctl reload postgresql || systemctl restart postgresql"
sudo chown -R postgres:postgres /etc/postgresql/tls
sudo chmod 600 /etc/postgresql/tls/privkey.pem
sudo chmod 644 /etc/postgresql/tls/fullchain.pem
```
## 2) Configure PostgreSQL TLS and access rules
In `postgresql.conf`:
```conf
ssl = on
ssl_cert_file = '/etc/postgresql/tls/fullchain.pem'
ssl_key_file = '/etc/postgresql/tls/privkey.pem'
```
In `pg_hba.conf`, allow app traffic via TLS only (example):
```conf
hostssl secrets-mcp postgres 47.238.146.244/32 scram-sha-256
```
Keep a safe admin path (`local` socket or restricted source CIDR) before removing old plaintext `host` rules.
Reload PostgreSQL:
```bash
sudo systemctl reload postgresql
```
## 3) Verify server-side TLS
```bash
openssl s_client -starttls postgres -connect db.refining.ltd:5432 -servername db.refining.ltd
```
The handshake should succeed and the certificate should match `db.refining.ltd`.
## 4) Update `secrets-mcp` app server env
Use environment values like:
```bash
SECRETS_DATABASE_URL=postgres://postgres:***@db.refining.ltd:5432/secrets-mcp
SECRETS_DATABASE_SSL_MODE=verify-full
SECRETS_ENV=production
```
If you use private CA instead of public CA, also set:
```bash
SECRETS_DATABASE_SSL_ROOT_CERT=/etc/secrets/pg-ca.crt
```
Restart `secrets-mcp` after updating env.
## 5) Verify from app server
Run positive and negative checks:
- Positive: app starts, migrations pass, dashboard + MCP API work.
- Negative:
- wrong hostname -> connection fails
- wrong CA file -> connection fails
- disable TLS on DB -> connection fails
This ensures no silent downgrade to weak TLS in production.

View File

@@ -0,0 +1,27 @@
[Unit]
Description=Secrets MCP Server
After=network.target
Wants=network-online.target
[Service]
Type=simple
User=secrets-mcp
Group=secrets-mcp
WorkingDirectory=/opt/secrets-mcp
EnvironmentFile=/opt/secrets-mcp/.env
ExecStart=/opt/secrets-mcp/secrets-mcp
Restart=always
RestartSec=5
StandardOutput=journal
StandardError=journal
SyslogIdentifier=secrets-mcp
# 安全加固
NoNewPrivileges=yes
ProtectSystem=strict
ProtectHome=yes
ReadWritePaths=/opt/secrets-mcp
PrivateTmp=yes
[Install]
WantedBy=multi-user.target

3
rust-toolchain.toml Normal file
View File

@@ -0,0 +1,3 @@
[toolchain]
channel = "1.94.0"
components = ["rustfmt", "clippy"]

View File

@@ -0,0 +1,22 @@
-- Run against prod BEFORE deploying secrets-mcp with FK migration.
-- Requires: write access to SECRETS_DATABASE_URL.
-- Example: psql "$SECRETS_DATABASE_URL" -v ON_ERROR_STOP=1 -f scripts/cleanup-orphan-user-ids.sql
BEGIN;
UPDATE entries
SET user_id = NULL
WHERE user_id IS NOT NULL
AND NOT EXISTS (SELECT 1 FROM users u WHERE u.id = entries.user_id);
UPDATE entries_history
SET user_id = NULL
WHERE user_id IS NOT NULL
AND NOT EXISTS (SELECT 1 FROM users u WHERE u.id = entries_history.user_id);
UPDATE audit_log
SET user_id = NULL
WHERE user_id IS NOT NULL
AND NOT EXISTS (SELECT 1 FROM users u WHERE u.id = audit_log.user_id);
COMMIT;

194
scripts/migrate-v0.3.0.sql Normal file
View File

@@ -0,0 +1,194 @@
-- ============================================================================
-- migrate-v0.3.0.sql
-- Schema migration from v0.2.x → v0.3.0
--
-- Changes:
-- • entries: namespace → folder, kind → type; add notes column
-- • audit_log: namespace → folder, kind → type
-- • entries_history: namespace → folder, kind → type; add user_id column
-- • Unique index: (user_id, name) → (user_id, folder, name)
-- Same name in different folders is now allowed; no rename needed.
--
-- Safe to run multiple times (fully idempotent).
-- Preserves all data in users, entries, secrets.
-- ============================================================================
BEGIN;
-- ── entries: rename namespace→folder, kind→type ──────────────────────────────
DO $$ BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'entries' AND column_name = 'namespace'
) THEN
ALTER TABLE entries RENAME COLUMN namespace TO folder;
END IF;
END $$;
DO $$ BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'entries' AND column_name = 'kind'
) THEN
ALTER TABLE entries RENAME COLUMN kind TO type;
END IF;
END $$;
-- Set NOT NULL + default for folder/type in entries
DO $$ BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'entries' AND column_name = 'folder'
) THEN
UPDATE entries SET folder = '' WHERE folder IS NULL;
ALTER TABLE entries ALTER COLUMN folder SET NOT NULL;
ALTER TABLE entries ALTER COLUMN folder SET DEFAULT '';
END IF;
END $$;
DO $$ BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'entries' AND column_name = 'type'
) THEN
UPDATE entries SET type = '' WHERE type IS NULL;
ALTER TABLE entries ALTER COLUMN type SET NOT NULL;
ALTER TABLE entries ALTER COLUMN type SET DEFAULT '';
END IF;
END $$;
-- Add notes column to entries if missing
ALTER TABLE entries ADD COLUMN IF NOT EXISTS notes TEXT NOT NULL DEFAULT '';
-- ── audit_log: rename namespace→folder, kind→type ────────────────────────────
DO $$ BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'audit_log' AND column_name = 'namespace'
) THEN
ALTER TABLE audit_log RENAME COLUMN namespace TO folder;
END IF;
END $$;
DO $$ BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'audit_log' AND column_name = 'kind'
) THEN
ALTER TABLE audit_log RENAME COLUMN kind TO type;
END IF;
END $$;
DO $$ BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'audit_log' AND column_name = 'folder'
) THEN
UPDATE audit_log SET folder = '' WHERE folder IS NULL;
ALTER TABLE audit_log ALTER COLUMN folder SET NOT NULL;
ALTER TABLE audit_log ALTER COLUMN folder SET DEFAULT '';
END IF;
END $$;
DO $$ BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'audit_log' AND column_name = 'type'
) THEN
UPDATE audit_log SET type = '' WHERE type IS NULL;
ALTER TABLE audit_log ALTER COLUMN type SET NOT NULL;
ALTER TABLE audit_log ALTER COLUMN type SET DEFAULT '';
END IF;
END $$;
ALTER TABLE audit_log DROP COLUMN IF EXISTS actor;
-- ── entries_history: rename namespace→folder, kind→type; add user_id ─────────
DO $$ BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'entries_history' AND column_name = 'namespace'
) THEN
ALTER TABLE entries_history RENAME COLUMN namespace TO folder;
END IF;
END $$;
DO $$ BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'entries_history' AND column_name = 'kind'
) THEN
ALTER TABLE entries_history RENAME COLUMN kind TO type;
END IF;
END $$;
DO $$ BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'entries_history' AND column_name = 'folder'
) THEN
UPDATE entries_history SET folder = '' WHERE folder IS NULL;
ALTER TABLE entries_history ALTER COLUMN folder SET NOT NULL;
ALTER TABLE entries_history ALTER COLUMN folder SET DEFAULT '';
END IF;
END $$;
DO $$ BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'entries_history' AND column_name = 'type'
) THEN
UPDATE entries_history SET type = '' WHERE type IS NULL;
ALTER TABLE entries_history ALTER COLUMN type SET NOT NULL;
ALTER TABLE entries_history ALTER COLUMN type SET DEFAULT '';
END IF;
END $$;
ALTER TABLE entries_history ADD COLUMN IF NOT EXISTS user_id UUID;
ALTER TABLE entries_history DROP COLUMN IF EXISTS actor;
-- ── secrets_history: drop actor column ───────────────────────────────────────
ALTER TABLE secrets_history DROP COLUMN IF EXISTS actor;
-- ── Rebuild unique indexes: (user_id, folder, name) ──────────────────────────
-- Note: folder is now part of the key, so same name in different folders is
-- naturally distinct — no rename of existing rows needed.
DROP INDEX IF EXISTS idx_entries_unique_legacy;
DROP INDEX IF EXISTS idx_entries_unique_user;
CREATE UNIQUE INDEX IF NOT EXISTS idx_entries_unique_legacy
ON entries(folder, name)
WHERE user_id IS NULL;
CREATE UNIQUE INDEX IF NOT EXISTS idx_entries_unique_user
ON entries(user_id, folder, name)
WHERE user_id IS NOT NULL;
-- ── Replace old namespace/kind indexes with folder/type ──────────────────────
DROP INDEX IF EXISTS idx_entries_namespace;
DROP INDEX IF EXISTS idx_entries_kind;
DROP INDEX IF EXISTS idx_audit_log_ns_kind;
DROP INDEX IF EXISTS idx_entries_history_ns_kind_name;
CREATE INDEX IF NOT EXISTS idx_entries_folder
ON entries(folder) WHERE folder <> '';
CREATE INDEX IF NOT EXISTS idx_entries_type
ON entries(type) WHERE type <> '';
CREATE INDEX IF NOT EXISTS idx_entries_user_id
ON entries(user_id) WHERE user_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_audit_log_folder_type
ON audit_log(folder, type);
CREATE INDEX IF NOT EXISTS idx_entries_history_folder_type_name
ON entries_history(folder, type, name, version DESC);
CREATE INDEX IF NOT EXISTS idx_entries_history_user_id
ON entries_history(user_id) WHERE user_id IS NOT NULL;
COMMIT;
-- ── Verification queries (run these manually to confirm) ─────────────────────
-- SELECT column_name, data_type FROM information_schema.columns
-- WHERE table_name = 'entries' ORDER BY ordinal_position;
-- SELECT indexname, indexdef FROM pg_indexes WHERE tablename = 'entries';
-- SELECT COUNT(*) FROM entries;
-- SELECT COUNT(*) FROM users;
-- SELECT COUNT(*) FROM secrets;

24
scripts/release-check.sh Executable file
View File

@@ -0,0 +1,24 @@
#!/usr/bin/env bash
set -euo pipefail
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$repo_root"
version="$(grep -m1 '^version' crates/secrets-mcp/Cargo.toml | sed 's/.*"\(.*\)".*/\1/')"
tag="secrets-mcp-${version}"
echo "==> 当前 secrets-mcp 版本: ${version}"
echo "==> 检查是否已存在 tag: ${tag}"
if git rev-parse "refs/tags/${tag}" >/dev/null 2>&1; then
echo "提示: 已存在 tag ${tag},将按重复构建处理,不阻断检查。"
echo "如需创建新的发布版本,请先 bump crates/secrets-mcp/Cargo.toml 中的 version。"
else
echo "==> 未发现重复 tag将创建新版本"
fi
echo "==> 开始执行检查"
cargo fmt -- --check
cargo clippy --locked -- -D warnings
cargo test --locked

View File

@@ -4,15 +4,24 @@
# 参考: .gitea/workflows/secrets.yml # 参考: .gitea/workflows/secrets.yml
# #
# 所需配置: # 所需配置:
# - secrets.RELEASE_TOKEN (必选) Release 上传用,值为 Gitea PAT # - secrets.RELEASE_TOKEN (可选,推荐) Gitea PAT未配置则工作流跳过 Release 创建与产物上传
# - vars.WEBHOOK_URL (可选) 飞书通知 # - vars.WEBHOOK_URL (可选) 飞书通知
# - vars.DEPLOY_HOST (可选) 部署目标 SSH 主机IP 或域名)
# - vars.DEPLOY_USER (可选) SSH 用户名
# - secrets.DEPLOY_SSH_KEY (可选) SSH 私钥 PEM 全文(原始字符,含 BEGIN/END 行);通过 DEPLOY_SSH_KEY_FILE 写入 API
# #
# 注意: Gitea 不允许 secret/variable 名以 GITEA_ 或 GITHUB_ 开头,故使用 RELEASE_TOKEN # 注意:
# - Gitea 不允许 secret/variable 名以 GITEA_ 或 GITHUB_ 开头,故使用 RELEASE_TOKEN
# - Gitea Actions 的 secretsAPI 的 data 字段,及网页里粘贴的值)必须是未经 base64 的原始值。
# 若事先 base64 再写入,工作流里拿到的仍是「一串 base64 文本」SSH/OpenSSH 无法识别,部署会失败。
# DEPLOY_SSH_KEY 须与 .pem 文件内容一致:本脚本用 jq --rawfile 按原文上传。
# - Variables 的 value 字段同样为原始字符串,不要 base64。
# #
# 用法: # 用法:
# 1. 从 ~/.config/gitea/config.env 读取 GITEA_URL, GITEA_TOKEN, GITEA_WEBHOOK_URL # 1. 从 ~/.config/gitea/config.env 读取 GITEA_URL, GITEA_TOKEN, GITEA_WEBHOOK_URL
# 2. 或通过环境变量覆盖: GITEA_TOKEN作为 RELEASE_TOKEN 的值), WEBHOOK_URL # 2. 或通过环境变量覆盖: GITEA_TOKEN作为 RELEASE_TOKEN 的值), WEBHOOK_URL,
# 3. 或使用 secrets CLI 获取: 需 DATABASE_URL从 refining/service gitea 读取 # DEPLOY_HOST, DEPLOY_USER, DEPLOY_SSH_KEY_FILE部署到 ECS
# 3. 凭据勿用 base64部署私钥路径见 DEPLOY_SSH_KEY_FILE
# #
set -e set -e
@@ -21,26 +30,41 @@ OWNER="refining"
REPO="secrets" REPO="secrets"
# 解析参数 # 解析参数
USE_SECRETS_CLI=false
while [[ $# -gt 0 ]]; do while [[ $# -gt 0 ]]; do
case $1 in case $1 in
--from-secrets) USE_SECRETS_CLI=true; shift ;; --from-secrets)
echo "❌ --from-secrets 尚未实现,请使用 ~/.config/gitea/config.env 或环境变量" >&2
exit 1
;;
-h|--help) -h|--help)
echo "用法: $0 [--from-secrets]" echo "用法: $0"
echo "" echo ""
echo " --from-secrets 从 secrets CLI (refining/service gitea) 获取 token 和 webhook_url" echo "从 ~/.config/gitea/config.env 读取,或由环境变量覆盖。"
echo " 否则从 ~/.config/gitea/config.env 读取"
echo "" echo ""
echo "环境变量覆盖:" echo "环境变量:"
echo " GITEA_URL Gitea 实例地址" echo " GITEA_URL Gitea 实例地址(可误带尾部 /api/v1脚本会规范化后拼接"
echo " GITEA_TOKEN 用于 Release 上传的 PAT (创建 RELEASE_TOKEN secret)" echo " GITEA_TOKEN 用于 Release 的 PAT → secrets.RELEASE_TOKEN"
echo " WEBHOOK_URL 飞书 Webhook URL (创建 variable可选)" echo " WEBHOOK_URL 或 GITEA_WEBHOOK_URL vars.WEBHOOK_URL可选"
echo " DEPLOY_HOST 部署 SSH 主机(可选,须与下面两项同时设置)"
echo " DEPLOY_USER 部署 SSH 用户"
echo " DEPLOY_SSH_KEY_FILE 本地 PEM 路径 → secrets.DEPLOY_SSH_KEY原文上传勿 base64"
exit 0 exit 0
;; ;;
*) shift ;; *)
echo "❌ 未知参数: $1" >&2
echo " 使用 $0 --help 查看用法" >&2
exit 1
;;
esac esac
done done
for cmd in curl jq; do
if ! command -v "$cmd" &>/dev/null; then
echo "❌ 未找到命令: $cmd(本脚本依赖 curl 与 jq" >&2
exit 1
fi
done
# 加载配置 # 加载配置
load_config() { load_config() {
local config="$HOME/.config/gitea/config.env" local config="$HOME/.config/gitea/config.env"
@@ -50,26 +74,6 @@ load_config() {
fi fi
} }
# 从 secrets CLI 获取 gitea 凭据
fetch_from_secrets() {
if ! command -v secrets &>/dev/null; then
echo "❌ secrets CLI 未找到,请先构建: cargo build --release" >&2
return 1
fi
# 输出 JSON 格式便于解析;需要 --show-secrets
# secrets 当前无 JSON 输出,用简单解析
local out
out=$(secrets search -n refining --kind service -q gitea --show-secrets 2>/dev/null || true)
if [[ -z "$out" ]]; then
echo "❌ 未找到 refining/service gitea 记录" >&2
return 1
fi
# 简化:从 metadata 和 secrets 中提取,实际格式需根据 search 输出调整
# 此处仅作占位,实际解析较复杂;建议用户优先用 config.env
echo "⚠️ --from-secrets 暂不支持自动解析,请使用 config.env 或环境变量" >&2
return 1
}
load_config load_config
# 优先使用环境变量 # 优先使用环境变量
@@ -84,18 +88,17 @@ if [[ -z "$GITEA_URL" ]]; then
exit 1 exit 1
fi fi
# 去掉 URL 尾部斜杠 # 规范为实例根 URL:去尾部斜杠,并去掉重复的 .../api/v1 后缀(避免拼成 .../api/v1/api/v1
GITEA_URL="${GITEA_URL%/}" GITEA_URL="${GITEA_URL%/}"
# 确保使用 /api/v1 基础路径(若用户只写了根 URL while [[ "$GITEA_URL" == */api/v1 ]]; do
[[ "$GITEA_URL" != *"/api/v1"* ]] || true GITEA_URL="${GITEA_URL%/api/v1}"
GITEA_URL="${GITEA_URL%/}"
done
API_BASE="${GITEA_URL}/api/v1" API_BASE="${GITEA_URL}/api/v1"
# 获取 GITEA_TOKEN作为 workflow 中 secrets.RELEASE_TOKEN 的值) # 获取 GITEA_TOKEN作为 workflow 中 secrets.RELEASE_TOKEN 的值)
if [[ -z "$GITEA_TOKEN" ]]; then if [[ -z "$GITEA_TOKEN" ]]; then
if $USE_SECRETS_CLI; then
fetch_from_secrets || exit 1
fi
echo "❌ GITEA_TOKEN 未配置" echo "❌ GITEA_TOKEN 未配置"
echo " 在 ~/.config/gitea/config.env 中设置,或 export GITEA_TOKEN=xxx" >&2 echo " 在 ~/.config/gitea/config.env 中设置,或 export GITEA_TOKEN=xxx" >&2
echo " Token 需具备 repo 写权限(创建 Release、上传附件" >&2 echo " Token 需具备 repo 写权限(创建 Release、上传附件" >&2
@@ -107,13 +110,13 @@ echo "配置 Gitea Actions: $OWNER/$REPO"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo "" echo ""
# 1. 创建 Secret: RELEASE_TOKEN # 1. 创建 Secret: RELEASE_TOKENdata = PAT 原文,勿 base64
echo "1. 创建 Secret: RELEASE_TOKEN" echo "1. 创建 Secret: RELEASE_TOKEN"
encoded=$(echo -n "$GITEA_TOKEN" | base64) secret_payload=$(jq -n --arg t "$GITEA_TOKEN" '{data: $t}')
resp=$(curl -s -w "\n%{http_code}" -X PUT \ resp=$(curl -s -w "\n%{http_code}" -X PUT \
-H "Authorization: token $GITEA_TOKEN" \ -H "Authorization: token $GITEA_TOKEN" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d "{\"data\":\"${encoded}\"}" \ -d "$secret_payload" \
"${API_BASE}/repos/${OWNER}/${REPO}/actions/secrets/RELEASE_TOKEN") "${API_BASE}/repos/${OWNER}/${REPO}/actions/secrets/RELEASE_TOKEN")
http_code=$(echo "$resp" | tail -n1) http_code=$(echo "$resp" | tail -n1)
body=$(echo "$resp" | sed '$d') body=$(echo "$resp" | sed '$d')
@@ -126,15 +129,16 @@ else
exit 1 exit 1
fi fi
# 2. 创建/更新 Variable: WEBHOOK_URL可选 # 2. 创建/更新 Variable: WEBHOOK_URL可选value 为原始 URL 字符串,勿 base64
WEBHOOK_VALUE="${WEBHOOK_URL:-$GITEA_WEBHOOK_URL}" WEBHOOK_VALUE="${WEBHOOK_URL:-$GITEA_WEBHOOK_URL}"
if [[ -n "$WEBHOOK_VALUE" ]]; then if [[ -n "$WEBHOOK_VALUE" ]]; then
echo "" echo ""
echo "2. 创建/更新 Variable: WEBHOOK_URL" echo "2. 创建/更新 Variable: WEBHOOK_URL"
var_payload=$(jq -n --arg v "$WEBHOOK_VALUE" '{value: $v}')
resp=$(curl -s -w "\n%{http_code}" -X POST \ resp=$(curl -s -w "\n%{http_code}" -X POST \
-H "Authorization: token $GITEA_TOKEN" \ -H "Authorization: token $GITEA_TOKEN" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d "{\"value\":\"${WEBHOOK_VALUE}\"}" \ -d "$var_payload" \
"${API_BASE}/repos/${OWNER}/${REPO}/actions/variables/WEBHOOK_URL") "${API_BASE}/repos/${OWNER}/${REPO}/actions/variables/WEBHOOK_URL")
http_code=$(echo "$resp" | tail -n1) http_code=$(echo "$resp" | tail -n1)
body=$(echo "$resp" | sed '$d') body=$(echo "$resp" | sed '$d')
@@ -146,7 +150,7 @@ if [[ -n "$WEBHOOK_VALUE" ]]; then
resp=$(curl -s -w "\n%{http_code}" -X PUT \ resp=$(curl -s -w "\n%{http_code}" -X PUT \
-H "Authorization: token $GITEA_TOKEN" \ -H "Authorization: token $GITEA_TOKEN" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d "{\"value\":\"${WEBHOOK_VALUE}\"}" \ -d "$var_payload" \
"${API_BASE}/repos/${OWNER}/${REPO}/actions/variables/WEBHOOK_URL") "${API_BASE}/repos/${OWNER}/${REPO}/actions/variables/WEBHOOK_URL")
http_code=$(echo "$resp" | tail -n1) http_code=$(echo "$resp" | tail -n1)
if [[ "$http_code" == "200" || "$http_code" == "204" ]]; then if [[ "$http_code" == "200" || "$http_code" == "204" ]]; then
@@ -163,6 +167,68 @@ else
echo " 飞书通知将不可用;如需可后续在仓库 Settings → Variables 中添加" echo " 飞书通知将不可用;如需可后续在仓库 Settings → Variables 中添加"
fi fi
# 3. 部署用 Variable + Secret与 .gitea/workflows/secrets.yml 中 deploy-mcp 一致)
upsert_repo_variable() {
local var_name="$1" var_value="$2"
local var_payload http_code body resp
var_payload=$(jq -n --arg v "$var_value" '{value: $v}')
resp=$(curl -s -w "\n%{http_code}" -X POST \
-H "Authorization: token $GITEA_TOKEN" \
-H "Content-Type: application/json" \
-d "$var_payload" \
"${API_BASE}/repos/${OWNER}/${REPO}/actions/variables/${var_name}")
http_code=$(echo "$resp" | tail -n1)
if [[ "$http_code" == "200" || "$http_code" == "201" || "$http_code" == "204" ]]; then
return 0
fi
if [[ "$http_code" == "409" ]]; then
resp=$(curl -s -w "\n%{http_code}" -X PUT \
-H "Authorization: token $GITEA_TOKEN" \
-H "Content-Type: application/json" \
-d "$var_payload" \
"${API_BASE}/repos/${OWNER}/${REPO}/actions/variables/${var_name}")
http_code=$(echo "$resp" | tail -n1)
[[ "$http_code" == "200" || "$http_code" == "204" ]]
return
fi
body=$(echo "$resp" | sed '$d')
echo " ❌ 变量 ${var_name} 失败 (HTTP $http_code)" >&2
echo "$body" | jq -r '.message // .' 2>/dev/null || echo "$body" >&2
return 1
}
if [[ -n "$DEPLOY_HOST" && -n "$DEPLOY_USER" && -n "$DEPLOY_SSH_KEY_FILE" ]]; then
echo ""
echo "3. 部署目标: vars.DEPLOY_HOST / vars.DEPLOY_USER + secrets.DEPLOY_SSH_KEY"
if [[ ! -f "$DEPLOY_SSH_KEY_FILE" ]]; then
echo " ❌ DEPLOY_SSH_KEY_FILE 不是文件: $DEPLOY_SSH_KEY_FILE" >&2
exit 1
fi
upsert_repo_variable DEPLOY_HOST "$DEPLOY_HOST" || exit 1
echo " ✓ DEPLOY_HOST"
upsert_repo_variable DEPLOY_USER "$DEPLOY_USER" || exit 1
echo " ✓ DEPLOY_USER"
# PEM 原文写入 secret.data勿对文件先做 base64否则 runner 侧 ssh 无法解析密钥
secret_payload=$(jq -n --rawfile k "$DEPLOY_SSH_KEY_FILE" '{data: $k}')
resp=$(curl -s -w "\n%{http_code}" -X PUT \
-H "Authorization: token $GITEA_TOKEN" \
-H "Content-Type: application/json" \
-d "$secret_payload" \
"${API_BASE}/repos/${OWNER}/${REPO}/actions/secrets/DEPLOY_SSH_KEY")
http_code=$(echo "$resp" | tail -n1)
body=$(echo "$resp" | sed '$d')
if [[ "$http_code" == "200" || "$http_code" == "201" || "$http_code" == "204" ]]; then
echo " ✓ DEPLOY_SSH_KEY"
else
echo " ❌ DEPLOY_SSH_KEY 失败 (HTTP $http_code)" >&2
echo "$body" | jq -r '.message // .' 2>/dev/null || echo "$body" >&2
exit 1
fi
else
echo ""
echo "3. 跳过部署配置(需同时设置 DEPLOY_HOST、DEPLOY_USER、DEPLOY_SSH_KEY_FILE"
fi
echo "" echo ""
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo "✓ 配置完成" echo "✓ 配置完成"
@@ -171,6 +237,7 @@ echo ""
echo "Workflow 将使用:" echo "Workflow 将使用:"
echo " - secrets.RELEASE_TOKEN 创建 Release 并上传二进制" echo " - secrets.RELEASE_TOKEN 创建 Release 并上传二进制"
echo " - vars.WEBHOOK_URL 发送飞书通知(如已配置)" echo " - vars.WEBHOOK_URL 发送飞书通知(如已配置)"
echo " - vars.DEPLOY_* / secrets.DEPLOY_SSH_KEY deploy-mcp如已配置"
echo "" echo ""
echo "推送代码触发构建:" echo "推送代码触发构建:"
echo " git push origin main" echo " git push origin main"

View File

@@ -1,87 +0,0 @@
use anyhow::Result;
use serde_json::{Map, Value};
use sqlx::PgPool;
use std::fs;
/// Parse "key=value" entries. Value starting with '@' reads from file.
fn parse_kv(entry: &str) -> Result<(String, String)> {
let (key, raw_val) = entry.split_once('=').ok_or_else(|| {
anyhow::anyhow!(
"Invalid format '{}'. Expected: key=value or key=@file",
entry
)
})?;
let value = if let Some(path) = raw_val.strip_prefix('@') {
fs::read_to_string(path)
.map_err(|e| anyhow::anyhow!("Failed to read file '{}': {}", path, e))?
} else {
raw_val.to_string()
};
Ok((key.to_string(), value))
}
fn build_json(entries: &[String]) -> Result<Value> {
let mut map = Map::new();
for entry in entries {
let (key, value) = parse_kv(entry)?;
map.insert(key, Value::String(value));
}
Ok(Value::Object(map))
}
pub async fn run(
pool: &PgPool,
namespace: &str,
kind: &str,
name: &str,
tags: &[String],
meta_entries: &[String],
secret_entries: &[String],
) -> Result<()> {
let metadata = build_json(meta_entries)?;
let encrypted = build_json(secret_entries)?;
sqlx::query(
r#"
INSERT INTO secrets (namespace, kind, name, tags, metadata, encrypted, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, NOW())
ON CONFLICT (namespace, kind, name)
DO UPDATE SET
tags = EXCLUDED.tags,
metadata = EXCLUDED.metadata,
encrypted = EXCLUDED.encrypted,
updated_at = NOW()
"#,
)
.bind(namespace)
.bind(kind)
.bind(name)
.bind(tags)
.bind(&metadata)
.bind(&encrypted)
.execute(pool)
.await?;
println!("Added: [{}/{}] {}", namespace, kind, name);
if !tags.is_empty() {
println!(" tags: {}", tags.join(", "));
}
if !meta_entries.is_empty() {
let keys: Vec<&str> = meta_entries
.iter()
.filter_map(|s| s.split_once('=').map(|(k, _)| k))
.collect();
println!(" metadata: {}", keys.join(", "));
}
if !secret_entries.is_empty() {
let keys: Vec<&str> = secret_entries
.iter()
.filter_map(|s| s.split_once('=').map(|(k, _)| k))
.collect();
println!(" secrets: {}", keys.join(", "));
}
Ok(())
}

View File

@@ -1,20 +0,0 @@
use anyhow::Result;
use sqlx::PgPool;
pub async fn run(pool: &PgPool, namespace: &str, kind: &str, name: &str) -> Result<()> {
let result = sqlx::query(
"DELETE FROM secrets WHERE namespace = $1 AND kind = $2 AND name = $3",
)
.bind(namespace)
.bind(kind)
.bind(name)
.execute(pool)
.await?;
if result.rows_affected() == 0 {
println!("Not found: [{}/{}] {}", namespace, kind, name);
} else {
println!("Deleted: [{}/{}] {}", namespace, kind, name);
}
Ok(())
}

View File

@@ -1,3 +0,0 @@
pub mod add;
pub mod delete;
pub mod search;

View File

@@ -1,104 +0,0 @@
use anyhow::Result;
use sqlx::PgPool;
use crate::models::Secret;
pub async fn run(
pool: &PgPool,
namespace: Option<&str>,
kind: Option<&str>,
tag: Option<&str>,
query: Option<&str>,
show_secrets: bool,
) -> Result<()> {
let mut conditions: Vec<String> = Vec::new();
let mut idx: i32 = 1;
if namespace.is_some() {
conditions.push(format!("namespace = ${}", idx));
idx += 1;
}
if kind.is_some() {
conditions.push(format!("kind = ${}", idx));
idx += 1;
}
if tag.is_some() {
conditions.push(format!("tags @> ARRAY[${}]", idx));
idx += 1;
}
if query.is_some() {
conditions.push(format!(
"(name ILIKE ${i} OR namespace ILIKE ${i} OR kind ILIKE ${i} OR metadata::text ILIKE ${i} OR EXISTS (SELECT 1 FROM unnest(tags) t WHERE t ILIKE ${i}))",
i = idx
));
}
let where_clause = if conditions.is_empty() {
String::new()
} else {
format!("WHERE {}", conditions.join(" AND "))
};
let sql = format!(
"SELECT * FROM secrets {} ORDER BY namespace, kind, name",
where_clause
);
let mut q = sqlx::query_as::<_, Secret>(&sql);
if let Some(v) = namespace {
q = q.bind(v);
}
if let Some(v) = kind {
q = q.bind(v);
}
if let Some(v) = tag {
q = q.bind(v);
}
if let Some(v) = query {
q = q.bind(format!("%{}%", v));
}
let rows = q.fetch_all(pool).await?;
if rows.is_empty() {
println!("No records found.");
return Ok(());
}
for row in &rows {
println!(
"[{}/{}] {}",
row.namespace, row.kind, row.name,
);
println!(" id: {}", row.id);
if !row.tags.is_empty() {
println!(" tags: [{}]", row.tags.join(", "));
}
let meta_obj = row.metadata.as_object();
if let Some(m) = meta_obj {
if !m.is_empty() {
println!(" metadata: {}", serde_json::to_string_pretty(&row.metadata)?);
}
}
if show_secrets {
println!(" secrets: {}", serde_json::to_string_pretty(&row.encrypted)?);
} else {
let keys: Vec<String> = row
.encrypted
.as_object()
.map(|m| m.keys().cloned().collect())
.unwrap_or_default();
if !keys.is_empty() {
println!(" secrets: [{}] (--show-secrets to reveal)", keys.join(", "));
}
}
println!(" created: {}", row.created_at.format("%Y-%m-%d %H:%M:%S UTC"));
println!();
}
println!("{} record(s) found.", rows.len());
Ok(())
}

View File

@@ -1,44 +0,0 @@
use anyhow::Result;
use sqlx::postgres::PgPoolOptions;
use sqlx::PgPool;
pub async fn create_pool(database_url: &str) -> Result<PgPool> {
let pool = PgPoolOptions::new()
.max_connections(5)
.connect(database_url)
.await?;
Ok(pool)
}
pub async fn migrate(pool: &PgPool) -> Result<()> {
sqlx::raw_sql(
r#"
CREATE TABLE IF NOT EXISTS secrets (
id UUID PRIMARY KEY DEFAULT uuidv7(),
namespace VARCHAR(64) NOT NULL,
kind VARCHAR(64) NOT NULL,
name VARCHAR(256) NOT NULL,
tags TEXT[] NOT NULL DEFAULT '{}',
metadata JSONB NOT NULL DEFAULT '{}',
encrypted JSONB NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(namespace, kind, name)
);
-- idempotent column add for existing tables
DO $$ BEGIN
ALTER TABLE secrets ADD COLUMN IF NOT EXISTS metadata JSONB NOT NULL DEFAULT '{}';
EXCEPTION WHEN OTHERS THEN NULL;
END $$;
CREATE INDEX IF NOT EXISTS idx_secrets_namespace ON secrets(namespace);
CREATE INDEX IF NOT EXISTS idx_secrets_kind ON secrets(kind);
CREATE INDEX IF NOT EXISTS idx_secrets_tags ON secrets USING GIN(tags);
CREATE INDEX IF NOT EXISTS idx_secrets_metadata ON secrets USING GIN(metadata jsonb_path_ops);
"#,
)
.execute(pool)
.await?;
Ok(())
}

View File

@@ -1,132 +0,0 @@
mod commands;
mod db;
mod models;
use anyhow::Result;
use clap::{Parser, Subcommand};
use dotenvy::dotenv;
#[derive(Parser)]
#[command(name = "secrets", version, about = "Secrets & config manager backed by PostgreSQL")]
struct Cli {
/// Database URL (or set DATABASE_URL env var)
#[arg(long, env = "DATABASE_URL", global = true, default_value = "")]
db_url: String,
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand)]
enum Commands {
/// Add or update a record (upsert)
Add {
/// Namespace (e.g. refining, ricnsmart)
#[arg(short, long)]
namespace: String,
/// Kind of record (server, service, key, ...)
#[arg(long)]
kind: String,
/// Human-readable name
#[arg(long)]
name: String,
/// Tags for categorization (repeatable)
#[arg(long = "tag")]
tags: Vec<String>,
/// Plaintext metadata entry: key=value (repeatable, key=@file reads from file)
#[arg(long = "meta", short = 'm')]
meta: Vec<String>,
/// Secret entry: key=value (repeatable, key=@file reads from file)
#[arg(long = "secret", short = 's')]
secrets: Vec<String>,
},
/// Search records
Search {
/// Filter by namespace
#[arg(short, long)]
namespace: Option<String>,
/// Filter by kind
#[arg(long)]
kind: Option<String>,
/// Filter by tag
#[arg(long)]
tag: Option<String>,
/// Search by keyword (matches name, namespace, kind)
#[arg(short, long)]
query: Option<String>,
/// Reveal encrypted secret values
#[arg(long)]
show_secrets: bool,
},
/// Delete a record
Delete {
/// Namespace
#[arg(short, long)]
namespace: String,
/// Kind
#[arg(long)]
kind: String,
/// Name
#[arg(long)]
name: String,
},
}
#[tokio::main]
async fn main() -> Result<()> {
dotenv().ok();
let cli = Cli::parse();
let db_url = if cli.db_url.is_empty() {
std::env::var("DATABASE_URL").map_err(|_| {
anyhow::anyhow!("DATABASE_URL not set. Use --db-url or set DATABASE_URL env var.")
})?
} else {
cli.db_url.clone()
};
let pool = db::create_pool(&db_url).await?;
db::migrate(&pool).await?;
match &cli.command {
Commands::Add {
namespace,
kind,
name,
tags,
meta,
secrets,
} => {
commands::add::run(&pool, namespace, kind, name, tags, meta, secrets).await?;
}
Commands::Search {
namespace,
kind,
tag,
query,
show_secrets,
} => {
commands::search::run(
&pool,
namespace.as_deref(),
kind.as_deref(),
tag.as_deref(),
query.as_deref(),
*show_secrets,
)
.await?;
}
Commands::Delete {
namespace,
kind,
name,
} => {
commands::delete::run(&pool, namespace, kind, name).await?;
}
}
Ok(())
}

View File

@@ -1,17 +0,0 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use uuid::Uuid;
#[derive(Debug, Serialize, Deserialize, sqlx::FromRow)]
pub struct Secret {
pub id: Uuid,
pub namespace: String,
pub kind: String,
pub name: String,
pub tags: Vec<String>,
pub metadata: Value,
pub encrypted: Value,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}