Compare commits

..

49 Commits

Author SHA1 Message Date
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
56 changed files with 8091 additions and 1844 deletions

View File

@@ -1,13 +1,14 @@
name: Secrets CLI - Build & Release
# MCP 分支:仅构建/发布 secrets-mcpCLI 在 main 分支维护)
name: Secrets MCP — Build & Release
on:
push:
branches: [main]
paths:
- 'src/**'
- 'crates/**'
- 'Cargo.toml'
- 'Cargo.lock'
- '.gitea/workflows/secrets.yml'
- 'deploy/**'
- '.gitea/workflows/**'
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
@@ -17,406 +18,231 @@ permissions:
contents: write
env:
BINARY_NAME: secrets
MCP_BINARY: secrets-mcp
RUST_TOOLCHAIN: 1.94.0
CARGO_INCREMENTAL: 0
CARGO_NET_RETRY: 10
CARGO_TERM_COLOR: always
RUST_BACKTRACE: short
MUSL_TARGET: x86_64-unknown-linux-musl
jobs:
version:
name: 版本 & Release
ci:
name: 检查 / 构建 / 发版
runs-on: debian
timeout-minutes: 40
outputs:
version: ${{ steps.ver.outputs.version }}
tag: ${{ steps.ver.outputs.tag }}
tag_exists: ${{ steps.ver.outputs.tag_exists }}
release_id: ${{ steps.release.outputs.release_id }}
version: ${{ steps.ver.outputs.version }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
# ── 版本解析 ────────────────────────────────────────────────────────
- name: 解析版本
id: ver
run: |
version=$(grep -m1 '^version' Cargo.toml | sed 's/.*"\(.*\)".*/\1/')
tag="secrets-${version}"
previous_tag=$(git tag --list 'secrets-*' --sort=-v:refname | awk -v tag="$tag" '$0 != tag { print; exit }')
version=$(grep -m1 '^version' crates/secrets-mcp/Cargo.toml | sed 's/.*"\(.*\)".*/\1/')
tag="secrets-mcp-${version}"
echo "version=${version}" >> "$GITHUB_OUTPUT"
echo "tag=${tag}" >> "$GITHUB_OUTPUT"
echo "previous_tag=${previous_tag}" >> "$GITHUB_OUTPUT"
if git rev-parse "refs/tags/${tag}" >/dev/null 2>&1; then
echo "⚠ 版本 ${tag} 已存在,将覆盖重新发版。"
echo "tag_exists=true" >> "$GITHUB_OUTPUT"
echo "版本 ${tag} 已存在"
else
echo "tag_exists=false" >> "$GITHUB_OUTPUT"
echo "将创建新版本 ${tag}"
echo "tag_exists=false" >> "$GITHUB_OUTPUT"
fi
# ── Rust 工具链 ──────────────────────────────────────────────────────
- name: 安装 Rust 与 musl 工具链
run: |
sudo apt-get update -qq
sudo apt-get install -y -qq pkg-config musl-tools binutils jq
if ! command -v rustup >/dev/null 2>&1; then
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain "${RUST_TOOLCHAIN}"
echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
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
- name: 缓存 Cargo
uses: actions/cache@v4
with:
path: |
~/.cargo/registry/index
~/.cargo/registry/cache
~/.cargo/git/db
target
key: cargo-${{ env.MUSL_TARGET }}-${{ env.RUST_TOOLCHAIN }}-${{ hashFiles('Cargo.lock') }}
restore-keys: |
cargo-${{ env.MUSL_TARGET }}-${{ env.RUST_TOOLCHAIN }}-
cargo-${{ env.MUSL_TARGET }}-
# ── 质量检查(先于构建,失败即止)──────────────────────────────────
- name: fmt
run: cargo fmt -- --check
- name: clippy
run: cargo clippy --locked -- -D warnings
- name: test
run: cargo test --locked
# ── 构建(质量检查通过后才执行)────────────────────────────────────
- name: 构建 secrets-mcp (musl)
run: |
cargo build --release --locked --target "${MUSL_TARGET}" -p secrets-mcp
strip "target/${MUSL_TARGET}/release/${MCP_BINARY}"
- name: 上传构建产物
uses: actions/upload-artifact@v3
with:
name: ${{ env.MCP_BINARY }}-linux-musl
path: target/${{ env.MUSL_TARGET }}/release/${{ env.MCP_BINARY }}
retention-days: 3
# ── 创建 / 覆盖 Tag构建成功后才打───────────────────────────────
- name: 创建 Tag
if: steps.ver.outputs.tag_exists == 'false'
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git tag -a "${{ steps.ver.outputs.tag }}" -m "Release ${{ steps.ver.outputs.tag }}"
git push origin "${{ steps.ver.outputs.tag }}"
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: 解析或创建 Release
id: release
# ── Release可选需配置 RELEASE_TOKEN───────────────────────────
- name: Upsert Release
if: env.RELEASE_TOKEN != ''
env:
RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }}
run: |
if [ -z "$RELEASE_TOKEN" ]; then
echo "release_id=" >> "$GITHUB_OUTPUT"
exit 0
fi
command -v jq >/dev/null 2>&1 || (sudo apt-get update -qq && sudo apt-get install -y -qq jq)
tag="${{ steps.ver.outputs.tag }}"
version="${{ steps.ver.outputs.version }}"
release_api="${{ github.server_url }}/api/v1/repos/${{ github.repository }}/releases"
api="${{ github.server_url }}/api/v1/repos/${{ github.repository }}/releases"
auth="Authorization: token $RELEASE_TOKEN"
http_code=$(curl -sS -o /tmp/release.json -w '%{http_code}' \
-H "Authorization: token $RELEASE_TOKEN" \
"${release_api}/tags/${tag}")
if [ "$http_code" = "200" ]; then
release_id=$(jq -r '.id // empty' /tmp/release.json)
if [ -n "$release_id" ]; then
echo "已找到现有 Release: ${release_id}"
echo "release_id=${release_id}" >> "$GITHUB_OUTPUT"
exit 0
fi
fi
previous_tag="${{ steps.ver.outputs.previous_tag }}"
previous_tag=$(git tag --list 'secrets-mcp-*' --sort=-v:refname | awk -v t="$tag" '$0 != t { print; exit }')
if [ -n "$previous_tag" ]; then
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")
payload=$(jq -n \
--arg tag "$tag" \
--arg name "${{ env.BINARY_NAME }} ${version}" \
--arg body "$body" \
'{tag_name: $tag, name: $name, body: $body, draft: true}')
http_code=$(curl -sS -o /tmp/create-release.json -w '%{http_code}' \
-H "Authorization: token $RELEASE_TOKEN" \
-H "Content-Type: application/json" \
-X POST "$release_api" \
-d "$payload")
if [ "$http_code" = "201" ] || [ "$http_code" = "200" ]; then
release_id=$(jq -r '.id // empty' /tmp/create-release.json)
fi
# Upsert: 存在 → PATCH + 清旧 assets不存在 → POST
release_id=$(curl -sS -H "$auth" "${api}/tags/${tag}" 2>/dev/null | jq -r '.id // empty')
if [ -n "$release_id" ]; then
echo "已创建草稿 Release: ${release_id}"
echo "release_id=${release_id}" >> "$GITHUB_OUTPUT"
curl -sS -o /dev/null -H "$auth" -H "Content-Type: application/json" \
-X PATCH "${api}/${release_id}" \
-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" | \
jq -r '.[].id' | xargs -I{} curl -sS -o /dev/null -H "$auth" -X DELETE "${api}/${release_id}/assets/{}"
echo "已更新 Release ${release_id}"
else
echo "⚠ 创建 Release 失败 (HTTP ${http_code}),跳过产物上传"
cat /tmp/create-release.json 2>/dev/null || true
echo "release_id=" >> "$GITHUB_OUTPUT"
release_id=$(curl -fsS -H "$auth" -H "Content-Type: application/json" \
-X POST "$api" \
-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
check:
name: 质量检查 (fmt / clippy / test)
runs-on: debian
timeout-minutes: 10
steps:
- name: 安装 Rust
run: |
if ! command -v cargo >/dev/null 2>&1; then
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable
echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
fi
source "$HOME/.cargo/env" 2>/dev/null || true
rustup component add rustfmt clippy
- uses: actions/checkout@v4
- name: 缓存 Cargo
uses: actions/cache@v4
with:
path: |
~/.cargo/registry/index
~/.cargo/registry/cache
~/.cargo/git/db
target
key: cargo-check-${{ hashFiles('Cargo.lock') }}
restore-keys: |
cargo-check-
- run: cargo fmt -- --check
- run: cargo clippy --locked -- -D warnings
- run: cargo test --locked
build-linux:
name: Build (x86_64-unknown-linux-musl)
needs: [version, check]
runs-on: debian
timeout-minutes: 10
steps:
- name: 安装依赖
run: |
sudo apt-get update
sudo apt-get install -y pkg-config musl-tools binutils curl
if ! command -v cargo >/dev/null 2>&1; then
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable
fi
source "$HOME/.cargo/env" 2>/dev/null || true
rustup target add x86_64-unknown-linux-musl
echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
- uses: actions/checkout@v4
- name: 缓存 Cargo
uses: actions/cache@v4
with:
path: |
~/.cargo/registry/index
~/.cargo/registry/cache
~/.cargo/git/db
target
key: cargo-x86_64-unknown-linux-musl-${{ hashFiles('Cargo.lock') }}
restore-keys: |
cargo-x86_64-unknown-linux-musl-
- run: cargo build --release --locked --target x86_64-unknown-linux-musl
- run: strip target/x86_64-unknown-linux-musl/release/${{ env.BINARY_NAME }}
- name: 上传 Release 产物
if: needs.version.outputs.release_id != ''
env:
RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }}
run: |
[ -z "$RELEASE_TOKEN" ] && exit 0
tag="${{ needs.version.outputs.tag }}"
bin="target/x86_64-unknown-linux-musl/release/${{ env.BINARY_NAME }}"
archive="${{ env.BINARY_NAME }}-${tag}-x86_64-linux-musl.tar.gz"
bin="target/${MUSL_TARGET}/release/${MCP_BINARY}"
archive="${MCP_BINARY}-${tag}-x86_64-linux-musl.tar.gz"
tar -czf "$archive" -C "$(dirname "$bin")" "$(basename "$bin")"
curl -fsS -H "Authorization: token $RELEASE_TOKEN" \
-F "attachment=@${archive}" \
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/releases/${{ needs.version.outputs.release_id }}/assets"
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} 已发布"
build-macos:
name: Build (aarch64-apple-darwin)
needs: [version, check]
if: vars.BUILD_MACOS != 'false'
runs-on: darwin-arm64
timeout-minutes: 10
steps:
- name: 安装依赖
run: |
if ! command -v cargo >/dev/null 2>&1; then
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable
fi
source "$HOME/.cargo/env" 2>/dev/null || true
rustup target add aarch64-apple-darwin
echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
- uses: actions/checkout@v4
- name: 缓存 Cargo
uses: actions/cache@v4
with:
path: |
~/.cargo/registry/index
~/.cargo/registry/cache
~/.cargo/git/db
target
key: cargo-aarch64-apple-darwin-${{ hashFiles('Cargo.lock') }}
restore-keys: |
cargo-aarch64-apple-darwin-
- run: cargo build --release --locked --target aarch64-apple-darwin
- run: strip -x target/aarch64-apple-darwin/release/${{ env.BINARY_NAME }}
- name: 上传 Release 产物
if: needs.version.outputs.release_id != ''
env:
RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }}
run: |
[ -z "$RELEASE_TOKEN" ] && exit 0
tag="${{ needs.version.outputs.tag }}"
bin="target/aarch64-apple-darwin/release/${{ env.BINARY_NAME }}"
archive="${{ env.BINARY_NAME }}-${tag}-aarch64-macos.tar.gz"
tar -czf "$archive" -C "$(dirname "$bin")" "$(basename "$bin")"
curl -fsS -H "Authorization: token $RELEASE_TOKEN" \
-F "attachment=@${archive}" \
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/releases/${{ needs.version.outputs.release_id }}/assets"
build-windows:
name: Build (x86_64-pc-windows-msvc)
needs: [version, check]
if: vars.BUILD_WINDOWS == 'true'
runs-on: windows
timeout-minutes: 10
steps:
- name: 安装依赖
shell: pwsh
run: |
if (-not (Get-Command cargo -ErrorAction SilentlyContinue)) {
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 target add x86_64-pc-windows-msvc
- uses: actions/checkout@v4
- name: 缓存 Cargo
uses: actions/cache@v4
with:
path: |
~/.cargo/registry/index
~/.cargo/registry/cache
~/.cargo/git/db
target
key: cargo-x86_64-pc-windows-msvc-${{ hashFiles('Cargo.lock') }}
restore-keys: |
cargo-x86_64-pc-windows-msvc-
- name: 构建
shell: pwsh
run: cargo build --release --locked --target x86_64-pc-windows-msvc
- name: 上传 Release 产物
if: needs.version.outputs.release_id != ''
shell: pwsh
env:
RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }}
run: |
if (-not $env:RELEASE_TOKEN) { exit 0 }
$tag = "${{ needs.version.outputs.tag }}"
$bin = "target\x86_64-pc-windows-msvc\release\${{ env.BINARY_NAME }}.exe"
$archive = "${{ env.BINARY_NAME }}-${tag}-x86_64-windows.zip"
Compress-Archive -Path $bin -DestinationPath $archive -Force
$url = "${{ github.server_url }}/api/v1/repos/${{ github.repository }}/releases/${{ needs.version.outputs.release_id }}/assets"
Invoke-RestMethod -Uri $url -Method Post `
-Headers @{ "Authorization" = "token $env:RELEASE_TOKEN" } `
-Form @{ attachment = Get-Item $archive }
publish-release:
name: 发布草稿 Release
needs: [version, build-linux]
if: needs.version.outputs.release_id != ''
runs-on: debian
timeout-minutes: 2
steps:
- name: 发布草稿
env:
RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }}
run: |
[ -z "$RELEASE_TOKEN" ] && exit 0
linux_r="${{ needs.build-linux.result }}"
if [ "$linux_r" != "success" ]; then
echo "Linux 构建未成功 (${linux_r}),保留草稿 Release"
exit 0
fi
release_api="${{ github.server_url }}/api/v1/repos/${{ github.repository }}/releases/${{ needs.version.outputs.release_id }}"
http_code=$(curl -sS -o /tmp/publish-release.json -w '%{http_code}' \
-H "Authorization: token $RELEASE_TOKEN" \
-H "Content-Type: application/json" \
-X PATCH "$release_api" \
-d '{"draft":false}')
if [ "$http_code" != "200" ]; then
echo "发布草稿 Release 失败 (HTTP ${http_code})"
cat /tmp/publish-release.json 2>/dev/null || true
exit 1
fi
notify:
name: 通知
needs: [version, check]
if: always() && github.event_name == 'push'
runs-on: debian
timeout-minutes: 1
continue-on-error: true
steps:
- uses: actions/checkout@v4
- name: 发送飞书通知
# ── 飞书汇总通知 ─────────────────────────────────────────────────────
- name: 飞书通知
if: always()
env:
WEBHOOK_URL: ${{ vars.WEBHOOK_URL }}
RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }}
run: |
[ -z "$WEBHOOK_URL" ] && exit 0
command -v jq >/dev/null 2>&1 || (sudo apt-get update -qq && sudo apt-get install -y -qq jq)
tag="${{ needs.version.outputs.tag }}"
tag_exists="${{ needs.version.outputs.tag_exists }}"
commit=$(git log -1 --pretty=format:"%s" 2>/dev/null || echo "N/A")
tag="${{ steps.ver.outputs.tag }}"
commit="${{ github.event.head_commit.message }}"
[ -z "$commit" ] && commit="${{ github.sha }}"
url="${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_number }}"
version_r="${{ needs.version.result }}"
check_r="${{ needs.check.result }}"
# 通过 API 查询当前 run 的构建 job 状态best-effort
linux_r="unknown"; macos_r="unknown"; windows_r="unknown"; publish_r="unknown"
if [ -n "$RELEASE_TOKEN" ]; then
sleep 3
run_api="${{ github.server_url }}/api/v1/repos/${{ github.repository }}/actions/tasks"
http_code=$(curl -sS -o /tmp/jobs.json -w '%{http_code}' \
-H "Authorization: token $RELEASE_TOKEN" "$run_api" 2>/dev/null) || true
if [ "$http_code" = "200" ] && [ -f /tmp/jobs.json ]; then
get_status() {
jq -r --arg name "$1" '
(.workflow_runs // .task_runs // . // [])[]?
| select(.name == $name)
| .status // "unknown"
' /tmp/jobs.json 2>/dev/null | head -1
}
s=$(get_status "Build (x86_64-unknown-linux-musl)"); [ -n "$s" ] && linux_r="$s"
s=$(get_status "Build (aarch64-apple-darwin)"); [ -n "$s" ] && macos_r="$s"
s=$(get_status "Build (x86_64-pc-windows-msvc)"); [ -n "$s" ] && windows_r="$s"
s=$(get_status "发布草稿 Release"); [ -n "$s" ] && publish_r="$s"
fi
fi
if [ "$version_r" = "success" ] && [ "$check_r" = "success" ]; then
status="检查通过 ✅"
else
status="检查失败 ❌"
fi
icon() {
case "$1" in
success) echo "✅" ;;
skipped) echo "⏭" ;;
unknown) echo "⏳" ;;
*) echo "❌" ;;
esac
}
msg="${{ env.BINARY_NAME }} ${status}"
if [ "$tag_exists" = "false" ]; then
msg="${msg}
🆕 新版本 ${tag}"
else
msg="${msg}
🔄 重复构建 ${tag}"
fi
msg="${msg}
质量检查:$(icon "$check_r")
构建结果linux$(icon "$linux_r") macOS$(icon "$macos_r") windows$(icon "$windows_r")
Release$(icon "$publish_r")
result="${{ job.status }}"
if [ "$result" = "success" ]; then icon="✅"; else icon="❌"; fi
msg="secrets-mcp 构建&发版 ${icon}
版本:${tag}
提交:${commit}
作者:${{ 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"
deploy:
name: 部署 secrets-mcp
needs: [ci]
if: |
github.ref == 'refs/heads/main' ||
github.ref == 'refs/heads/feat/mcp' ||
github.ref == 'refs/heads/mcp'
runs-on: debian
timeout-minutes: 10
steps:
- name: 下载构建产物
uses: actions/download-artifact@v3
with:
name: ${{ env.MCP_BINARY }}-linux-musl
path: /tmp/artifact
- name: 部署到阿里云 ECS
env:
DEPLOY_HOST: ${{ vars.DEPLOY_HOST }}
DEPLOY_USER: ${{ vars.DEPLOY_USER }}
DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
run: |
if [ -z "$DEPLOY_HOST" ] || [ -z "$DEPLOY_USER" ] || [ -z "$DEPLOY_SSH_KEY" ]; then
echo "部署跳过:请配置 vars.DEPLOY_HOST、vars.DEPLOY_USER 与 secrets.DEPLOY_SSH_KEY"
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"

6
.gitignore vendored
View File

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

166
.vscode/tasks.json vendored
View File

@@ -2,148 +2,46 @@
"version": "2.0.0",
"tasks": [
{
"label": "build",
"label": "mcp: build",
"type": "shell",
"command": "cargo build",
"group": { "kind": "build", "isDefault": true }
"command": "cargo build --locked -p secrets-mcp",
"group": "build",
"options": {
"envFile": "${workspaceFolder}/.env"
}
},
{
"label": "cli: version",
"label": "mcp: run",
"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"
},
{
"label": "cli: help",
"label": "ci: release-check",
"type": "shell",
"command": "./target/debug/secrets --help",
"dependsOn": "build"
},
{
"label": "cli: help add",
"type": "shell",
"command": "./target/debug/secrets help add",
"dependsOn": "build"
},
{
"label": "cli: help config",
"type": "shell",
"command": "./target/debug/secrets help config",
"dependsOn": "build"
},
{
"label": "cli: config path",
"type": "shell",
"command": "./target/debug/secrets config path",
"dependsOn": "build"
},
{
"label": "cli: config show",
"type": "shell",
"command": "./target/debug/secrets config show",
"dependsOn": "build"
},
{
"label": "test: search all",
"type": "shell",
"command": "./target/debug/secrets search",
"dependsOn": "build"
},
{
"label": "test: search all (verbose)",
"type": "shell",
"command": "./target/debug/secrets --verbose 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 --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 + delete roundtrip (verbose)",
"type": "shell",
"command": "echo '--- add (verbose) ---' && ./target/debug/secrets --verbose add -n test --kind demo --name roundtrip-verbose --tag test -m foo=bar -s password=secret123 && echo '--- delete (verbose) ---' && ./target/debug/secrets --verbose delete -n test --kind demo --name roundtrip-verbose",
"dependsOn": "build"
},
{
"label": "test: update roundtrip",
"type": "shell",
"command": "echo '--- add ---' && ./target/debug/secrets add -n test --kind demo --name update-test --tag v1 -m env=staging && echo '--- update ---' && ./target/debug/secrets update -n test --kind demo --name update-test --add-tag v2 --remove-tag v1 -m env=production && echo '--- verify ---' && ./target/debug/secrets search -n test --kind demo && echo '--- cleanup ---' && ./target/debug/secrets delete -n test --kind demo --name update-test",
"dependsOn": "build"
},
{
"label": "test: audit log",
"type": "shell",
"command": "echo '--- add ---' && ./target/debug/secrets add -n test --kind demo --name audit-test -m foo=bar -s key=val && echo '--- update ---' && ./target/debug/secrets update -n test --kind demo --name audit-test -m foo=baz && echo '--- delete ---' && ./target/debug/secrets delete -n test --kind demo --name audit-test && echo '--- audit log (last 5) ---' && psql $DATABASE_URL -c \"SELECT action, namespace, kind, name, actor, detail, created_at FROM audit_log ORDER BY created_at DESC LIMIT 5;\"",
"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"
"command": "./scripts/release-check.sh",
"problemMatcher": []
}
]
}

289
AGENTS.md
View File

@@ -1,229 +1,170 @@
# 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-*'`
3. 若当前版本对应 tag 已存在,默认允许复用现有 tag 继续构建;仅在需要新的发布版本时再 bump `version``cargo build` 同步 `Cargo.lock`
4. 提交前优先运行 `./scripts/release-check.sh`(版本/tag + `fmt` + `clippy --locked` + `test --locked`)。
## 项目结构
```
secrets/
src/
main.rs # CLI 入口clap 命令定义auto-migrate--verbose 全局参数
config.rs # 配置读写:~/.config/secrets/config.tomldatabase_url
db.rs # PgPool 创建 + 建表/索引(幂等,含 audit_log
models.rs # Secret 结构体sqlx::FromRow + serde
audit.rs # 审计写入:向 audit_log 表记录所有写操作
commands/
add.rs # add 命令upsert支持 --meta key=value / --secret key=@file
config.rs # config 命令set-db / show / path持久化 database_url
search.rs # search 命令:多条件动态查询
delete.rs # delete 命令
update.rs # update 命令:增量更新(合并 tags/metadata/encrypted
Cargo.toml
crates/
secrets-core/ # db / crypto / models / audit / service
secrets-mcp/ # rmcp tools、axum、OAuth、Dashboard
scripts/
seed-data.sh # 从 refining/ricnsmart config.toml 导入全量数据
.gitea/workflows/
secrets.yml # CIfmt + clippy + musl 构建 + Release 上传 + 飞书通知
.vscode/tasks.json # 本地测试任务build / config / search / add+delete / update / audit 等)
release-check.sh
setup-gitea-actions.sh
.gitea/workflows/secrets.yml
.vscode/tasks.json
```
## 数据库
- **Host**: `47.117.131.22:5432`(阿里云上海 ECSPostgreSQL 18 with io_uring
- **Database**: `secrets`
- **连接串**: `postgres://postgres:<password>@47.117.131.22:5432/secrets`
- **表**: `secrets`(主表)+ `audit_log`审计表首次连接自动建表auto-migrate
- **建议库名**`secrets-mcp`(专用实例,与历史库名区分)。
- **连接**:环境变量 **`SECRETS_DATABASE_URL`**(本分支无本地配置文件路径)。
- **表**`entries`(含 `user_id`)、`secrets``entries_history``secrets_history``audit_log``users``oauth_accounts``api_keys`,首次连接 **auto-migrate**
### 表结构
### 表结构(摘录)
```sql
secrets (
id UUID PRIMARY KEY DEFAULT uuidv7(), -- PG18 时间有序 UUID
namespace VARCHAR(64) NOT NULL, -- 一级隔离: "refining" | "ricnsmart"
kind VARCHAR(64) NOT NULL, -- 类型: "server" | "service"(可扩展)
name VARCHAR(256) NOT NULL, -- 人类可读标识
tags TEXT[] NOT NULL DEFAULT '{}', -- 灵活标签: ["aliyun","hongkong"]
metadata JSONB NOT NULL DEFAULT '{}', -- 明文描述: ip, desc, domains, location...
encrypted JSONB NOT NULL DEFAULT '{}', -- 敏感数据: ssh_key, password, token...
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(namespace, kind, name)
)
```
### audit_log 表结构
```sql
audit_log (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
action VARCHAR(32) NOT NULL, -- 'add' | 'update' | 'delete'
entries (
id UUID PRIMARY KEY DEFAULT uuidv7(),
user_id UUID, -- 多租户NULL=遗留行;非空=归属用户
namespace VARCHAR(64) NOT NULL,
kind VARCHAR(64) NOT NULL,
name VARCHAR(256) NOT NULL,
detail JSONB NOT NULL DEFAULT '{}', -- 变更摘要tags/meta keys/secret keys不含 value
actor VARCHAR(128) NOT NULL DEFAULT '', -- 操作者($USER 环境变量)
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
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()
)
```
### 字段职责划分
| 字段 | 存什么 | 示例 |
|------|--------|------|
| `namespace` | 项目/团队隔离 | `refining`, `ricnsmart` |
| `kind` | 记录类型 | `server`, `service` |
| `name` | 唯一标识名 | `i-uf63f2uookgs5uxmrdyc`, `gitea` |
| `tags` | 多维分类标签 | `["aliyun","hongkong","ricn"]` |
| `metadata` | 明文非敏感信息 | `{"ip":"47.243.154.187","desc":"Grafana","domains":["..."]}` |
| `encrypted` | 敏感凭据MVP 阶段明文存储,后续对 value 加密) | `{"ssh_key":"-----BEGIN...","password":"..."}` |
## 数据库配置
首次使用需显式配置数据库连接,设置一次后在该设备上持久生效:
```bash
secrets config set-db "postgres://postgres:<password>@47.117.131.22:5432/secrets"
secrets config show # 查看当前配置(密码脱敏)
secrets config path # 打印配置文件路径
```sql
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)
)
```
配置文件:`~/.config/secrets/config.toml`,权限 0600。`--db-url` 参数可一次性覆盖。
### users / oauth_accounts / api_keys
## CLI 命令
```sql
users (
id UUID PRIMARY KEY DEFAULT uuidv7(),
email VARCHAR(256),
name VARCHAR(256) NOT NULL DEFAULT '',
avatar_url TEXT,
key_salt BYTEA, -- PBKDF2 salt32B首次设置密码短语时写入
key_check BYTEA, -- 派生密钥加密已知常量,用于验证密码短语
key_params JSONB, -- 算法参数,如 {"alg":"pbkdf2-sha256","iterations":600000}
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
```bash
# 查看版本
secrets -V / --version
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,
...
UNIQUE(provider, provider_id)
)
# 查看帮助
secrets -h / --help
secrets help <subcommand> # 子命令详细帮助,如 secrets help add
# 添加或更新记录upsert
secrets add -n <namespace> --kind <kind> --name <name> \
[--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
# 开启 debug 级别日志(全局参数,位于子命令之前)
secrets --verbose <subcommand>
secrets -v <subcommand>
# 或通过环境变量控制RUST_LOG=secrets=trace secrets search
# 增量更新已有记录(合并语义,记录不存在则报错)
secrets update -n <namespace> --kind <kind> --name <name> \
[--add-tag <tag>]... # 添加标签(不影响已有标签)
[--remove-tag <tag>]... # 移除标签
[-m key=value]... # 新增或覆盖 metadata 字段(不影响其他字段)
[--remove-meta <key>]... # 删除 metadata 字段
[-s key=value]... # 新增或覆盖 encrypted 字段(不影响其他字段)
[--remove-secret <key>]... # 删除 encrypted 字段
# 删除
secrets delete -n <namespace> --kind <kind> --name <name>
# 配置(持久化 database_url设置一次即可
secrets config set-db <url>
secrets config show
secrets config path
api_keys (
id UUID PRIMARY KEY DEFAULT uuidv7(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name VARCHAR(256) NOT NULL,
key_hash VARCHAR(64) NOT NULL UNIQUE,
key_prefix VARCHAR(12) NOT NULL,
last_used_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
```
### 示例
### audit_log / history
```bash
# 添加服务器
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
与迁移脚本一致:`audit_log``entries_history``secrets_history` 用于审计与时间旅行恢复;字段定义见 `crates/secrets-core/src/db.rs``migrate` SQL。
# 添加服务凭据
secrets add -n refining --kind service --name gitea \
--tag gitea \
-m url=https://gitea.refining.dev \
-s token=<token>
### 字段职责
# 搜索含 mqtt 的所有记录
secrets search -q mqtt
| 字段 | 含义 | 示例 |
|------|------|------|
| `namespace` | 隔离空间 | `refining` |
| `kind` | 记录类型 | `server`, `service`, `key` |
| `name` | 标识名 | `gitea`, `i-example0…` |
| `tags` | 标签 | `["aliyun","prod"]` |
| `metadata` | 明文描述 | `ip``url``key_ref` |
| `secrets.field_name` | 加密字段名(明文) | `token`, `ssh_key` |
| `secrets.encrypted` | 密文 | AES-GCM |
# 查看 refining 的全部服务配置(显示 secrets
secrets search -n refining --kind service --show-secrets
### PEM 共享(`key_ref`
# 按 tag 筛选
secrets search --tag hongkong
# 只更新一个 IP不影响其他 metadata/secrets/tags
secrets update -n refining --kind server --name i-uf63f2uookgs5uxmrdyc \
-m ip=10.0.0.1
# 给一条记录新增 tag 并轮换密码
secrets update -n refining --kind service --name gitea \
--add-tag production \
-s token=<new-token>
# 移除一个废弃的 metadata 字段
secrets update -n refining --kind service --name mqtt \
--remove-meta old_port
```
将共享 PEM 存为 `kind=key` 的 entry其它记录在 `metadata.key_ref` 指向该 key 的 `name`。更新 key 记录后,引用方通过服务层解析合并逻辑即可使用新密钥(实现见 `secrets_core::service`)。
## 代码规范
- 错误处理:统一使用 `anyhow::Result`不用 `unwrap()`
- 异步:全程 `tokio`,数据库操作 `sqlx` async
- SQL使用 `sqlx::query` / `sqlx::query_as` 绑定参数,禁止字符串拼接(搜索的动态 WHERE 子句除外,需使用参数绑定 `$1/$2`
- 新增 `kind` 类型时:只需在 `add` 调用时传入,无需改代码
- 字段命名CLI 短标志 `-n`=namespace`-m`=meta`-s`=secret`-q`=query`-v`=verbose
- 日志:用户可见输出用 `println!`;调试/运维信息用 `tracing::debug!`/`info!`/`warn!`/`error!`
- 审计:`add`/`update`/`delete` 成功后调用 `audit::log()`,写入 `audit_log` 表;失败只 warn 不中断
- 错误:业务层 `anyhow::Result`避免生产路径 `unwrap()`
- 异步:`tokio` + `sqlx` async
- SQL`sqlx::query` / `query_as` 参数绑定;动态 WHERE 仍须用占位符绑定。
- 日志:运维用 `tracing`;面向用户的 Web 响应走 axum handler。
- 审计:写操作成功后尽量 `audit::log_tx`;失败可 `warn`,不掩盖主错误。
- 加密:密钥由用户密码短语通过 **PBKDF2-SHA256600k 次)** 在客户端派生,服务端只存 `key_salt`/`key_check`/`key_params`不持有原始密钥。Web 客户端在浏览器本地完成加解密MCP 客户端通过 `X-Encryption-Key` 请求头传递密钥,服务端临时解密后返回明文。
- MCPtools 参数与 JSON Schema`schemars`)保持同步,鉴权以请求扩展中的用户上下文为准。
## 提交前检查(必须全部通过)
每次提交代码前,请在本地依次执行以下检查,**全部通过后再 push**
### 1. 版本号(按需)
若本次改动需要发版,请先确认 `Cargo.toml` 中的 `version` 已提升,避免 CI 打出的 Tag 与已有版本重复。可通过 git tag 判断:
## 提交前检查
```bash
# 查看当前 Cargo.toml 版本
grep '^version' Cargo.toml
# 查看是否已存在该版本对应的 tagCI 使用格式 secrets-<version>
git tag -l 'secrets-*'
./scripts/release-check.sh
```
若当前版本已被 tag例如已有 `secrets-0.1.0``Cargo.toml` 仍为 `0.1.0`),则应在 `Cargo.toml` 中 bump 版本号后再提交,以便 CI 自动打新 Tag 并发布 Release。
### 2. 格式、Lint、测试
或手动:
```bash
cargo fmt -- --check # 格式检查(不通过则运行 cargo fmt 修复)
cargo clippy -- -D warnings # Lint 检查(消除所有 warning
cargo test # 单元/集成测试
cargo fmt -- --check
cargo clippy --locked -- -D warnings
cargo test --locked
```
或一次性执行
发版前确认未重复 tag
```bash
cargo fmt -- --check && cargo clippy -- -D warnings && cargo test
grep '^version' crates/secrets-mcp/Cargo.toml
git tag -l 'secrets-mcp-*'
```
## CI/CD
- Gitea Actionsrunner: debian
- 触发:`src/**``Cargo.toml``Cargo.lock` 变更推送到 main
- 构建目标:`x86_64-unknown-linux-musl`(静态链接,无 glibc 依赖)
- 新版本自动打 Tag格式 `secrets-<version>`)并上传二进制到 Gitea Release
- 通知:飞书 Webhook`vars.WEBHOOK_URL`
- 所需 secrets/vars`RELEASE_TOKEN`Release 上传Gitea PAT`vars.WEBHOOK_URL`(通知,可选)
- **触发**:任意分支 `push`,且路径含 `crates/**``deploy/**`、根目录 `Cargo.toml``Cargo.lock`(见 `.gitea/workflows/secrets.yml`)。
- **版本与 tag**:从 `crates/secrets-mcp/Cargo.toml` 读版本;若远程已存在同名 `secrets-mcp-<version>` tag则复用现有 tag 继续构建;否则由 CI 创建并推送该 tag。
- **质量与构建**`fmt` / `clippy --locked` / `test --locked``x86_64-unknown-linux-musl` 发布构建 `secrets-mcp`
- **Release可选**`secrets.RELEASE_TOKEN`Gitea PAT用于创建草稿 Release、上传 `tar.gz` + `.sha256`、构建成功后发布;未配置则跳过 API Release仅 tag + 构建。
- **部署(可选)**:仅 `main``feat/mcp``mcp` 分支在构建成功时跑 `deploy-mcp`;需 `vars.DEPLOY_HOST``vars.DEPLOY_USER``secrets.DEPLOY_SSH_KEY`。勿把 OAuth/DB 等写进 workflow`deploy/.env.example` 在目标机配置。
- **Secrets 写法**Actions **secrets 须为原始值**PEM、PAT 明文),**勿** base64否则 SSH/Release 会失败。**勿**在 CI 中保存 `GOOGLE_CLIENT_SECRET`、DB 密码。
- **通知**`vars.WEBHOOK_URL`(可选,飞书)。
## 环境变量
## 环境变量secrets-mcp
| 变量 | 说明 |
|------|------|
| `RUST_LOG` | 日志级别,如 `secrets=debug``secrets=trace`(默认 warn |
| `USER` | 审计日志 actor 字段来源Shell 自动设置,通常无需手动配置 |
| `SECRETS_DATABASE_URL` | **必填**。PostgreSQL URL。 |
| `BASE_URL` | 对外基址OAuth 回调 `${BASE_URL}/auth/google/callback` |
| `SECRETS_MCP_BIND` | 监听地址,默认 `0.0.0.0:9315`。 |
| `GOOGLE_CLIENT_ID` / `GOOGLE_CLIENT_SECRET` | 可选;仅运行时配置。 |
| `RUST_LOG` | 如 `secrets_mcp=debug`。 |
| `USER` | 若写入审计 `actor`,由运行环境提供。 |
数据库连接通过 `secrets config set-db` 持久化到 `~/.config/secrets/config.toml`,不支持环境变量。
> `SERVER_MASTER_KEY` 已不再需要。新架构下密钥由用户密码短语在客户端派生,服务端不持有。

1410
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,18 +1,38 @@
[package]
name = "secrets"
version = "0.3.0"
[workspace]
members = [
"crates/secrets-core",
"crates/secrets-mcp",
]
resolver = "2"
[workspace.package]
edition = "2024"
[dependencies]
anyhow = "1.0.102"
chrono = { version = "0.4.44", features = ["serde"] }
clap = { version = "4.6.0", features = ["derive", "env"] }
dirs = "6.0.0"
serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.149"
sqlx = { version = "0.8.6", features = ["runtime-tokio", "tls-rustls", "postgres", "uuid", "json", "chrono"] }
tokio = { version = "1.50.0", features = ["full"] }
toml = "1.0.7"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
uuid = { version = "1.22.0", features = ["serde", "v4"] }
[workspace.dependencies]
# Async runtime
tokio = { version = "^1.50.0", features = ["rt-multi-thread", "macros", "fs", "io-util", "process", "signal"] }
# Database
sqlx = { version = "^0.8.6", features = ["runtime-tokio", "tls-rustls", "postgres", "uuid", "json", "chrono"] }
# Serialization
serde = { version = "^1.0.228", features = ["derive"] }
serde_json = "^1.0.149"
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"] }

208
README.md
View File

@@ -1,96 +1,149 @@
# secrets
# secrets-mcp
跨设备密钥与配置管理 CLI基于 Rust + PostgreSQL 18
将服务器信息、服务凭据统一存入数据库,供本地工具和 AI 读取上下文。
Workspace**`secrets-core`** + **`secrets-mcp`**HTTP Streamable MCP + Web。多租户密钥与元数据存 PostgreSQL用户通过 **Google OAuth** 登录,**API Key** 鉴权 MCP 请求;秘密数据用**用户密码短语派生的密钥**在客户端加密,服务端不持有原始密钥
## 安装
```bash
cargo build --release
# 或从 Release 页面下载预编译二进制
cargo build --release -p secrets-mcp
# 产物: target/release/secrets-mcp
```
配置数据库连接(首次使用需执行一次,之后在该设备上持久生效):
发版产物见 Gitea Releasetag`secrets-mcp-<version>`Linux musl 预编译);其它平台本地 `cargo build`
## 环境变量与本地运行
复制 `deploy/.env.example` 为项目根目录 `.env`(已在 `.gitignore`),或导出同名变量:
| 变量 | 说明 |
|------|------|
| `SECRETS_DATABASE_URL` | **必填**。PostgreSQL 连接串(建议专用库,如 `secrets-mcp`)。 |
| `BASE_URL` | 对外访问基址OAuth 回调为 `{BASE_URL}/auth/google/callback`。默认 `http://localhost:9315`。 |
| `SECRETS_MCP_BIND` | 监听地址,默认 `0.0.0.0:9315`。反代时常为 `127.0.0.1:9315`。 |
| `GOOGLE_CLIENT_ID` / `GOOGLE_CLIENT_SECRET` | 可选;不配置则无 Google 登录入口。运行时从环境读取,勿写入 CI、勿打入二进制。 |
```bash
secrets config set-db "postgres://postgres:<password>@<host>:5432/secrets"
cargo run -p secrets-mcp
```
## 使用
- **Web**`BASE_URL`登录、Dashboard、设置密码短语、创建 API Key
- **MCP**Streamable HTTP 基址 `{BASE_URL}/mcp`,需 `Authorization: Bearer <api_key>` + `X-Encryption-Key: <hex>` 请求头。
```bash
# 查看版本
secrets -V
secrets --version
## 加密架构(混合 E2EE
# 查看帮助
secrets --help
secrets -h
### 密钥派生
# 查看子命令帮助
secrets help config
secrets help add
secrets help search
secrets help delete
secrets help update
用户在 Web Dashboard 设置**密码短语**,浏览器使用 **Web Crypto APIPBKDF2-SHA256600k 次迭代)**在本地派生 256-bit AES 密钥。
# 添加服务器
secrets add -n refining --kind server --name my-server \
--tag aliyun --tag shanghai \
-m ip=1.2.3.4 -m desc="My Server" \
-s username=root \
-s ssh_key=@./keys/my.pem
- **Salt32B**:首次设置时在浏览器生成,存入服务端 `users.key_salt`
- **key_check**:派生密钥加密已知常量 `"secrets-mcp-key-check"`,存入 `users.key_check`,用于登录时验证密码短语
- **服务端不存储原始密钥**,只存 salt + key_check
# 添加服务凭据
secrets add -n refining --kind service --name gitea \
-m url=https://gitea.example.com \
-s token=<token>
跨设备同步:新设备登录 → 输入相同密码短语 → 从服务端取 salt → 同样的 PBKDF2 → 得到相同密钥。
# 搜索(默认隐藏敏感字段)
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
### 写入与读取流程
# 开启 debug 级别日志(--verbose / -v全局参数
secrets --verbose search -q mqtt
secrets -v add -n refining --kind service --name gitea -m url=xxx -s token=yyy
```mermaid
flowchart LR
subgraph Web["Web 浏览器E2E"]
P["密码短语"] --> K["PBKDF2 → 256-bit key"]
K --> Enc["AES-256-GCM 加密"]
K --> Dec["AES-256-GCM 解密"]
end
# 或通过环境变量精细控制日志级别
RUST_LOG=secrets=trace secrets search
subgraph AI["AI 客户端MCP"]
HdrKey["X-Encryption-Key: hex"]
end
# 增量更新已有记录(合并语义,记录不存在则报错)
secrets update -n refining --kind server --name my-server -m ip=10.0.0.1
secrets update -n refining --kind service --name gitea --add-tag production -s token=<new-token>
secrets update -n refining --kind service --name mqtt --remove-meta old_port --remove-secret old_key
subgraph Server["secrets-mcp 服务端"]
Middleware["请求中临时持有 key\n请求结束即丢弃"]
DB[(PostgreSQL\nsecrets.encrypted = 密文\nentries.metadata = 明文)]
end
# 删除
secrets delete -n refining --kind server --name my-server
Enc -->|密文| Server
HdrKey -->|key + 请求| Middleware
Middleware <-->|加解密| DB
DB -->|密文| Dec
```
### 两种客户端对比
| | Web 浏览器 | AI 客户端MCP |
|---|---|---|
| 密钥位置 | 仅在浏览器内存 / sessionStorage | MCP 配置 headers 中 |
| 加解密位置 | 客户端(真正 E2E | 服务端临时(请求级生命周期) |
| 安全边界 | 服务端零知识 | 依赖 TLS + 服务端内存隔离 |
### 敏感数据传输
- **OAuth `client_secret`** 只存服务端环境变量,不发给浏览器
- **API Key** 创建时原始 key 仅展示一次,库中只存 SHA-256 哈希
- **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` 表,首次连接自动建表;同时自动创建 `audit_log` 表,记录所有写操作
主表 **`entries`**`namespace``kind``name``tags``metadata`,多租户时带 `user_id`+ 子表 **`secrets`**(每行一个加密字段:`field_name``encrypted`)。另有 `entries_history``secrets_history``audit_log`,以及 **`users`**(含 `key_salt``key_check``key_params`)、**`oauth_accounts`**、**`api_keys`**。首次连库自动迁移建表
| 字段 | 说明 |
|------|------|
| `namespace` | 一级隔离,如 `refining``ricnsmart` |
| `kind` | 记录类型,如 `server``service`(可自由扩展) |
| `name` | 人类可读唯一标识 |
| `tags` | 多维标签,如 `["aliyun","hongkong"]` |
| `metadata` | 明文描述信息ip、desc、domains 等) |
| `encrypted` | 敏感凭据ssh_key、password、token 等MVP 阶段明文存储,预留加密字段 |
| 位置 | 字段 | 说明 |
|------|------|------|
| entries | namespace | 一级隔离,如 `refining``ricnsmart` |
| entries | kind | `server``service``key`(可扩展) |
| entries | name | 人类可读标识 |
| entries | metadata | 明文 JSONip、url、`key_ref` 等) |
| 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 存为 `kind=key` 的 entry在服务器条目的 `metadata.key_ref` 中写 key 的名称;轮换时只更新 key 对应记录即可。
## 审计日志
`add``update``delete` 操作成功后自动向 `audit_log` 表写入一条记录,包含操作类型、操作对象和变更摘要不含 secret 值)。操作者取自 `$USER` 环境变量
`add``update``delete` 等写操作写入 **`audit_log`**(操作类型、对象、摘要不含 secret 明文)
```sql
-- 查看最近 20 条审计记录
SELECT action, namespace, kind, name, actor, detail, created_at
FROM audit_log
ORDER BY created_at DESC
@@ -100,34 +153,25 @@ LIMIT 20;
## 项目结构
```
src/
main.rs # CLI 入口clap
config.rs # 配置读写(~/.config/secrets/config.toml
db.rs # 连接池 + auto-migratesecrets + audit_log
models.rs # Secret 结构体
audit.rs # 审计日志写入audit_log 表)
commands/
add.rs # upsert
config.rs # config set-db/show/path
search.rs # 多条件查询
delete.rs # 删除
update.rs # 增量更新(合并 tags/metadata/encrypted
Cargo.toml
crates/secrets-core/ # db / crypto / models / audit / service
crates/secrets-mcp/ # MCP HTTP、Web、OAuth、API Key
scripts/
seed-data.sh # 导入 refining / ricnsmart 全量数据
deploy/ # systemd、.env 示例
```
## 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`
- **流水线**:解析 `crates/secrets-mcp/Cargo.toml` 版本 → 若 `secrets-mcp-<version>` 的 tag 已存在则**复用现有 tag 继续构建**,否则自动打 tag → `cargo fmt` / `clippy --locked` / `test --locked` → 交叉编译 `x86_64-unknown-linux-musl``secrets-mcp`
- **Release可选**:配置仓库 Secret `RELEASE_TOKEN`Gitea PAT明文勿 base64会通过 API 创建**草稿** Release、在 Linux 构建成功后上传 `tar.gz``.sha256`,再自动将草稿**正式发布**;未配置则跳过创建 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
# 需有 ~/.config/gitea/config.envGITEA_URL、GITEA_TOKEN、GITEA_WEBHOOK_URL
./scripts/setup-gitea-actions.sh
./scripts/setup-gitea-actions.sh # 通过 Gitea API 写入 RELEASE_TOKEN、WEBHOOK_URL、部署相关变量等
```
- `RELEASE_TOKEN`SecretGitea PAT用于创建 Release 上传二进制
- `WEBHOOK_URL`Variable飞书通知可选
详见 [AGENTS.md](AGENTS.md)。
详见 [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,105 @@
use serde_json::{Value, json};
use sqlx::{PgPool, Postgres, Transaction};
use uuid::Uuid;
pub const ACTION_LOGIN: &str = "login";
pub const NAMESPACE_AUTH: &str = "auth";
/// Return the current OS user as the audit actor (falls back to empty string).
pub fn current_actor() -> String {
std::env::var("USER").unwrap_or_default()
}
fn login_detail(
user_id: Uuid,
provider: &str,
client_ip: Option<&str>,
user_agent: Option<&str>,
) -> Value {
json!({
"user_id": user_id,
"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,
kind: &str,
provider: &str,
user_id: Uuid,
client_ip: Option<&str>,
user_agent: Option<&str>,
) {
let actor = current_actor();
let detail = login_detail(user_id, provider, client_ip, user_agent);
let result: Result<_, sqlx::Error> = sqlx::query(
"INSERT INTO audit_log (user_id, action, namespace, kind, name, detail, actor) \
VALUES ($1, $2, $3, $4, $5, $6, $7)",
)
.bind(user_id)
.bind(ACTION_LOGIN)
.bind(NAMESPACE_AUTH)
.bind(kind)
.bind(provider)
.bind(&detail)
.bind(&actor)
.execute(pool)
.await;
if let Err(e) = result {
tracing::warn!(error = %e, kind, provider, "failed to write login audit log");
} else {
tracing::debug!(kind, provider, ?user_id, actor, "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,
namespace: &str,
kind: &str,
name: &str,
detail: Value,
) {
let actor = current_actor();
let result: Result<_, sqlx::Error> = sqlx::query(
"INSERT INTO audit_log (user_id, action, namespace, kind, name, detail, actor) \
VALUES ($1, $2, $3, $4, $5, $6, $7)",
)
.bind(user_id)
.bind(action)
.bind(namespace)
.bind(kind)
.bind(name)
.bind(&detail)
.bind(&actor)
.execute(&mut **tx)
.await;
if let Err(e) = result {
tracing::warn!(error = %e, "failed to write audit log");
} else {
tracing::debug!(action, namespace, kind, name, actor, "audit logged");
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn login_detail_includes_expected_fields() {
let user_id = Uuid::nil();
let detail = login_detail(user_id, "google", Some("127.0.0.1"), Some("Mozilla/5.0"));
assert_eq!(detail["user_id"], json!(user_id));
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,20 @@
use anyhow::Result;
/// 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"
)
}

View File

@@ -0,0 +1,207 @@
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")
}
// ─── Per-user key management (DEPRECATED — kept only for migration) ───────────
/// Generate a new random 32-byte per-user encryption key.
#[allow(dead_code)]
pub fn generate_user_key() -> [u8; 32] {
use aes_gcm::aead::rand_core::RngCore;
let mut key = [0u8; 32];
OsRng.fill_bytes(&mut key);
key
}
/// Wrap a per-user key with the server master key using AES-256-GCM.
#[allow(dead_code)]
pub fn wrap_user_key(server_master_key: &[u8; 32], user_key: &[u8; 32]) -> Result<Vec<u8>> {
encrypt(server_master_key, user_key.as_ref())
}
/// Unwrap a per-user key using the server master key.
#[allow(dead_code)]
pub fn unwrap_user_key(server_master_key: &[u8; 32], wrapped: &[u8]) -> Result<[u8; 32]> {
let bytes = decrypt(server_master_key, wrapped)?;
if bytes.len() != 32 {
bail!("unwrapped user key has unexpected length {}", bytes.len());
}
let mut key = [0u8; 32];
key.copy_from_slice(&bytes);
Ok(key)
}
// ─── 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)
}
// ─── Server master key ────────────────────────────────────────────────────────
/// Load the server master key from `SERVER_MASTER_KEY` environment variable (64 hex chars).
pub fn load_master_key_auto() -> Result<[u8; 32]> {
let hex_str = std::env::var("SERVER_MASTER_KEY").map_err(|_| {
anyhow::anyhow!(
"SERVER_MASTER_KEY is not set. \
Generate one with: openssl rand -hex 32"
)
})?;
if hex_str.is_empty() {
bail!("SERVER_MASTER_KEY is set but empty");
}
let bytes = hex::decode_hex(hex_str.trim())?;
if bytes.len() != 32 {
bail!(
"SERVER_MASTER_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);
}
#[test]
fn user_key_wrap_unwrap_roundtrip() {
let server_key = [0xABu8; 32];
let user_key = [0xCDu8; 32];
let wrapped = wrap_user_key(&server_key, &user_key).unwrap();
let unwrapped = unwrap_user_key(&server_key, &wrapped).unwrap();
assert_eq!(unwrapped, user_key);
}
#[test]
fn user_key_wrap_wrong_server_key_fails() {
let server_key1 = [0xABu8; 32];
let server_key2 = [0xEFu8; 32];
let user_key = [0xCDu8; 32];
let wrapped = wrap_user_key(&server_key1, &user_key).unwrap();
assert!(unwrap_user_key(&server_key2, &wrapped).is_err());
}
}

View File

@@ -0,0 +1,238 @@
use anyhow::Result;
use serde_json::Value;
use sqlx::PgPool;
use sqlx::postgres::PgPoolOptions;
use crate::audit::current_actor;
pub async fn create_pool(database_url: &str) -> Result<PgPool> {
tracing::debug!("connecting to database");
let pool = PgPoolOptions::new()
.max_connections(10)
.acquire_timeout(std::time::Duration::from_secs(5))
.connect(database_url)
.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,
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 '{}',
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(namespace, kind, name)
WHERE user_id IS NULL;
-- Multi-user unique constraint
CREATE UNIQUE INDEX IF NOT EXISTS idx_entries_unique_user
ON entries(user_id, namespace, kind, name)
WHERE user_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_entries_namespace ON entries(namespace);
CREATE INDEX IF NOT EXISTS idx_entries_kind ON entries(kind);
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,
namespace VARCHAR(64) NOT NULL,
kind VARCHAR(64) NOT NULL,
name VARCHAR(256) NOT NULL,
detail JSONB NOT NULL DEFAULT '{}',
actor VARCHAR(128) NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
ALTER TABLE audit_log ADD COLUMN IF NOT EXISTS user_id UUID;
CREATE INDEX IF NOT EXISTS idx_audit_log_created ON audit_log(created_at DESC);
CREATE INDEX IF NOT EXISTS idx_audit_log_ns_kind ON audit_log(namespace, kind);
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,
namespace VARCHAR(64) NOT NULL,
kind VARCHAR(64) NOT NULL,
name VARCHAR(256) NOT NULL,
version BIGINT NOT NULL,
action VARCHAR(16) NOT NULL,
tags TEXT[] NOT NULL DEFAULT '{}',
metadata JSONB NOT NULL DEFAULT '{}',
actor VARCHAR(128) 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_ns_kind_name
ON entries_history(namespace, kind, 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;
-- ── 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,
actor VARCHAR(128) NOT NULL DEFAULT '',
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);
-- ── 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);
"#,
)
.execute(pool)
.await?;
tracing::debug!("migrations complete");
Ok(())
}
// ── Entry-level history snapshot ─────────────────────────────────────────────
pub struct EntrySnapshotParams<'a> {
pub entry_id: uuid::Uuid,
pub user_id: Option<uuid::Uuid>,
pub namespace: &'a str,
pub kind: &'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<()> {
let actor = current_actor();
sqlx::query(
"INSERT INTO entries_history \
(entry_id, namespace, kind, name, version, action, tags, metadata, actor, user_id) \
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)",
)
.bind(p.entry_id)
.bind(p.namespace)
.bind(p.kind)
.bind(p.name)
.bind(p.version)
.bind(p.action)
.bind(p.tags)
.bind(p.metadata)
.bind(&actor)
.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<()> {
let actor = current_actor();
sqlx::query(
"INSERT INTO secrets_history \
(entry_id, secret_id, entry_version, field_name, encrypted, action, actor) \
VALUES ($1, $2, $3, $4, $5, $6, $7)",
)
.bind(p.entry_id)
.bind(p.secret_id)
.bind(p.entry_version)
.bind(p.field_name)
.bind(p.encrypted)
.bind(p.action)
.bind(&actor)
.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,263 @@
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, …).
/// Sensitive fields are stored separately in `secrets`.
#[derive(Debug, Serialize, Deserialize, sqlx::FromRow)]
pub struct Entry {
pub id: Uuid,
pub namespace: String,
pub kind: String,
pub name: 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 tags: Vec<String>,
pub metadata: Value,
}
/// 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 namespace: String,
pub kind: String,
pub name: 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 namespace: String,
pub kind: String,
pub name: String,
pub detail: Value,
pub actor: String,
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,405 @@
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 namespace: String,
pub kind: String,
pub name: String,
pub tags: Vec<String>,
pub meta_keys: Vec<String>,
pub secret_keys: Vec<String>,
}
pub struct AddParams<'a> {
pub namespace: &'a str,
pub kind: &'a str,
pub name: &'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 (user-scoped or global depending on user_id)
let existing: Option<EntryRow> = if let Some(uid) = params.user_id {
sqlx::query_as(
"SELECT id, version, tags, metadata FROM entries \
WHERE user_id = $1 AND namespace = $2 AND kind = $3 AND name = $4",
)
.bind(uid)
.bind(params.namespace)
.bind(params.kind)
.bind(params.name)
.fetch_optional(&mut *tx)
.await?
} else {
sqlx::query_as(
"SELECT id, version, tags, metadata FROM entries \
WHERE user_id IS NULL AND namespace = $1 AND kind = $2 AND name = $3",
)
.bind(params.namespace)
.bind(params.kind)
.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,
namespace: params.namespace,
kind: params.kind,
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, namespace, kind, name, tags, metadata, version, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, 1, NOW())
ON CONFLICT (user_id, namespace, kind, name) WHERE user_id IS NOT NULL
DO UPDATE SET
tags = EXCLUDED.tags,
metadata = EXCLUDED.metadata,
version = entries.version + 1,
updated_at = NOW()
RETURNING id"#,
)
.bind(uid)
.bind(params.namespace)
.bind(params.kind)
.bind(params.name)
.bind(params.tags)
.bind(&metadata)
.fetch_one(&mut *tx)
.await?
} else {
sqlx::query_scalar(
r#"INSERT INTO entries (namespace, kind, name, tags, metadata, version, updated_at)
VALUES ($1, $2, $3, $4, $5, 1, NOW())
ON CONFLICT (namespace, kind, name) WHERE user_id IS NULL
DO UPDATE SET
tags = EXCLUDED.tags,
metadata = EXCLUDED.metadata,
version = entries.version + 1,
updated_at = NOW()
RETURNING id"#,
)
.bind(params.namespace)
.bind(params.kind)
.bind(params.name)
.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,
namespace: params.namespace,
kind: params.kind,
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.namespace,
params.kind,
params.name,
serde_json::json!({
"tags": params.tags,
"meta_keys": meta_keys,
"secret_keys": secret_keys,
}),
)
.await;
tx.commit().await?;
Ok(AddResult {
namespace: params.namespace.to_string(),
kind: params.kind.to_string(),
name: params.name.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, namespace, kind, name, detail, actor, created_at \
FROM audit_log \
WHERE user_id = $1 OR (user_id IS NULL AND detail->>'user_id' = $1::text) \
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,322 @@
use anyhow::Result;
use serde_json::json;
use sqlx::PgPool;
use uuid::Uuid;
use crate::db;
use crate::models::{EntryRow, SecretFieldRow};
#[derive(Debug, serde::Serialize)]
pub struct DeletedEntry {
pub namespace: String,
pub kind: String,
pub name: String,
}
#[derive(Debug, serde::Serialize)]
pub struct DeleteResult {
pub deleted: Vec<DeletedEntry>,
pub dry_run: bool,
}
pub struct DeleteParams<'a> {
pub namespace: &'a str,
pub kind: Option<&'a str>,
pub name: Option<&'a str>,
pub dry_run: bool,
pub user_id: Option<Uuid>,
}
pub async fn run(pool: &PgPool, params: DeleteParams<'_>) -> Result<DeleteResult> {
match params.name {
Some(name) => {
let kind = params
.kind
.ok_or_else(|| anyhow::anyhow!("--kind is required when --name is specified"))?;
delete_one(
pool,
params.namespace,
kind,
name,
params.dry_run,
params.user_id,
)
.await
}
None => {
delete_bulk(
pool,
params.namespace,
params.kind,
params.dry_run,
params.user_id,
)
.await
}
}
}
async fn delete_one(
pool: &PgPool,
namespace: &str,
kind: &str,
name: &str,
dry_run: bool,
user_id: Option<Uuid>,
) -> Result<DeleteResult> {
if dry_run {
let exists: bool = if let Some(uid) = user_id {
sqlx::query_scalar(
"SELECT EXISTS(SELECT 1 FROM entries \
WHERE user_id = $1 AND namespace = $2 AND kind = $3 AND name = $4)",
)
.bind(uid)
.bind(namespace)
.bind(kind)
.bind(name)
.fetch_one(pool)
.await?
} else {
sqlx::query_scalar(
"SELECT EXISTS(SELECT 1 FROM entries \
WHERE user_id IS NULL AND namespace = $1 AND kind = $2 AND name = $3)",
)
.bind(namespace)
.bind(kind)
.bind(name)
.fetch_one(pool)
.await?
};
let deleted = if exists {
vec![DeletedEntry {
namespace: namespace.to_string(),
kind: kind.to_string(),
name: name.to_string(),
}]
} else {
vec![]
};
return Ok(DeleteResult {
deleted,
dry_run: true,
});
}
let mut tx = pool.begin().await?;
let row: Option<EntryRow> = if let Some(uid) = user_id {
sqlx::query_as(
"SELECT id, version, tags, metadata FROM entries \
WHERE user_id = $1 AND namespace = $2 AND kind = $3 AND name = $4 FOR UPDATE",
)
.bind(uid)
.bind(namespace)
.bind(kind)
.bind(name)
.fetch_optional(&mut *tx)
.await?
} else {
sqlx::query_as(
"SELECT id, version, tags, metadata FROM entries \
WHERE user_id IS NULL AND namespace = $1 AND kind = $2 AND name = $3 FOR UPDATE",
)
.bind(namespace)
.bind(kind)
.bind(name)
.fetch_optional(&mut *tx)
.await?
};
let Some(row) = row else {
tx.rollback().await?;
return Ok(DeleteResult {
deleted: vec![],
dry_run: false,
});
};
snapshot_and_delete(&mut tx, namespace, kind, name, &row, user_id).await?;
crate::audit::log_tx(&mut tx, user_id, "delete", namespace, kind, name, json!({})).await;
tx.commit().await?;
Ok(DeleteResult {
deleted: vec![DeletedEntry {
namespace: namespace.to_string(),
kind: kind.to_string(),
name: name.to_string(),
}],
dry_run: false,
})
}
async fn delete_bulk(
pool: &PgPool,
namespace: &str,
kind: Option<&str>,
dry_run: bool,
user_id: Option<Uuid>,
) -> Result<DeleteResult> {
#[derive(Debug, sqlx::FromRow)]
struct FullEntryRow {
id: Uuid,
version: i64,
kind: String,
name: String,
metadata: serde_json::Value,
tags: Vec<String>,
}
let rows: Vec<FullEntryRow> = match (user_id, kind) {
(Some(uid), Some(k)) => {
sqlx::query_as(
"SELECT id, version, kind, name, metadata, tags FROM entries \
WHERE user_id = $1 AND namespace = $2 AND kind = $3 ORDER BY name",
)
.bind(uid)
.bind(namespace)
.bind(k)
.fetch_all(pool)
.await?
}
(Some(uid), None) => {
sqlx::query_as(
"SELECT id, version, kind, name, metadata, tags FROM entries \
WHERE user_id = $1 AND namespace = $2 ORDER BY kind, name",
)
.bind(uid)
.bind(namespace)
.fetch_all(pool)
.await?
}
(None, Some(k)) => {
sqlx::query_as(
"SELECT id, version, kind, name, metadata, tags FROM entries \
WHERE user_id IS NULL AND namespace = $1 AND kind = $2 ORDER BY name",
)
.bind(namespace)
.bind(k)
.fetch_all(pool)
.await?
}
(None, None) => {
sqlx::query_as(
"SELECT id, version, kind, name, metadata, tags FROM entries \
WHERE user_id IS NULL AND namespace = $1 ORDER BY kind, name",
)
.bind(namespace)
.fetch_all(pool)
.await?
}
};
if dry_run {
let deleted = rows
.iter()
.map(|r| DeletedEntry {
namespace: namespace.to_string(),
kind: r.kind.clone(),
name: r.name.clone(),
})
.collect();
return Ok(DeleteResult {
deleted,
dry_run: true,
});
}
let mut deleted = Vec::with_capacity(rows.len());
for row in &rows {
let entry_row = EntryRow {
id: row.id,
version: row.version,
tags: row.tags.clone(),
metadata: row.metadata.clone(),
};
let mut tx = pool.begin().await?;
snapshot_and_delete(
&mut tx, namespace, &row.kind, &row.name, &entry_row, user_id,
)
.await?;
crate::audit::log_tx(
&mut tx,
user_id,
"delete",
namespace,
&row.kind,
&row.name,
json!({"bulk": true}),
)
.await;
tx.commit().await?;
deleted.push(DeletedEntry {
namespace: namespace.to_string(),
kind: row.kind.clone(),
name: row.name.clone(),
});
}
Ok(DeleteResult {
deleted,
dry_run: false,
})
}
async fn snapshot_and_delete(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
namespace: &str,
kind: &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,
namespace,
kind,
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(())
}

View File

@@ -0,0 +1,122 @@
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,
namespace: Option<&str>,
kind: 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, namespace, kind, 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).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],
) -> 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
if let Some(key_ref) = entry.metadata.get("key_ref").and_then(|v| v.as_str()) {
let key_entries = fetch_entries(
pool,
Some(&entry.namespace),
Some("key"),
Some(key_ref),
&[],
None,
None,
)
.await?;
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, "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,139 @@
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 namespace: Option<&'a str>,
pub kind: 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.namespace,
params.kind,
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 {
namespace: entry.namespace.clone(),
kind: entry.kind.clone(),
name: entry.name.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,79 @@
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_entries, fetch_secrets_for_entries};
/// Decrypt a single named field from an entry.
pub async fn get_secret_field(
pool: &PgPool,
namespace: &str,
kind: &str,
name: &str,
field_name: &str,
master_key: &[u8; 32],
user_id: Option<Uuid>,
) -> Result<Value> {
let entries = fetch_entries(
pool,
Some(namespace),
Some(kind),
Some(name),
&[],
None,
user_id,
)
.await?;
let entry = entries
.first()
.ok_or_else(|| anyhow::anyhow!("Not found: [{}/{}] {}", namespace, kind, name))?;
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.
pub async fn get_all_secrets(
pool: &PgPool,
namespace: &str,
kind: &str,
name: &str,
master_key: &[u8; 32],
user_id: Option<Uuid>,
) -> Result<HashMap<String, Value>> {
let entries = fetch_entries(
pool,
Some(namespace),
Some(kind),
Some(name),
&[],
None,
user_id,
)
.await?;
let entry = entries
.first()
.ok_or_else(|| anyhow::anyhow!("Not found: [{}/{}] {}", namespace, kind, name))?;
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,78 @@
use anyhow::Result;
use serde_json::Value;
use sqlx::PgPool;
use uuid::Uuid;
#[derive(Debug, serde::Serialize)]
pub struct HistoryEntry {
pub version: i64,
pub action: String,
pub actor: String,
pub created_at: String,
}
pub async fn run(
pool: &PgPool,
namespace: &str,
kind: &str,
name: &str,
limit: u32,
user_id: Option<Uuid>,
) -> Result<Vec<HistoryEntry>> {
#[derive(sqlx::FromRow)]
struct Row {
version: i64,
action: String,
actor: String,
created_at: chrono::DateTime<chrono::Utc>,
}
let rows: Vec<Row> = if let Some(uid) = user_id {
sqlx::query_as(
"SELECT version, action, actor, created_at FROM entries_history \
WHERE namespace = $1 AND kind = $2 AND name = $3 AND user_id = $4 \
ORDER BY id DESC LIMIT $5",
)
.bind(namespace)
.bind(kind)
.bind(name)
.bind(uid)
.bind(limit as i64)
.fetch_all(pool)
.await?
} else {
sqlx::query_as(
"SELECT version, action, actor, created_at FROM entries_history \
WHERE namespace = $1 AND kind = $2 AND name = $3 AND user_id IS NULL \
ORDER BY id DESC LIMIT $4",
)
.bind(namespace)
.bind(kind)
.bind(name)
.bind(limit as i64)
.fetch_all(pool)
.await?
};
Ok(rows
.into_iter()
.map(|r| HistoryEntry {
version: r.version,
action: r.action,
actor: r.actor,
created_at: r.created_at.format("%Y-%m-%dT%H:%M:%SZ").to_string(),
})
.collect())
}
pub async fn run_json(
pool: &PgPool,
namespace: &str,
kind: &str,
name: &str,
limit: u32,
user_id: Option<Uuid>,
) -> Result<Value> {
let entries = run(pool, namespace, kind, name, limit, user_id).await?;
Ok(serde_json::to_value(entries)?)
}

View File

@@ -0,0 +1,123 @@
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 namespace = $1 AND kind = $2 AND name = $3 AND user_id IS NOT DISTINCT FROM $4)",
)
.bind(&entry.namespace)
.bind(&entry.kind)
.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.namespace,
entry.kind,
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 {
namespace: &entry.namespace,
kind: &entry.kind,
name: &entry.name,
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!(
namespace = entry.namespace,
kind = entry.kind,
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,297 @@
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 namespace: String,
pub kind: String,
pub name: String,
pub restored_version: i64,
}
pub async fn run(
pool: &PgPool,
namespace: &str,
kind: &str,
name: &str,
to_version: Option<i64>,
master_key: &[u8; 32],
user_id: Option<Uuid>,
) -> Result<RollbackResult> {
#[derive(sqlx::FromRow)]
struct EntryHistoryRow {
entry_id: Uuid,
version: i64,
action: String,
tags: Vec<String>,
metadata: Value,
}
let snap: Option<EntryHistoryRow> = if let Some(ver) = to_version {
if let Some(uid) = user_id {
sqlx::query_as(
"SELECT entry_id, version, action, tags, metadata FROM entries_history \
WHERE namespace = $1 AND kind = $2 AND name = $3 AND version = $4 \
AND user_id = $5 ORDER BY id DESC LIMIT 1",
)
.bind(namespace)
.bind(kind)
.bind(name)
.bind(ver)
.bind(uid)
.fetch_optional(pool)
.await?
} else {
sqlx::query_as(
"SELECT entry_id, version, action, tags, metadata FROM entries_history \
WHERE namespace = $1 AND kind = $2 AND name = $3 AND version = $4 \
AND user_id IS NULL ORDER BY id DESC LIMIT 1",
)
.bind(namespace)
.bind(kind)
.bind(name)
.bind(ver)
.fetch_optional(pool)
.await?
}
} else if let Some(uid) = user_id {
sqlx::query_as(
"SELECT entry_id, version, action, tags, metadata FROM entries_history \
WHERE namespace = $1 AND kind = $2 AND name = $3 \
AND user_id = $4 ORDER BY id DESC LIMIT 1",
)
.bind(namespace)
.bind(kind)
.bind(name)
.bind(uid)
.fetch_optional(pool)
.await?
} else {
sqlx::query_as(
"SELECT entry_id, version, action, tags, metadata FROM entries_history \
WHERE namespace = $1 AND kind = $2 AND name = $3 \
AND user_id IS NULL ORDER BY id DESC LIMIT 1",
)
.bind(namespace)
.bind(kind)
.bind(name)
.fetch_optional(pool)
.await?
};
let snap = snap.ok_or_else(|| {
anyhow::anyhow!(
"No history found for [{}/{}] {}{}.",
namespace,
kind,
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,
tags: Vec<String>,
metadata: Value,
}
// Query live entry with correct user_id scoping to avoid PK conflicts
let live: Option<LiveEntry> = if let Some(uid) = user_id {
sqlx::query_as(
"SELECT id, version, tags, metadata FROM entries \
WHERE user_id = $1 AND namespace = $2 AND kind = $3 AND name = $4 FOR UPDATE",
)
.bind(uid)
.bind(namespace)
.bind(kind)
.bind(name)
.fetch_optional(&mut *tx)
.await?
} else {
sqlx::query_as(
"SELECT id, version, tags, metadata FROM entries \
WHERE user_id IS NULL AND namespace = $1 AND kind = $2 AND name = $3 FOR UPDATE",
)
.bind(namespace)
.bind(kind)
.bind(name)
.fetch_optional(&mut *tx)
.await?
};
let entry_id = if let Some(ref lr) = live {
// Snapshot current state before overwriting
if let Err(e) = db::snapshot_entry_history(
&mut tx,
db::EntrySnapshotParams {
entry_id: lr.id,
user_id,
namespace,
kind,
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");
}
}
// Update the existing row in-place to preserve its primary key and user_id
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 {
// No live entry — insert a fresh one with a new UUID
if let Some(uid) = user_id {
sqlx::query_scalar(
"INSERT INTO entries \
(user_id, namespace, kind, name, tags, metadata, version, updated_at) \
VALUES ($1, $2, $3, $4, $5, $6, $7, NOW()) RETURNING id",
)
.bind(uid)
.bind(namespace)
.bind(kind)
.bind(name)
.bind(&snap.tags)
.bind(&snap.metadata)
.bind(snap.version)
.fetch_one(&mut *tx)
.await?
} else {
sqlx::query_scalar(
"INSERT INTO entries \
(namespace, kind, name, tags, metadata, version, updated_at) \
VALUES ($1, $2, $3, $4, $5, $6, NOW()) RETURNING id",
)
.bind(namespace)
.bind(kind)
.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(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(entry_id)
.bind(&f.field_name)
.bind(&f.encrypted)
.execute(&mut *tx)
.await?;
}
crate::audit::log_tx(
&mut tx,
user_id,
"rollback",
namespace,
kind,
name,
serde_json::json!({
"restored_version": snap.version,
"original_action": snap.action,
}),
)
.await;
tx.commit().await?;
Ok(RollbackResult {
namespace: namespace.to_string(),
kind: kind.to_string(),
name: name.to_string(),
restored_version: snap.version,
})
}

View File

@@ -0,0 +1,241 @@
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 namespace: Option<&'a str>,
pub kind: 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>>,
}
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,
namespace: Option<&str>,
kind: Option<&str>,
name: Option<&str>,
tags: &[String],
query: Option<&str>,
user_id: Option<Uuid>,
) -> Result<Vec<Entry>> {
let params = SearchParams {
namespace,
kind,
name,
tags,
query,
sort: "name",
limit: FETCH_ALL_LIMIT,
offset: 0,
user_id,
};
fetch_entries_paged(pool, &params).await
}
async fn fetch_entries_paged(pool: &PgPool, a: &SearchParams<'_>) -> Result<Vec<Entry>> {
let mut conditions: Vec<String> = Vec::new();
let mut idx: i32 = 1;
// user_id filtering — always comes first when present
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.namespace.is_some() {
conditions.push(format!("namespace = ${}", idx));
idx += 1;
}
if a.kind.is_some() {
conditions.push(format!("kind = ${}", 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 namespace ILIKE ${i} ESCAPE '\\' \
OR kind 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 order = match a.sort {
"updated" => "updated_at DESC",
"created" => "created_at DESC",
_ => "name ASC",
};
let limit_idx = idx;
idx += 1;
let offset_idx = idx;
let where_clause = if conditions.is_empty() {
String::new()
} else {
format!("WHERE {}", conditions.join(" AND "))
};
let sql = format!(
"SELECT id, COALESCE(user_id, '00000000-0000-0000-0000-000000000000'::uuid) AS user_id, \
namespace, kind, name, 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.namespace {
q = q.bind(v);
}
if let Some(v) = a.kind {
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)
}
// ── Internal raw row (because user_id is nullable in DB) ─────────────────────
#[derive(sqlx::FromRow)]
struct EntryRaw {
id: Uuid,
#[allow(dead_code)] // Selected for row shape; Entry model has no user_id field
user_id: Uuid,
namespace: String,
kind: String,
name: 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,
namespace: r.namespace,
kind: r.kind,
name: r.name,
tags: r.tags,
metadata: r.metadata,
version: r.version,
created_at: r.created_at,
updated_at: r.updated_at,
}
}
}

View File

@@ -0,0 +1,273 @@
use anyhow::Result;
use serde_json::{Map, Value};
use sqlx::PgPool;
use uuid::Uuid;
use crate::crypto;
use crate::db;
use crate::models::EntryRow;
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 namespace: String,
pub kind: String,
pub name: 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 namespace: &'a str,
pub kind: &'a str,
pub name: &'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?;
let row: Option<EntryRow> = if let Some(uid) = params.user_id {
sqlx::query_as(
"SELECT id, version, tags, metadata FROM entries \
WHERE user_id = $1 AND namespace = $2 AND kind = $3 AND name = $4 FOR UPDATE",
)
.bind(uid)
.bind(params.namespace)
.bind(params.kind)
.bind(params.name)
.fetch_optional(&mut *tx)
.await?
} else {
sqlx::query_as(
"SELECT id, version, tags, metadata FROM entries \
WHERE user_id IS NULL AND namespace = $1 AND kind = $2 AND name = $3 FOR UPDATE",
)
.bind(params.namespace)
.bind(params.kind)
.bind(params.name)
.fetch_optional(&mut *tx)
.await?
};
let row = row.ok_or_else(|| {
anyhow::anyhow!(
"Not found: [{}/{}] {}. Use `add` to create it first.",
params.namespace,
params.kind,
params.name
)
})?;
if let Err(e) = db::snapshot_entry_history(
&mut tx,
db::EntrySnapshotParams {
entry_id: row.id,
user_id: params.user_id,
namespace: params.namespace,
kind: params.kind,
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 result = sqlx::query(
"UPDATE entries SET tags = $1, metadata = $2, version = version + 1, updated_at = NOW() \
WHERE id = $3 AND version = $4",
)
.bind(&tags)
.bind(&metadata)
.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.namespace,
params.kind,
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.namespace,
params.kind,
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 {
namespace: params.namespace.to_string(),
kind: params.kind.to_string(),
name: params.name.to_string(),
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,
})
}

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,44 @@
[package]
name = "secrets-mcp"
version = "0.1.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"] }
tower-sessions = "0.14"
# 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,155 @@
mod auth;
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::{MemoryStore, SessionManagerLayer};
use tracing_subscriber::EnvFilter;
use secrets_core::config::resolve_db_url;
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),
})
}
#[tokio::main]
async fn main() -> Result<()> {
// Load .env if present
let _ = dotenvy::dotenv();
tracing_subscriber::fmt()
.with_env_filter(
EnvFilter::try_from_default_env().unwrap_or_else(|_| "secrets_mcp=info".into()),
)
.init();
// ── Database ──────────────────────────────────────────────────────────────
let db_url = resolve_db_url("")
.context("Database not configured. Set SECRETS_DATABASE_URL environment variable.")?;
let pool = create_pool(&db_url)
.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(|| "0.0.0.0: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 ─────────────────────────────────────────────────────────
let session_store = MemoryStore::default();
// 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);
// ── 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_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")?;
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")
}

View File

@@ -0,0 +1,609 @@
use std::sync::Arc;
use anyhow::Result;
use rmcp::{
RoleServer, ServerHandler,
handler::server::wrapper::Parameters,
model::{
CallToolResult, Content, Implementation, InitializeResult, ProtocolVersion,
ServerCapabilities,
},
service::RequestContext,
tool, tool_handler, tool_router,
};
use schemars::JsonSchema;
use serde::Deserialize;
use sqlx::PgPool;
use uuid::Uuid;
use secrets_core::service::{
add::{AddParams, run as svc_add},
delete::{DeleteParams, run as svc_delete},
export::{ExportParams, export as svc_export},
get_secret::{get_all_secrets, get_secret_field},
history::run as svc_history,
rollback::run as svc_rollback,
search::{SearchParams, run as svc_search},
update::{UpdateParams, run as svc_update},
};
use crate::auth::AuthUser;
// ── Shared state ──────────────────────────────────────────────────────────────
#[derive(Clone)]
pub struct SecretsService {
pub pool: Arc<PgPool>,
pub tool_router: rmcp::handler::server::router::tool::ToolRouter<SecretsService>,
}
impl SecretsService {
pub fn new(pool: Arc<PgPool>) -> Self {
Self {
pool,
tool_router: Self::tool_router(),
}
}
/// Extract user_id from the HTTP request parts injected by auth middleware.
fn user_id_from_ctx(ctx: &RequestContext<RoleServer>) -> Result<Option<Uuid>, rmcp::ErrorData> {
let parts = ctx
.extensions
.get::<http::request::Parts>()
.ok_or_else(|| rmcp::ErrorData::internal_error("Missing HTTP parts", None))?;
Ok(parts.extensions.get::<AuthUser>().map(|a| a.user_id))
}
/// Get the authenticated user_id (returns error if not authenticated).
fn require_user_id(ctx: &RequestContext<RoleServer>) -> Result<Uuid, rmcp::ErrorData> {
let parts = ctx
.extensions
.get::<http::request::Parts>()
.ok_or_else(|| rmcp::ErrorData::internal_error("Missing HTTP parts", None))?;
parts
.extensions
.get::<AuthUser>()
.map(|a| a.user_id)
.ok_or_else(|| rmcp::ErrorData::invalid_request("Unauthorized: API key required", None))
}
/// Extract the 32-byte encryption key from the X-Encryption-Key request header.
/// The header value must be 64 lowercase hex characters (PBKDF2-derived key).
fn extract_enc_key(ctx: &RequestContext<RoleServer>) -> Result<[u8; 32], rmcp::ErrorData> {
let parts = ctx
.extensions
.get::<http::request::Parts>()
.ok_or_else(|| rmcp::ErrorData::internal_error("Missing HTTP parts", None))?;
let hex_str = parts
.headers
.get("x-encryption-key")
.ok_or_else(|| {
rmcp::ErrorData::invalid_request(
"Missing X-Encryption-Key header. \
Set this to your 64-char hex encryption key derived from your passphrase.",
None,
)
})?
.to_str()
.map_err(|_| {
rmcp::ErrorData::invalid_request("Invalid X-Encryption-Key header value", None)
})?;
secrets_core::crypto::extract_key_from_hex(hex_str)
.map_err(|e| rmcp::ErrorData::invalid_request(e.to_string(), None))
}
/// Require both user_id and encryption key.
fn require_user_and_key(
ctx: &RequestContext<RoleServer>,
) -> Result<(Uuid, [u8; 32]), rmcp::ErrorData> {
let user_id = Self::require_user_id(ctx)?;
let key = Self::extract_enc_key(ctx)?;
Ok((user_id, key))
}
}
// ── Tool parameter types ──────────────────────────────────────────────────────
#[derive(Debug, Deserialize, JsonSchema)]
struct SearchInput {
#[schemars(description = "Namespace filter (e.g. 'refining', 'ricnsmart')")]
namespace: Option<String>,
#[schemars(description = "Kind filter (e.g. 'server', 'service', 'key')")]
kind: Option<String>,
#[schemars(description = "Exact record name")]
name: Option<String>,
#[schemars(description = "Tag filters (all must match)")]
tags: Option<Vec<String>>,
#[schemars(description = "Fuzzy search across name, namespace, kind, tags, metadata")]
query: Option<String>,
#[schemars(description = "Return only summary fields (name/tags/desc/updated_at)")]
summary: Option<bool>,
#[schemars(description = "Sort order: 'name' (default), 'updated', 'created'")]
sort: Option<String>,
#[schemars(description = "Max results (default 20)")]
limit: Option<u32>,
#[schemars(description = "Pagination offset (default 0)")]
offset: Option<u32>,
}
#[derive(Debug, Deserialize, JsonSchema)]
struct GetSecretInput {
#[schemars(description = "Namespace of the entry")]
namespace: String,
#[schemars(description = "Kind of the entry (e.g. 'server', 'service')")]
kind: String,
#[schemars(description = "Name of the entry")]
name: String,
#[schemars(description = "Specific field to retrieve. If omitted, returns all fields.")]
field: Option<String>,
}
#[derive(Debug, Deserialize, JsonSchema)]
struct AddInput {
#[schemars(description = "Namespace")]
namespace: String,
#[schemars(description = "Kind (e.g. 'server', 'service', 'key')")]
kind: String,
#[schemars(description = "Unique name within namespace+kind")]
name: String,
#[schemars(description = "Tags for this entry")]
tags: Option<Vec<String>>,
#[schemars(description = "Metadata fields as 'key=value' or 'key:=json' strings")]
meta: Option<Vec<String>>,
#[schemars(description = "Secret fields as 'key=value' strings")]
secrets: Option<Vec<String>>,
}
#[derive(Debug, Deserialize, JsonSchema)]
struct UpdateInput {
#[schemars(description = "Namespace")]
namespace: String,
#[schemars(description = "Kind")]
kind: String,
#[schemars(description = "Name")]
name: String,
#[schemars(description = "Tags to add")]
add_tags: Option<Vec<String>>,
#[schemars(description = "Tags to remove")]
remove_tags: Option<Vec<String>>,
#[schemars(description = "Metadata fields to update/add as 'key=value' strings")]
meta: Option<Vec<String>>,
#[schemars(description = "Metadata field keys to remove")]
remove_meta: Option<Vec<String>>,
#[schemars(description = "Secret fields to update/add as 'key=value' strings")]
secrets: Option<Vec<String>>,
#[schemars(description = "Secret field keys to remove")]
remove_secrets: Option<Vec<String>>,
}
#[derive(Debug, Deserialize, JsonSchema)]
struct DeleteInput {
#[schemars(description = "Namespace")]
namespace: String,
#[schemars(description = "Kind filter (required for single delete)")]
kind: Option<String>,
#[schemars(description = "Exact name to delete. Omit for bulk delete by namespace+kind.")]
name: Option<String>,
#[schemars(description = "Preview deletions without writing")]
dry_run: Option<bool>,
}
#[derive(Debug, Deserialize, JsonSchema)]
struct HistoryInput {
#[schemars(description = "Namespace")]
namespace: String,
#[schemars(description = "Kind")]
kind: String,
#[schemars(description = "Name")]
name: String,
#[schemars(description = "Max history entries to return (default 20)")]
limit: Option<u32>,
}
#[derive(Debug, Deserialize, JsonSchema)]
struct RollbackInput {
#[schemars(description = "Namespace")]
namespace: String,
#[schemars(description = "Kind")]
kind: String,
#[schemars(description = "Name")]
name: String,
#[schemars(description = "Target version number. Omit to restore the most recent snapshot.")]
to_version: Option<i64>,
}
#[derive(Debug, Deserialize, JsonSchema)]
struct ExportInput {
#[schemars(description = "Namespace filter")]
namespace: Option<String>,
#[schemars(description = "Kind filter")]
kind: Option<String>,
#[schemars(description = "Exact name filter")]
name: Option<String>,
#[schemars(description = "Tag filters")]
tags: Option<Vec<String>>,
#[schemars(description = "Fuzzy query")]
query: Option<String>,
#[schemars(description = "Export format: 'json' (default), 'toml', 'yaml'")]
format: Option<String>,
}
#[derive(Debug, Deserialize, JsonSchema)]
struct EnvMapInput {
#[schemars(description = "Namespace filter")]
namespace: Option<String>,
#[schemars(description = "Kind filter")]
kind: Option<String>,
#[schemars(description = "Exact name filter")]
name: Option<String>,
#[schemars(description = "Tag filters")]
tags: Option<Vec<String>>,
#[schemars(description = "Only include these secret fields")]
only_fields: Option<Vec<String>>,
#[schemars(description = "Environment variable name prefix")]
prefix: Option<String>,
}
// ── Tool implementations ──────────────────────────────────────────────────────
#[tool_router]
impl SecretsService {
#[tool(
description = "Search entries in the secrets store. Returns entries with metadata and \
secret field names (not values). Use secrets_get to decrypt secret values."
)]
async fn secrets_search(
&self,
Parameters(input): Parameters<SearchInput>,
ctx: RequestContext<RoleServer>,
) -> Result<CallToolResult, rmcp::ErrorData> {
let user_id = Self::user_id_from_ctx(&ctx)?;
let tags = input.tags.unwrap_or_default();
let result = svc_search(
&self.pool,
SearchParams {
namespace: input.namespace.as_deref(),
kind: input.kind.as_deref(),
name: input.name.as_deref(),
tags: &tags,
query: input.query.as_deref(),
sort: input.sort.as_deref().unwrap_or("name"),
limit: input.limit.unwrap_or(20),
offset: input.offset.unwrap_or(0),
user_id,
},
)
.await
.map_err(|e| rmcp::ErrorData::internal_error(e.to_string(), None))?;
let summary = input.summary.unwrap_or(false);
let entries: Vec<serde_json::Value> = result
.entries
.iter()
.map(|e| {
if summary {
serde_json::json!({
"namespace": e.namespace,
"kind": e.kind,
"name": e.name,
"tags": e.tags,
"desc": e.metadata.get("desc").or_else(|| e.metadata.get("url"))
.and_then(|v| v.as_str()).unwrap_or(""),
"updated_at": e.updated_at.format("%Y-%m-%dT%H:%M:%SZ").to_string(),
})
} else {
let schema: Vec<&str> = result
.secret_schemas
.get(&e.id)
.map(|f| f.iter().map(|s| s.field_name.as_str()).collect())
.unwrap_or_default();
serde_json::json!({
"id": e.id,
"namespace": e.namespace,
"kind": e.kind,
"name": e.name,
"tags": e.tags,
"metadata": e.metadata,
"secret_fields": schema,
"version": e.version,
"updated_at": e.updated_at.format("%Y-%m-%dT%H:%M:%SZ").to_string(),
})
}
})
.collect();
let json = serde_json::to_string_pretty(&entries).unwrap_or_else(|_| "[]".to_string());
Ok(CallToolResult::success(vec![Content::text(json)]))
}
#[tool(
description = "Get decrypted secret field values for an entry. Requires your \
encryption key via X-Encryption-Key header (64 hex chars, PBKDF2-derived). \
Returns all fields, or a specific field if 'field' is provided."
)]
async fn secrets_get(
&self,
Parameters(input): Parameters<GetSecretInput>,
ctx: RequestContext<RoleServer>,
) -> Result<CallToolResult, rmcp::ErrorData> {
let (user_id, user_key) = Self::require_user_and_key(&ctx)?;
if let Some(field_name) = &input.field {
let value = get_secret_field(
&self.pool,
&input.namespace,
&input.kind,
&input.name,
field_name,
&user_key,
Some(user_id),
)
.await
.map_err(|e| rmcp::ErrorData::internal_error(e.to_string(), None))?;
let result = serde_json::json!({ field_name: value });
let json = serde_json::to_string_pretty(&result).unwrap_or_default();
Ok(CallToolResult::success(vec![Content::text(json)]))
} else {
let secrets = get_all_secrets(
&self.pool,
&input.namespace,
&input.kind,
&input.name,
&user_key,
Some(user_id),
)
.await
.map_err(|e| rmcp::ErrorData::internal_error(e.to_string(), None))?;
let json = serde_json::to_string_pretty(&secrets).unwrap_or_default();
Ok(CallToolResult::success(vec![Content::text(json)]))
}
}
#[tool(
description = "Add or upsert an entry with metadata and encrypted secret fields. \
Requires X-Encryption-Key header. \
Meta and secret values use 'key=value', 'key=@file', or 'key:=<json>' format."
)]
async fn secrets_add(
&self,
Parameters(input): Parameters<AddInput>,
ctx: RequestContext<RoleServer>,
) -> Result<CallToolResult, rmcp::ErrorData> {
let (user_id, user_key) = Self::require_user_and_key(&ctx)?;
let tags = input.tags.unwrap_or_default();
let meta = input.meta.unwrap_or_default();
let secrets = input.secrets.unwrap_or_default();
let result = svc_add(
&self.pool,
AddParams {
namespace: &input.namespace,
kind: &input.kind,
name: &input.name,
tags: &tags,
meta_entries: &meta,
secret_entries: &secrets,
user_id: Some(user_id),
},
&user_key,
)
.await
.map_err(|e| rmcp::ErrorData::internal_error(e.to_string(), None))?;
let json = serde_json::to_string_pretty(&result).unwrap_or_default();
Ok(CallToolResult::success(vec![Content::text(json)]))
}
#[tool(
description = "Incrementally update an existing entry. Requires X-Encryption-Key header. \
Only the fields you specify are changed; everything else is preserved."
)]
async fn secrets_update(
&self,
Parameters(input): Parameters<UpdateInput>,
ctx: RequestContext<RoleServer>,
) -> Result<CallToolResult, rmcp::ErrorData> {
let (user_id, user_key) = Self::require_user_and_key(&ctx)?;
let add_tags = input.add_tags.unwrap_or_default();
let remove_tags = input.remove_tags.unwrap_or_default();
let meta = input.meta.unwrap_or_default();
let remove_meta = input.remove_meta.unwrap_or_default();
let secrets = input.secrets.unwrap_or_default();
let remove_secrets = input.remove_secrets.unwrap_or_default();
let result = svc_update(
&self.pool,
UpdateParams {
namespace: &input.namespace,
kind: &input.kind,
name: &input.name,
add_tags: &add_tags,
remove_tags: &remove_tags,
meta_entries: &meta,
remove_meta: &remove_meta,
secret_entries: &secrets,
remove_secrets: &remove_secrets,
user_id: Some(user_id),
},
&user_key,
)
.await
.map_err(|e| rmcp::ErrorData::internal_error(e.to_string(), None))?;
let json = serde_json::to_string_pretty(&result).unwrap_or_default();
Ok(CallToolResult::success(vec![Content::text(json)]))
}
#[tool(
description = "Delete one entry (specify namespace+kind+name) or bulk delete all \
entries matching namespace+kind. Use dry_run=true to preview."
)]
async fn secrets_delete(
&self,
Parameters(input): Parameters<DeleteInput>,
ctx: RequestContext<RoleServer>,
) -> Result<CallToolResult, rmcp::ErrorData> {
let user_id = Self::user_id_from_ctx(&ctx)?;
let result = svc_delete(
&self.pool,
DeleteParams {
namespace: &input.namespace,
kind: input.kind.as_deref(),
name: input.name.as_deref(),
dry_run: input.dry_run.unwrap_or(false),
user_id,
},
)
.await
.map_err(|e| rmcp::ErrorData::internal_error(e.to_string(), None))?;
let json = serde_json::to_string_pretty(&result).unwrap_or_default();
Ok(CallToolResult::success(vec![Content::text(json)]))
}
#[tool(
description = "View change history for an entry. Returns a list of versions with \
actions and timestamps."
)]
async fn secrets_history(
&self,
Parameters(input): Parameters<HistoryInput>,
_ctx: RequestContext<RoleServer>,
) -> Result<CallToolResult, rmcp::ErrorData> {
let result = svc_history(
&self.pool,
&input.namespace,
&input.kind,
&input.name,
input.limit.unwrap_or(20),
None,
)
.await
.map_err(|e| rmcp::ErrorData::internal_error(e.to_string(), None))?;
let json = serde_json::to_string_pretty(&result).unwrap_or_default();
Ok(CallToolResult::success(vec![Content::text(json)]))
}
#[tool(
description = "Rollback an entry to a previous version. Requires X-Encryption-Key header. \
Omit to_version to restore the most recent snapshot."
)]
async fn secrets_rollback(
&self,
Parameters(input): Parameters<RollbackInput>,
ctx: RequestContext<RoleServer>,
) -> Result<CallToolResult, rmcp::ErrorData> {
let (user_id, user_key) = Self::require_user_and_key(&ctx)?;
let result = svc_rollback(
&self.pool,
&input.namespace,
&input.kind,
&input.name,
input.to_version,
&user_key,
Some(user_id),
)
.await
.map_err(|e| rmcp::ErrorData::internal_error(e.to_string(), None))?;
let json = serde_json::to_string_pretty(&result).unwrap_or_default();
Ok(CallToolResult::success(vec![Content::text(json)]))
}
#[tool(
description = "Export matching entries with decrypted secrets as JSON/TOML/YAML string. \
Requires X-Encryption-Key header. Useful for backup or data migration."
)]
async fn secrets_export(
&self,
Parameters(input): Parameters<ExportInput>,
ctx: RequestContext<RoleServer>,
) -> Result<CallToolResult, rmcp::ErrorData> {
let (user_id, user_key) = Self::require_user_and_key(&ctx)?;
let tags = input.tags.unwrap_or_default();
let format = input.format.as_deref().unwrap_or("json");
let data = svc_export(
&self.pool,
ExportParams {
namespace: input.namespace.as_deref(),
kind: input.kind.as_deref(),
name: input.name.as_deref(),
tags: &tags,
query: input.query.as_deref(),
no_secrets: false,
user_id: Some(user_id),
},
Some(&user_key),
)
.await
.map_err(|e| rmcp::ErrorData::internal_error(e.to_string(), None))?;
let serialized = format
.parse::<secrets_core::models::ExportFormat>()
.and_then(|fmt| fmt.serialize(&data))
.map_err(|e| rmcp::ErrorData::internal_error(e.to_string(), None))?;
Ok(CallToolResult::success(vec![Content::text(serialized)]))
}
#[tool(
description = "Build the environment variable map from entry secrets with decrypted \
plaintext values. Requires X-Encryption-Key header. \
Returns a JSON object of VAR_NAME -> plaintext_value ready for injection."
)]
async fn secrets_env_map(
&self,
Parameters(input): Parameters<EnvMapInput>,
ctx: RequestContext<RoleServer>,
) -> Result<CallToolResult, rmcp::ErrorData> {
let (user_id, user_key) = Self::require_user_and_key(&ctx)?;
let tags = input.tags.unwrap_or_default();
let only_fields = input.only_fields.unwrap_or_default();
let env_map = secrets_core::service::env_map::build_env_map(
&self.pool,
input.namespace.as_deref(),
input.kind.as_deref(),
input.name.as_deref(),
&tags,
&only_fields,
input.prefix.as_deref().unwrap_or(""),
&user_key,
Some(user_id),
)
.await
.map_err(|e| rmcp::ErrorData::internal_error(e.to_string(), None))?;
let json = serde_json::to_string_pretty(&env_map).unwrap_or_default();
Ok(CallToolResult::success(vec![Content::text(json)]))
}
}
// ── ServerHandler ─────────────────────────────────────────────────────────────
#[tool_handler]
impl ServerHandler for SecretsService {
fn get_info(&self) -> InitializeResult {
let mut info = InitializeResult::new(ServerCapabilities::builder().enable_tools().build());
info.server_info = Implementation::new("secrets-mcp", env!("CARGO_PKG_VERSION"));
info.protocol_version = ProtocolVersion::V_2025_03_26;
info.instructions = Some(
"Manage cross-device secrets and configuration securely. \
Data is encrypted with your passphrase-derived key. \
Include your 64-char hex key in the X-Encryption-Key header for all read/write operations. \
Use secrets_search to discover entries (no key needed), \
secrets_get to decrypt secret values, \
and secrets_add/secrets_update to write encrypted secrets."
.to_string(),
);
info
}
}

View File

@@ -0,0 +1,648 @@
use askama::Template;
use std::net::SocketAddr;
use axum::{
Json, Router,
body::Body,
extract::{ConnectInfo, Path, Query, State},
http::{HeaderMap, StatusCode, header},
response::{Html, IntoResponse, Redirect, Response},
routing::{get, post},
};
use serde::{Deserialize, Serialize};
use tower_sessions::Session;
use uuid::Uuid;
use secrets_core::audit::log_login;
use secrets_core::crypto::hex;
use secrets_core::service::{
api_key::{ensure_api_key, regenerate_api_key},
audit_log::list_for_user,
user::{
OAuthProfile, bind_oauth_account, find_or_create_user, get_user_by_id,
unbind_oauth_account, update_user_key_setup,
},
};
use crate::AppState;
use crate::oauth::{OAuthConfig, OAuthUserInfo, google_auth_url, random_state};
const SESSION_USER_ID: &str = "user_id";
const SESSION_OAUTH_STATE: &str = "oauth_state";
const SESSION_OAUTH_BIND_MODE: &str = "oauth_bind_mode";
const SESSION_LOGIN_PROVIDER: &str = "login_provider";
// ── Template types ────────────────────────────────────────────────────────────
#[derive(Template)]
#[template(path = "login.html")]
struct LoginTemplate {
has_google: bool,
version: &'static str,
}
#[derive(Template)]
#[template(path = "dashboard.html")]
struct DashboardTemplate {
user_name: String,
user_email: String,
has_passphrase: bool,
base_url: String,
version: &'static str,
}
#[derive(Template)]
#[template(path = "audit.html")]
struct AuditPageTemplate {
user_name: String,
user_email: String,
entries: Vec<AuditEntryView>,
version: &'static str,
}
struct AuditEntryView {
created_at: String,
action: String,
target: String,
detail: String,
}
// ── App state helpers ─────────────────────────────────────────────────────────
fn google_cfg(state: &AppState) -> Option<&OAuthConfig> {
state.google_config.as_ref()
}
async fn current_user_id(session: &Session) -> Option<Uuid> {
session
.get::<String>(SESSION_USER_ID)
.await
.ok()
.flatten()
.and_then(|s| Uuid::parse_str(&s).ok())
}
fn request_client_ip(headers: &HeaderMap, connect_info: ConnectInfo<SocketAddr>) -> Option<String> {
if let Some(first) = headers
.get("x-forwarded-for")
.and_then(|v| v.to_str().ok())
.and_then(|s| s.split(',').next())
{
let value = first.trim();
if !value.is_empty() {
return Some(value.to_string());
}
}
Some(connect_info.ip().to_string())
}
fn request_user_agent(headers: &HeaderMap) -> Option<String> {
headers
.get(header::USER_AGENT)
.and_then(|value| value.to_str().ok())
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
}
// ── Routes ────────────────────────────────────────────────────────────────────
pub fn web_router() -> Router<AppState> {
Router::new()
.route("/favicon.svg", get(favicon_svg))
.route(
"/favicon.ico",
get(|| async { Redirect::permanent("/favicon.svg") }),
)
.route("/", get(login_page))
.route("/auth/google", get(auth_google))
.route("/auth/google/callback", get(auth_google_callback))
.route("/auth/logout", post(auth_logout))
.route("/dashboard", get(dashboard))
.route("/audit", get(audit_page))
.route("/account/bind/google", get(account_bind_google))
.route(
"/account/bind/google/callback",
get(account_bind_google_callback),
)
.route("/account/unbind/{provider}", post(account_unbind))
.route("/api/key-salt", get(api_key_salt))
.route("/api/key-setup", post(api_key_setup))
.route("/api/apikey", get(api_apikey_get))
.route("/api/apikey/regenerate", post(api_apikey_regenerate))
}
async fn favicon_svg() -> Response {
Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, "image/svg+xml")
.header(header::CACHE_CONTROL, "public, max-age=86400")
.body(Body::from(include_str!("../static/favicon.svg")))
.expect("favicon response")
}
// ── Login page ────────────────────────────────────────────────────────────────
async fn login_page(
State(state): State<AppState>,
session: Session,
) -> Result<Response, StatusCode> {
if let Some(_uid) = current_user_id(&session).await {
return Ok(Redirect::to("/dashboard").into_response());
}
let tmpl = LoginTemplate {
has_google: state.google_config.is_some(),
version: env!("CARGO_PKG_VERSION"),
};
render_template(tmpl)
}
// ── Google OAuth ──────────────────────────────────────────────────────────────
async fn auth_google(
State(state): State<AppState>,
session: Session,
) -> Result<Response, StatusCode> {
let config = google_cfg(&state).ok_or(StatusCode::SERVICE_UNAVAILABLE)?;
let oauth_state = random_state();
session
.insert(SESSION_OAUTH_STATE, &oauth_state)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let url = google_auth_url(config, &oauth_state);
Ok(Redirect::to(&url).into_response())
}
#[derive(Deserialize)]
struct OAuthCallbackQuery {
code: Option<String>,
state: Option<String>,
error: Option<String>,
}
async fn auth_google_callback(
State(state): State<AppState>,
connect_info: ConnectInfo<SocketAddr>,
headers: HeaderMap,
session: Session,
Query(params): Query<OAuthCallbackQuery>,
) -> Result<Response, StatusCode> {
let client_ip = request_client_ip(&headers, connect_info);
let user_agent = request_user_agent(&headers);
handle_oauth_callback(
&state,
&session,
params,
"google",
client_ip.as_deref(),
user_agent.as_deref(),
|s, cfg, code| {
Box::pin(crate::oauth::google::exchange_code(
&s.http_client,
cfg,
code,
))
},
)
.await
}
// ── Shared OAuth callback handler ─────────────────────────────────────────────
async fn handle_oauth_callback<F>(
state: &AppState,
session: &Session,
params: OAuthCallbackQuery,
provider: &str,
client_ip: Option<&str>,
user_agent: Option<&str>,
exchange_fn: F,
) -> Result<Response, StatusCode>
where
F: for<'a> Fn(
&'a AppState,
&'a OAuthConfig,
&'a str,
) -> std::pin::Pin<
Box<dyn std::future::Future<Output = anyhow::Result<OAuthUserInfo>> + Send + 'a>,
>,
{
if let Some(err) = params.error {
tracing::warn!(provider, error = %err, "OAuth error");
return Ok(Redirect::to("/?error=oauth_error").into_response());
}
let Some(code) = params.code else {
tracing::warn!(provider, "OAuth callback missing code");
return Ok(Redirect::to("/?error=oauth_missing_code").into_response());
};
let Some(returned_state) = params.state.as_deref() else {
tracing::warn!(provider, "OAuth callback missing state");
return Ok(Redirect::to("/?error=oauth_missing_state").into_response());
};
let expected_state: Option<String> = session
.get(SESSION_OAUTH_STATE)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
if expected_state.as_deref() != Some(returned_state) {
tracing::warn!(
provider,
expected_present = expected_state.is_some(),
"OAuth state mismatch (empty session often means SameSite=Strict or server restart)"
);
return Ok(Redirect::to("/?error=oauth_state").into_response());
}
session.remove::<String>(SESSION_OAUTH_STATE).await.ok();
let config = match provider {
"google" => state
.google_config
.as_ref()
.ok_or(StatusCode::SERVICE_UNAVAILABLE)?,
_ => return Err(StatusCode::BAD_REQUEST),
};
let user_info = exchange_fn(state, config, code.as_str())
.await
.map_err(|e| {
tracing::error!(provider, error = %e, "failed to exchange OAuth code");
StatusCode::INTERNAL_SERVER_ERROR
})?;
let bind_mode: bool = session
.get(SESSION_OAUTH_BIND_MODE)
.await
.unwrap_or(None)
.unwrap_or(false);
if bind_mode {
let user_id = current_user_id(session)
.await
.ok_or(StatusCode::UNAUTHORIZED)?;
session.remove::<bool>(SESSION_OAUTH_BIND_MODE).await.ok();
let profile = OAuthProfile {
provider: user_info.provider,
provider_id: user_info.provider_id,
email: user_info.email,
name: user_info.name,
avatar_url: user_info.avatar_url,
};
bind_oauth_account(&state.pool, user_id, profile)
.await
.map_err(|e| {
tracing::error!(error = %e, "failed to bind OAuth account");
StatusCode::INTERNAL_SERVER_ERROR
})?;
return Ok(Redirect::to("/dashboard?bound=1").into_response());
}
let profile = OAuthProfile {
provider: user_info.provider,
provider_id: user_info.provider_id,
email: user_info.email,
name: user_info.name,
avatar_url: user_info.avatar_url,
};
let (user, _is_new) = find_or_create_user(&state.pool, profile)
.await
.map_err(|e| {
tracing::error!(error = %e, "failed to find or create user");
StatusCode::INTERNAL_SERVER_ERROR
})?;
// Ensure the user has an API key (auto-creates on first login).
if let Err(e) = ensure_api_key(&state.pool, user.id).await {
tracing::warn!(error = %e, "failed to ensure api key for user");
}
session
.insert(SESSION_USER_ID, user.id.to_string())
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
session
.insert(SESSION_LOGIN_PROVIDER, &provider)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
log_login(
&state.pool,
"oauth",
provider,
user.id,
client_ip,
user_agent,
)
.await;
Ok(Redirect::to("/dashboard").into_response())
}
// ── Logout ────────────────────────────────────────────────────────────────────
async fn auth_logout(session: Session) -> impl IntoResponse {
session.flush().await.ok();
Redirect::to("/")
}
// ── Dashboard ─────────────────────────────────────────────────────────────────
async fn dashboard(
State(state): State<AppState>,
session: Session,
) -> Result<Response, StatusCode> {
let Some(user_id) = current_user_id(&session).await else {
return Ok(Redirect::to("/").into_response());
};
let user = match get_user_by_id(&state.pool, user_id)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
{
Some(u) => u,
None => return Ok(Redirect::to("/").into_response()),
};
let tmpl = DashboardTemplate {
user_name: user.name.clone(),
user_email: user.email.clone().unwrap_or_default(),
has_passphrase: user.key_salt.is_some(),
base_url: state.base_url.clone(),
version: env!("CARGO_PKG_VERSION"),
};
render_template(tmpl)
}
async fn audit_page(
State(state): State<AppState>,
session: Session,
) -> Result<Response, StatusCode> {
let Some(user_id) = current_user_id(&session).await else {
return Ok(Redirect::to("/").into_response());
};
let user = match get_user_by_id(&state.pool, user_id)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
{
Some(u) => u,
None => return Ok(Redirect::to("/").into_response()),
};
let rows = list_for_user(&state.pool, user_id, 100)
.await
.map_err(|e| {
tracing::error!(error = %e, "failed to load audit log for user");
StatusCode::INTERNAL_SERVER_ERROR
})?;
let entries = rows
.into_iter()
.map(|row| AuditEntryView {
created_at: row.created_at.format("%Y-%m-%d %H:%M:%S UTC").to_string(),
action: row.action,
target: format_audit_target(&row.namespace, &row.kind, &row.name),
detail: serde_json::to_string_pretty(&row.detail).unwrap_or_else(|_| "{}".to_string()),
})
.collect();
let tmpl = AuditPageTemplate {
user_name: user.name.clone(),
user_email: user.email.clone().unwrap_or_default(),
entries,
version: env!("CARGO_PKG_VERSION"),
};
render_template(tmpl)
}
// ── Account bind/unbind ───────────────────────────────────────────────────────
async fn account_bind_google(
State(state): State<AppState>,
session: Session,
) -> Result<Response, StatusCode> {
let _ = current_user_id(&session)
.await
.ok_or(StatusCode::UNAUTHORIZED)?;
session
.insert(SESSION_OAUTH_BIND_MODE, true)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let redirect_uri = format!("{}/account/bind/google/callback", state.base_url);
let mut cfg = state
.google_config
.clone()
.ok_or(StatusCode::SERVICE_UNAVAILABLE)?;
cfg.redirect_uri = redirect_uri;
let st = random_state();
session.insert(SESSION_OAUTH_STATE, &st).await.ok();
Ok(Redirect::to(&google_auth_url(&cfg, &st)).into_response())
}
async fn account_bind_google_callback(
State(state): State<AppState>,
connect_info: ConnectInfo<SocketAddr>,
headers: HeaderMap,
session: Session,
Query(params): Query<OAuthCallbackQuery>,
) -> Result<Response, StatusCode> {
let client_ip = request_client_ip(&headers, connect_info);
let user_agent = request_user_agent(&headers);
handle_oauth_callback(
&state,
&session,
params,
"google",
client_ip.as_deref(),
user_agent.as_deref(),
|s, cfg, code| {
Box::pin(crate::oauth::google::exchange_code(
&s.http_client,
cfg,
code,
))
},
)
.await
}
async fn account_unbind(
State(state): State<AppState>,
Path(provider): Path<String>,
session: Session,
) -> Result<Response, StatusCode> {
let user_id = current_user_id(&session)
.await
.ok_or(StatusCode::UNAUTHORIZED)?;
let current_login_provider = session
.get::<String>(SESSION_LOGIN_PROVIDER)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
unbind_oauth_account(
&state.pool,
user_id,
&provider,
current_login_provider.as_deref(),
)
.await
.map_err(|e| {
tracing::warn!(error = %e, "failed to unbind oauth account");
StatusCode::BAD_REQUEST
})?;
Ok(Redirect::to("/dashboard?unbound=1").into_response())
}
// ── Passphrase / Key setup API ────────────────────────────────────────────────
#[derive(Serialize)]
struct KeySaltResponse {
has_passphrase: bool,
#[serde(skip_serializing_if = "Option::is_none")]
salt: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
key_check: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
params: Option<serde_json::Value>,
}
async fn api_key_salt(
State(state): State<AppState>,
session: Session,
) -> Result<Json<KeySaltResponse>, StatusCode> {
let user_id = current_user_id(&session)
.await
.ok_or(StatusCode::UNAUTHORIZED)?;
let user = get_user_by_id(&state.pool, user_id)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
.ok_or(StatusCode::UNAUTHORIZED)?;
if user.key_salt.is_none() {
return Ok(Json(KeySaltResponse {
has_passphrase: false,
salt: None,
key_check: None,
params: None,
}));
}
Ok(Json(KeySaltResponse {
has_passphrase: true,
salt: user.key_salt.as_deref().map(hex::encode_hex),
key_check: user.key_check.as_deref().map(hex::encode_hex),
params: user.key_params,
}))
}
#[derive(Deserialize)]
struct KeySetupRequest {
/// Hex-encoded 32-byte random salt
salt: String,
/// Hex-encoded AES-256-GCM encryption of "secrets-mcp-key-check" with the derived key
key_check: String,
/// Key derivation parameters, e.g. {"alg":"pbkdf2-sha256","iterations":600000}
params: serde_json::Value,
}
#[derive(Serialize)]
struct KeySetupResponse {
ok: bool,
}
async fn api_key_setup(
State(state): State<AppState>,
session: Session,
Json(body): Json<KeySetupRequest>,
) -> Result<Json<KeySetupResponse>, StatusCode> {
let user_id = current_user_id(&session)
.await
.ok_or(StatusCode::UNAUTHORIZED)?;
let salt = hex::decode_hex(&body.salt).map_err(|_| StatusCode::BAD_REQUEST)?;
let key_check = hex::decode_hex(&body.key_check).map_err(|_| StatusCode::BAD_REQUEST)?;
if salt.len() != 32 {
return Err(StatusCode::BAD_REQUEST);
}
update_user_key_setup(&state.pool, user_id, &salt, &key_check, &body.params)
.await
.map_err(|e| {
tracing::error!(error = %e, "failed to update key setup");
StatusCode::INTERNAL_SERVER_ERROR
})?;
Ok(Json(KeySetupResponse { ok: true }))
}
// ── API Key management ────────────────────────────────────────────────────────
#[derive(Serialize)]
struct ApiKeyResponse {
api_key: String,
}
async fn api_apikey_get(
State(state): State<AppState>,
session: Session,
) -> Result<Json<ApiKeyResponse>, StatusCode> {
let user_id = current_user_id(&session)
.await
.ok_or(StatusCode::UNAUTHORIZED)?;
let api_key = ensure_api_key(&state.pool, user_id)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
Ok(Json(ApiKeyResponse { api_key }))
}
async fn api_apikey_regenerate(
State(state): State<AppState>,
session: Session,
) -> Result<Json<ApiKeyResponse>, StatusCode> {
let user_id = current_user_id(&session)
.await
.ok_or(StatusCode::UNAUTHORIZED)?;
let api_key = regenerate_api_key(&state.pool, user_id)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
Ok(Json(ApiKeyResponse { api_key }))
}
// ── Helper ────────────────────────────────────────────────────────────────────
fn render_template<T: Template>(tmpl: T) -> Result<Response, StatusCode> {
let html = tmpl.render().map_err(|e| {
tracing::error!(error = %e, "template render error");
StatusCode::INTERNAL_SERVER_ERROR
})?;
Ok(Html(html).into_response())
}
fn format_audit_target(namespace: &str, kind: &str, name: &str) -> String {
if namespace == "auth" {
format!("{}/{}", kind, name)
} else {
format!("[{}/{}] {}", namespace, kind, name)
}
}

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,142 @@
<!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="/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">{{ entry.created_at }}</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>
</body>
</html>

View File

@@ -0,0 +1,939 @@
<!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="/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,125 @@
<!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 — Sign In</title>
<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;
}
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: flex-end; margin-bottom: 20px; }
.lang-bar { display: flex; gap: 2px; background: rgba(255,255,255,0.04); 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); }
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">
<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>
</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': {
title: '登录',
subtitle: '安全管理你的跨设备 secrets。',
google: '使用 Google 登录',
noProviders: '未配置登录方式,请联系管理员。',
},
'zh-TW': {
title: '登入',
subtitle: '安全管理你的跨裝置 secrets。',
google: '使用 Google 登入',
noProviders: '尚未設定登入方式,請聯絡管理員。',
},
'en': {
title: 'Sign in',
subtitle: 'Manage your cross-device secrets securely.',
google: 'Continue with Google',
noProviders: 'No login providers configured. Please contact your administrator.',
}
};
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('.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>

27
deploy/.env.example Normal file
View File

@@ -0,0 +1,27 @@
# Secrets MCP Server 环境变量配置
# 复制此文件为 .env 并填写真实值
# ─── 数据库 ───────────────────────────────────────────────────────────
SECRETS_DATABASE_URL=postgres://postgres:PASSWORD@HOST:PORT/secrets-mcp
# ─── 服务地址 ─────────────────────────────────────────────────────────
# 内网监听地址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=
# ─── 注意 ─────────────────────────────────────────────────────────────
# SERVER_MASTER_KEY 已不再需要。
# 新架构E2EE加密密钥由用户密码短语在客户端本地派生服务端不持有原始密钥。
# 仅在需要迁移旧版 wrapped_key 数据时临时启用。

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"]

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
#
# 所需配置:
# - secrets.RELEASE_TOKEN (必选) Release 上传用,值为 Gitea PAT
# - secrets.RELEASE_TOKEN (可选,推荐) Gitea PAT未配置则工作流跳过 Release 创建与产物上传
# - 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
# 2. 或通过环境变量覆盖: GITEA_TOKEN作为 RELEASE_TOKEN 的值), WEBHOOK_URL
# 3. 或使用 secrets CLI 获取: 需 DATABASE_URL从 refining/service gitea 读取
# 2. 或通过环境变量覆盖: GITEA_TOKEN作为 RELEASE_TOKEN 的值), WEBHOOK_URL,
# DEPLOY_HOST, DEPLOY_USER, DEPLOY_SSH_KEY_FILE部署到 ECS
# 3. 凭据勿用 base64部署私钥路径见 DEPLOY_SSH_KEY_FILE
#
set -e
@@ -21,26 +30,41 @@ OWNER="refining"
REPO="secrets"
# 解析参数
USE_SECRETS_CLI=false
while [[ $# -gt 0 ]]; do
case $1 in
--from-secrets) USE_SECRETS_CLI=true; shift ;;
--from-secrets)
echo "❌ --from-secrets 尚未实现,请使用 ~/.config/gitea/config.env 或环境变量" >&2
exit 1
;;
-h|--help)
echo "用法: $0 [--from-secrets]"
echo "用法: $0"
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 " GITEA_URL Gitea 实例地址"
echo " GITEA_TOKEN 用于 Release 上传的 PAT (创建 RELEASE_TOKEN secret)"
echo " WEBHOOK_URL 飞书 Webhook URL (创建 variable可选)"
echo "环境变量:"
echo " GITEA_URL Gitea 实例地址(可误带尾部 /api/v1脚本会规范化后拼接"
echo " GITEA_TOKEN 用于 Release 的 PAT → secrets.RELEASE_TOKEN"
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
;;
*) shift ;;
*)
echo "❌ 未知参数: $1" >&2
echo " 使用 $0 --help 查看用法" >&2
exit 1
;;
esac
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() {
local config="$HOME/.config/gitea/config.env"
@@ -50,26 +74,6 @@ load_config() {
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
# 优先使用环境变量
@@ -84,18 +88,17 @@ if [[ -z "$GITEA_URL" ]]; then
exit 1
fi
# 去掉 URL 尾部斜杠
# 规范为实例根 URL:去尾部斜杠,并去掉重复的 .../api/v1 后缀(避免拼成 .../api/v1/api/v1
GITEA_URL="${GITEA_URL%/}"
# 确保使用 /api/v1 基础路径(若用户只写了根 URL
[[ "$GITEA_URL" != *"/api/v1"* ]] || true
while [[ "$GITEA_URL" == */api/v1 ]]; do
GITEA_URL="${GITEA_URL%/api/v1}"
GITEA_URL="${GITEA_URL%/}"
done
API_BASE="${GITEA_URL}/api/v1"
# 获取 GITEA_TOKEN作为 workflow 中 secrets.RELEASE_TOKEN 的值)
if [[ -z "$GITEA_TOKEN" ]]; then
if $USE_SECRETS_CLI; then
fetch_from_secrets || exit 1
fi
echo "❌ GITEA_TOKEN 未配置"
echo " 在 ~/.config/gitea/config.env 中设置,或 export GITEA_TOKEN=xxx" >&2
echo " Token 需具备 repo 写权限(创建 Release、上传附件" >&2
@@ -107,12 +110,13 @@ echo "配置 Gitea Actions: $OWNER/$REPO"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo ""
# 1. 创建 Secret: RELEASE_TOKEN
# 1. 创建 Secret: RELEASE_TOKENdata = PAT 原文,勿 base64
echo "1. 创建 Secret: RELEASE_TOKEN"
secret_payload=$(jq -n --arg t "$GITEA_TOKEN" '{data: $t}')
resp=$(curl -s -w "\n%{http_code}" -X PUT \
-H "Authorization: token $GITEA_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"data\":\"${GITEA_TOKEN}\"}" \
-d "$secret_payload" \
"${API_BASE}/repos/${OWNER}/${REPO}/actions/secrets/RELEASE_TOKEN")
http_code=$(echo "$resp" | tail -n1)
body=$(echo "$resp" | sed '$d')
@@ -125,15 +129,16 @@ else
exit 1
fi
# 2. 创建/更新 Variable: WEBHOOK_URL可选
# 2. 创建/更新 Variable: WEBHOOK_URL可选value 为原始 URL 字符串,勿 base64
WEBHOOK_VALUE="${WEBHOOK_URL:-$GITEA_WEBHOOK_URL}"
if [[ -n "$WEBHOOK_VALUE" ]]; then
echo ""
echo "2. 创建/更新 Variable: WEBHOOK_URL"
var_payload=$(jq -n --arg v "$WEBHOOK_VALUE" '{value: $v}')
resp=$(curl -s -w "\n%{http_code}" -X POST \
-H "Authorization: token $GITEA_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"value\":\"${WEBHOOK_VALUE}\"}" \
-d "$var_payload" \
"${API_BASE}/repos/${OWNER}/${REPO}/actions/variables/WEBHOOK_URL")
http_code=$(echo "$resp" | tail -n1)
body=$(echo "$resp" | sed '$d')
@@ -145,7 +150,7 @@ if [[ -n "$WEBHOOK_VALUE" ]]; then
resp=$(curl -s -w "\n%{http_code}" -X PUT \
-H "Authorization: token $GITEA_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"value\":\"${WEBHOOK_VALUE}\"}" \
-d "$var_payload" \
"${API_BASE}/repos/${OWNER}/${REPO}/actions/variables/WEBHOOK_URL")
http_code=$(echo "$resp" | tail -n1)
if [[ "$http_code" == "200" || "$http_code" == "204" ]]; then
@@ -162,6 +167,68 @@ else
echo " 飞书通知将不可用;如需可后续在仓库 Settings → Variables 中添加"
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 "✓ 配置完成"
@@ -170,6 +237,7 @@ echo ""
echo "Workflow 将使用:"
echo " - secrets.RELEASE_TOKEN 创建 Release 并上传二进制"
echo " - vars.WEBHOOK_URL 发送飞书通知(如已配置)"
echo " - vars.DEPLOY_* / secrets.DEPLOY_SSH_KEY deploy-mcp如已配置"
echo ""
echo "推送代码触发构建:"
echo " git push origin main"

View File

@@ -1,34 +0,0 @@
use anyhow::Result;
use serde_json::Value;
use sqlx::PgPool;
/// Write an audit entry for a write operation. Failures are logged as warnings
/// and do not interrupt the main flow.
pub async fn log(
pool: &PgPool,
action: &str,
namespace: &str,
kind: &str,
name: &str,
detail: Value,
) {
let actor = std::env::var("USER").unwrap_or_default();
let result: Result<_, sqlx::Error> = sqlx::query(
"INSERT INTO audit_log (action, namespace, kind, name, detail, actor) \
VALUES ($1, $2, $3, $4, $5, $6)",
)
.bind(action)
.bind(namespace)
.bind(kind)
.bind(name)
.bind(&detail)
.bind(&actor)
.execute(pool)
.await;
if let Err(e) = result {
tracing::warn!(error = %e, "failed to write audit log");
} else {
tracing::debug!(action, namespace, kind, name, actor, "audit logged");
}
}

View File

@@ -1,104 +0,0 @@
use anyhow::Result;
use serde_json::{Map, Value, json};
use sqlx::PgPool;
use std::fs;
/// Parse "key=value" entries. Value starting with '@' reads from file.
pub(crate) 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)?;
tracing::debug!(namespace, kind, name, "upserting record");
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?;
let meta_keys: Vec<&str> = meta_entries
.iter()
.filter_map(|s| s.split_once('=').map(|(k, _)| k))
.collect();
let secret_keys: Vec<&str> = secret_entries
.iter()
.filter_map(|s| s.split_once('=').map(|(k, _)| k))
.collect();
crate::audit::log(
pool,
"add",
namespace,
kind,
name,
json!({
"tags": tags,
"meta_keys": meta_keys,
"secret_keys": secret_keys,
}),
)
.await;
println!("Added: [{}/{}] {}", namespace, kind, name);
if !tags.is_empty() {
println!(" tags: {}", tags.join(", "));
}
if !meta_entries.is_empty() {
println!(" metadata: {}", meta_keys.join(", "));
}
if !secret_entries.is_empty() {
println!(" secrets: {}", secret_keys.join(", "));
}
Ok(())
}

View File

@@ -1,54 +0,0 @@
use crate::config::{self, Config, config_path};
use anyhow::Result;
pub enum ConfigAction {
SetDb { url: String },
Show,
Path,
}
pub async fn run(action: ConfigAction) -> Result<()> {
match action {
ConfigAction::SetDb { url } => {
let cfg = Config {
database_url: Some(url.clone()),
};
config::save_config(&cfg)?;
println!("✓ 数据库连接串已保存到: {}", config_path().display());
println!(" {}", mask_password(&url));
}
ConfigAction::Show => {
let cfg = config::load_config()?;
match cfg.database_url {
Some(url) => {
println!("database_url = {}", mask_password(&url));
println!("配置文件: {}", config_path().display());
}
None => {
println!("未配置数据库连接串。");
println!("请运行secrets config set-db <DATABASE_URL>");
}
}
}
ConfigAction::Path => {
println!("{}", config_path().display());
}
}
Ok(())
}
/// 将 postgres://user:password@host/db 中的密码替换为 ***
fn mask_password(url: &str) -> String {
if let Some(at_pos) = url.rfind('@')
&& let Some(scheme_end) = url.find("://")
{
let prefix = &url[..scheme_end + 3];
let credentials = &url[scheme_end + 3..at_pos];
let rest = &url[at_pos..];
if let Some(colon_pos) = credentials.find(':') {
let user = &credentials[..colon_pos];
return format!("{}{}:***{}", prefix, user, rest);
}
}
url.to_string()
}

View File

@@ -1,24 +0,0 @@
use anyhow::Result;
use serde_json::json;
use sqlx::PgPool;
pub async fn run(pool: &PgPool, namespace: &str, kind: &str, name: &str) -> Result<()> {
tracing::debug!(namespace, kind, name, "deleting record");
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 {
tracing::warn!(namespace, kind, name, "record not found for deletion");
println!("Not found: [{}/{}] {}", namespace, kind, name);
} else {
crate::audit::log(pool, "delete", namespace, kind, name, json!({})).await;
println!("Deleted: [{}/{}] {}", namespace, kind, name);
}
Ok(())
}

View File

@@ -1,5 +0,0 @@
pub mod add;
pub mod config;
pub mod delete;
pub mod search;
pub mod update;

View File

@@ -1,112 +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
);
tracing::debug!(sql, "executing search query");
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(", "));
}
if row.metadata.as_object().is_some_and(|m| !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,169 +0,0 @@
use anyhow::Result;
use serde_json::{Map, Value, json};
use sqlx::{FromRow, PgPool};
use uuid::Uuid;
use super::add::parse_kv;
#[derive(FromRow)]
struct UpdateRow {
id: Uuid,
tags: Vec<String>,
metadata: Value,
encrypted: Value,
}
pub struct UpdateArgs<'a> {
pub namespace: &'a str,
pub kind: &'a str,
pub name: &'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 async fn run(pool: &PgPool, args: UpdateArgs<'_>) -> Result<()> {
let row: Option<UpdateRow> = sqlx::query_as(
r#"
SELECT id, tags, metadata, encrypted
FROM secrets
WHERE namespace = $1 AND kind = $2 AND name = $3
"#,
)
.bind(args.namespace)
.bind(args.kind)
.bind(args.name)
.fetch_optional(pool)
.await?;
let row = row.ok_or_else(|| {
anyhow::anyhow!(
"Not found: [{}/{}] {}. Use `add` to create it first.",
args.namespace,
args.kind,
args.name
)
})?;
// Merge tags
let mut tags: Vec<String> = row.tags;
for t in args.add_tags {
if !tags.contains(t) {
tags.push(t.clone());
}
}
tags.retain(|t| !args.remove_tags.contains(t));
// Merge metadata
let mut meta_map: Map<String, Value> = match row.metadata {
Value::Object(m) => m,
_ => Map::new(),
};
for entry in args.meta_entries {
let (key, value) = parse_kv(entry)?;
meta_map.insert(key, Value::String(value));
}
for key in args.remove_meta {
meta_map.remove(key);
}
let metadata = Value::Object(meta_map);
// Merge encrypted
let mut enc_map: Map<String, Value> = match row.encrypted {
Value::Object(m) => m,
_ => Map::new(),
};
for entry in args.secret_entries {
let (key, value) = parse_kv(entry)?;
enc_map.insert(key, Value::String(value));
}
for key in args.remove_secrets {
enc_map.remove(key);
}
let encrypted = Value::Object(enc_map);
tracing::debug!(
namespace = args.namespace,
kind = args.kind,
name = args.name,
"updating record"
);
sqlx::query(
r#"
UPDATE secrets
SET tags = $1, metadata = $2, encrypted = $3, updated_at = NOW()
WHERE id = $4
"#,
)
.bind(&tags)
.bind(metadata)
.bind(encrypted)
.bind(row.id)
.execute(pool)
.await?;
let meta_keys: Vec<&str> = args
.meta_entries
.iter()
.filter_map(|s| s.split_once('=').map(|(k, _)| k))
.collect();
let secret_keys: Vec<&str> = args
.secret_entries
.iter()
.filter_map(|s| s.split_once('=').map(|(k, _)| k))
.collect();
crate::audit::log(
pool,
"update",
args.namespace,
args.kind,
args.name,
json!({
"add_tags": args.add_tags,
"remove_tags": args.remove_tags,
"meta_keys": meta_keys,
"remove_meta": args.remove_meta,
"secret_keys": secret_keys,
"remove_secrets": args.remove_secrets,
}),
)
.await;
println!("Updated: [{}/{}] {}", args.namespace, args.kind, args.name);
if !args.add_tags.is_empty() {
println!(" +tags: {}", args.add_tags.join(", "));
}
if !args.remove_tags.is_empty() {
println!(" -tags: {}", args.remove_tags.join(", "));
}
if !args.meta_entries.is_empty() {
let keys: Vec<&str> = args
.meta_entries
.iter()
.filter_map(|s| s.split_once('=').map(|(k, _)| k))
.collect();
println!(" +metadata: {}", keys.join(", "));
}
if !args.remove_meta.is_empty() {
println!(" -metadata: {}", args.remove_meta.join(", "));
}
if !args.secret_entries.is_empty() {
let keys: Vec<&str> = args
.secret_entries
.iter()
.filter_map(|s| s.split_once('=').map(|(k, _)| k))
.collect();
println!(" +secrets: {}", keys.join(", "));
}
if !args.remove_secrets.is_empty() {
println!(" -secrets: {}", args.remove_secrets.join(", "));
}
Ok(())
}

View File

@@ -1,70 +0,0 @@
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::PathBuf;
#[derive(Debug, Serialize, Deserialize, Default)]
pub struct Config {
pub database_url: Option<String>,
}
pub fn config_dir() -> PathBuf {
dirs::config_dir()
.unwrap_or_else(|| PathBuf::from("~/.config"))
.join("secrets")
}
pub fn config_path() -> PathBuf {
config_dir().join("config.toml")
}
pub fn load_config() -> Result<Config> {
let path = config_path();
if !path.exists() {
return Ok(Config::default());
}
let content = fs::read_to_string(&path)
.with_context(|| format!("读取配置文件失败: {}", path.display()))?;
let config: Config = toml::from_str(&content)
.with_context(|| format!("解析配置文件失败: {}", path.display()))?;
Ok(config)
}
pub fn save_config(config: &Config) -> Result<()> {
let dir = config_dir();
fs::create_dir_all(&dir).with_context(|| format!("创建配置目录失败: {}", dir.display()))?;
let path = config_path();
let content = toml::to_string_pretty(config).context("序列化配置失败")?;
fs::write(&path, &content).with_context(|| format!("写入配置文件失败: {}", path.display()))?;
// 设置文件权限为 0600仅所有者读写
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let perms = fs::Permissions::from_mode(0o600);
fs::set_permissions(&path, perms)
.with_context(|| format!("设置文件权限失败: {}", path.display()))?;
}
Ok(())
}
/// 按优先级解析数据库连接串:
/// 1. --db-url CLI 参数(非空时使用)
/// 2. ~/.config/secrets/config.toml 中的 database_url
/// 3. 报错并提示用户配置
pub fn resolve_db_url(cli_db_url: &str) -> Result<String> {
if !cli_db_url.is_empty() {
return Ok(cli_db_url.to_string());
}
let config = load_config()?;
if let Some(url) = config.database_url
&& !url.is_empty()
{
return Ok(url);
}
anyhow::bail!("数据库未配置。请先运行:\n\n secrets config set-db <DATABASE_URL>\n")
}

View File

@@ -1,62 +0,0 @@
use anyhow::Result;
use sqlx::PgPool;
use sqlx::postgres::PgPoolOptions;
pub async fn create_pool(database_url: &str) -> Result<PgPool> {
tracing::debug!("connecting to database");
let pool = PgPoolOptions::new()
.max_connections(5)
.connect(database_url)
.await?;
tracing::debug!("database connection established");
Ok(pool)
}
pub async fn migrate(pool: &PgPool) -> Result<()> {
tracing::debug!("running migrations");
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);
CREATE TABLE IF NOT EXISTS audit_log (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
action VARCHAR(32) NOT NULL,
namespace VARCHAR(64) NOT NULL,
kind VARCHAR(64) NOT NULL,
name VARCHAR(256) NOT NULL,
detail JSONB NOT NULL DEFAULT '{}',
actor VARCHAR(128) 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_ns_kind ON audit_log(namespace, kind);
"#,
)
.execute(pool)
.await?;
tracing::debug!("migrations complete");
Ok(())
}

View File

@@ -1,240 +0,0 @@
mod audit;
mod commands;
mod config;
mod db;
mod models;
use anyhow::Result;
use clap::{Parser, Subcommand};
use tracing_subscriber::EnvFilter;
#[derive(Parser)]
#[command(
name = "secrets",
version,
about = "Secrets & config manager backed by PostgreSQL"
)]
struct Cli {
/// Database URL, overrides saved config (one-time override)
#[arg(long, global = true, default_value = "")]
db_url: String,
/// Enable verbose debug output
#[arg(long, short, global = true)]
verbose: bool,
#[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,
},
/// Incrementally update an existing record (merge semantics)
Update {
/// 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,
/// Add a tag (repeatable)
#[arg(long = "add-tag")]
add_tags: Vec<String>,
/// Remove a tag (repeatable)
#[arg(long = "remove-tag")]
remove_tags: Vec<String>,
/// Set or overwrite a metadata field: key=value (repeatable, @file supported)
#[arg(long = "meta", short = 'm')]
meta: Vec<String>,
/// Remove a metadata field by key (repeatable)
#[arg(long = "remove-meta")]
remove_meta: Vec<String>,
/// Set or overwrite a secret field: key=value (repeatable, @file supported)
#[arg(long = "secret", short = 's')]
secrets: Vec<String>,
/// Remove a secret field by key (repeatable)
#[arg(long = "remove-secret")]
remove_secrets: Vec<String>,
},
/// Manage CLI configuration (database connection, etc.)
Config {
#[command(subcommand)]
action: ConfigAction,
},
}
#[derive(Subcommand)]
enum ConfigAction {
/// Save database URL to config file (~/.config/secrets/config.toml)
SetDb {
/// PostgreSQL connection string
url: String,
},
/// Show current configuration
Show,
/// Print path to config file
Path,
}
#[tokio::main]
async fn main() -> Result<()> {
let cli = Cli::parse();
let filter = if cli.verbose {
EnvFilter::new("secrets=debug")
} else {
EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("secrets=warn"))
};
tracing_subscriber::fmt()
.with_env_filter(filter)
.with_target(false)
.init();
// config 子命令不需要数据库连接,提前处理
if let Commands::Config { action } = &cli.command {
let cmd_action = match action {
ConfigAction::SetDb { url } => {
commands::config::ConfigAction::SetDb { url: url.clone() }
}
ConfigAction::Show => commands::config::ConfigAction::Show,
ConfigAction::Path => commands::config::ConfigAction::Path,
};
return commands::config::run(cmd_action).await;
}
let db_url = config::resolve_db_url(&cli.db_url)?;
let pool = db::create_pool(&db_url).await?;
db::migrate(&pool).await?;
match &cli.command {
Commands::Add {
namespace,
kind,
name,
tags,
meta,
secrets,
} => {
let _span =
tracing::info_span!("cmd", command = "add", %namespace, %kind, %name).entered();
commands::add::run(&pool, namespace, kind, name, tags, meta, secrets).await?;
}
Commands::Search {
namespace,
kind,
tag,
query,
show_secrets,
} => {
let _span = tracing::info_span!("cmd", command = "search").entered();
commands::search::run(
&pool,
namespace.as_deref(),
kind.as_deref(),
tag.as_deref(),
query.as_deref(),
*show_secrets,
)
.await?;
}
Commands::Delete {
namespace,
kind,
name,
} => {
let _span =
tracing::info_span!("cmd", command = "delete", %namespace, %kind, %name).entered();
commands::delete::run(&pool, namespace, kind, name).await?;
}
Commands::Update {
namespace,
kind,
name,
add_tags,
remove_tags,
meta,
remove_meta,
secrets,
remove_secrets,
} => {
let _span =
tracing::info_span!("cmd", command = "update", %namespace, %kind, %name).entered();
commands::update::run(
&pool,
commands::update::UpdateArgs {
namespace,
kind,
name,
add_tags,
remove_tags,
meta_entries: meta,
remove_meta,
secret_entries: secrets,
remove_secrets,
},
)
.await?;
}
Commands::Config { .. } => unreachable!(),
}
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>,
}