Compare commits

...

36 Commits

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Made-with: Cursor
2026-03-20 17:36:00 +08:00
70 changed files with 9066 additions and 6307 deletions

View File

@@ -1,12 +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'
- 'deploy/**'
- '.gitea/workflows/**'
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
@@ -16,467 +18,231 @@ permissions:
contents: write
env:
BINARY_NAME: secrets
SECRETS_UPGRADE_URL: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}/releases/latest
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
- name: 严格拦截重复版本
if: steps.ver.outputs.tag_exists == 'true'
# ── Rust 工具链 ──────────────────────────────────────────────────────
- name: 安装 Rust 与 musl 工具链
run: |
echo "错误: 版本 ${{ steps.ver.outputs.tag }} 已存在,禁止重复发版。"
echo "请先 bump Cargo.toml 中的 version并执行 cargo build 同步 Cargo.lock。"
exit 1
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: 15
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")"
sha256sum "$archive" > "${archive}.sha256"
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"
curl -fsS -H "Authorization: token $RELEASE_TOKEN" \
-F "attachment=@${archive}.sha256" \
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/releases/${{ needs.version.outputs.release_id }}/assets"
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} 已发布"
# ── 飞书汇总通知 ─────────────────────────────────────────────────────
- name: 飞书通知
if: always()
env:
WEBHOOK_URL: ${{ vars.WEBHOOK_URL }}
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 }}"
commit=$(git log -1 --pretty=format:"%s" 2>/dev/null || echo "N/A")
url="${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_number }}"
result="${{ job.status }}"
if [ "$result" = "success" ]; then icon="✅"; else icon="❌"; fi
msg="secrets linux 构建${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"
build-macos:
name: Build (macOS aarch64 + x86_64)
needs: [version, check]
runs-on: darwin-arm64
timeout-minutes: 15
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
rustup target add x86_64-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-macos-${{ hashFiles('Cargo.lock') }}
restore-keys: |
cargo-macos-
- run: cargo build --release --locked --target aarch64-apple-darwin
- run: cargo build --release --locked --target x86_64-apple-darwin
- run: strip -x target/aarch64-apple-darwin/release/${{ env.BINARY_NAME }}
- run: strip -x target/x86_64-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 }}"
release_id="${{ needs.version.outputs.release_id }}"
arm_bin="target/aarch64-apple-darwin/release/${{ env.BINARY_NAME }}"
arm_archive="${{ env.BINARY_NAME }}-${tag}-aarch64-macos.tar.gz"
tar -czf "$arm_archive" -C "$(dirname "$arm_bin")" "$(basename "$arm_bin")"
shasum -a 256 "$arm_archive" > "${arm_archive}.sha256"
curl -fsS -H "Authorization: token $RELEASE_TOKEN" \
-F "attachment=@${arm_archive}" \
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/releases/${release_id}/assets"
curl -fsS -H "Authorization: token $RELEASE_TOKEN" \
-F "attachment=@${arm_archive}.sha256" \
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/releases/${release_id}/assets"
intel_bin="target/x86_64-apple-darwin/release/${{ env.BINARY_NAME }}"
intel_archive="${{ env.BINARY_NAME }}-${tag}-x86_64-macos.tar.gz"
tar -czf "$intel_archive" -C "$(dirname "$intel_bin")" "$(basename "$intel_bin")"
shasum -a 256 "$intel_archive" > "${intel_archive}.sha256"
curl -fsS -H "Authorization: token $RELEASE_TOKEN" \
-F "attachment=@${intel_archive}" \
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/releases/${release_id}/assets"
curl -fsS -H "Authorization: token $RELEASE_TOKEN" \
-F "attachment=@${intel_archive}.sha256" \
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/releases/${release_id}/assets"
- name: 飞书通知
if: always()
env:
WEBHOOK_URL: ${{ vars.WEBHOOK_URL }}
run: |
[ -z "$WEBHOOK_URL" ] && exit 0
tag="${{ needs.version.outputs.tag }}"
commit=$(git log -1 --pretty=format:"%s" 2>/dev/null || echo "N/A")
url="${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_number }}"
result="${{ job.status }}"
if [ "$result" = "success" ]; then icon="✅"; else icon="❌"; fi
msg="secrets macOS 双架构构建${icon}
版本:${tag}
目标aarch64-apple-darwin, x86_64-apple-darwin
提交:${commit}
作者:${{ github.actor }}
详情:${url}"
payload=$(python3 -c "import json,sys; print(json.dumps({'msg_type':'text','content':{'text':sys.argv[1]}}))" "$msg")
curl -sS -H "Content-Type: application/json" -X POST -d "$payload" "$WEBHOOK_URL"
build-windows:
name: Build (x86_64-pc-windows-msvc)
needs: [version, check]
runs-on: windows
timeout-minutes: 15
steps:
- name: 安装依赖
shell: pwsh
run: |
$cargoBin = Join-Path $env:USERPROFILE ".cargo\bin"
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
}
$env:Path = "$cargoBin;$env:Path"
Add-Content -Path $env:GITHUB_PATH -Value $cargoBin
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
$hash = (Get-FileHash -Algorithm SHA256 $archive).Hash.ToLower()
Set-Content -Path "${archive}.sha256" -Value "$hash $archive" -NoNewline
$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 }
Invoke-RestMethod -Uri $url -Method Post `
-Headers @{ "Authorization" = "token $env:RELEASE_TOKEN" } `
-Form @{ attachment = Get-Item "${archive}.sha256" }
- name: 飞书通知
if: always()
shell: pwsh
env:
WEBHOOK_URL: ${{ vars.WEBHOOK_URL }}
run: |
if (-not $env:WEBHOOK_URL) { exit 0 }
$tag = "${{ needs.version.outputs.tag }}"
$commit = (git log -1 --pretty=format:"%s" 2>$null) ?? "N/A"
$url = "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_number }}"
$result = "${{ job.status }}"
$icon = if ($result -eq "success") { "✅" } else { "❌" }
$msg = "secrets windows 构建${icon}`n版本${tag}`n提交${commit}`n作者${{ github.actor }}`n详情${url}"
$payload = @{ msg_type = "text"; content = @{ text = $msg } } | ConvertTo-Json
Invoke-RestMethod -Uri $env:WEBHOOK_URL -Method Post `
-ContentType "application/json" -Body $payload
publish-release:
name: 发布草稿 Release
needs: [version, build-linux, build-macos, build-windows]
if: always() && needs.version.outputs.release_id != ''
runs-on: debian
timeout-minutes: 5
steps:
- name: 发布草稿
env:
RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }}
run: |
[ -z "$RELEASE_TOKEN" ] && exit 0
linux_r="${{ needs.build-linux.result }}"
macos_r="${{ needs.build-macos.result }}"
windows_r="${{ needs.build-windows.result }}"
if [ "$linux_r" != "success" ] || [ "$macos_r" != "success" ] || [ "$windows_r" != "success" ]; then
echo "存在未成功的构建任务,保留草稿 Release"
echo "linux=${linux_r} macos=${macos_r} windows=${windows_r}"
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
echo "Release 已发布"
- name: 飞书汇总通知
if: always()
env:
WEBHOOK_URL: ${{ vars.WEBHOOK_URL }}
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 }}"
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 }}"
linux_r="${{ needs.build-linux.result }}"
macos_r="${{ needs.build-macos.result }}"
windows_r="${{ needs.build-windows.result }}"
publish_r="${{ job.status }}"
icon() { case "$1" in success) echo "✅";; skipped) echo "⏭";; *) echo "❌";; esac; }
if [ "$linux_r" = "success" ] && [ "$macos_r" = "success" ] && [ "$windows_r" = "success" ] && [ "$publish_r" = "success" ]; then
status="发布成功 ✅"
elif [ "$linux_r" != "success" ] || [ "$macos_r" != "success" ] || [ "$windows_r" != "success" ]; then
status="构建失败 ❌"
else
status="发布失败 ❌"
fi
if [ "$tag_exists" = "false" ]; then
version_line="🆕 新版本 ${tag}"
else
version_line="🔄 重复构建 ${tag}"
fi
msg="secrets ${status}
${version_line}
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"

3
.gitignore vendored
View File

@@ -2,3 +2,6 @@
.env
.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: run service secrets",
"type": "shell",
"command": "./target/debug/secrets run -n refining --kind service --name gitea -- printenv",
"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 metadata ---' && ./target/debug/secrets search -n test && echo '--- run secrets ---' && ./target/debug/secrets run -n test --kind demo --name roundtrip-test -- printenv && 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=@./test-fixtures/example-key.pem && echo '--- verify metadata ---' && ./target/debug/secrets search -n test --kind key && echo '--- verify run ---' && ./target/debug/secrets run -n test --kind key --name test-key -- printenv && echo '--- cleanup ---' && ./target/debug/secrets delete -n test --kind key --name test-key",
"dependsOn": "build"
"command": "./scripts/release-check.sh",
"problemMatcher": []
}
]
}

700
AGENTS.md
View File

@@ -1,77 +1,63 @@
# Secrets CLI — AGENTS.md
# Secrets MCP — AGENTS.md
## 提交 / 发版硬规则(优先于下文其他说明)
本仓库为 **MCP SaaS**`secrets-core`(业务与持久化)+ `secrets-mcp`Streamable HTTP MCP、Web、OAuth、API Key。对外入口见 `crates/secrets-mcp`
1. 涉及 `src/**``Cargo.toml``Cargo.lock`、CLI 行为变更的提交,默认视为**需要发版**,除非用户明确说明“本次不发版”。
2. 发版前必须先检查 `Cargo.toml` 中的 `version`,再检查是否已存在对应 tag`git tag -l 'secrets-*'`
3. 若当前版本对应 tag 已存在,必须先 bump `Cargo.toml``version`,再执行 `cargo build` 同步 `Cargo.lock`,然后才能提交。
4. 提交前优先运行 `./scripts/release-check.sh`;该脚本会检查重复版本并执行 `cargo fmt -- --check && cargo clippy --locked -- -D warnings && cargo test --locked`
## 提交 / 推送硬规则(优先于下文)
跨设备密钥与配置管理 CLI 工具,将服务器信息、服务凭据等存储到 PostgreSQL 18供 AI 工具读取上下文。每个加密字段单独行存储(`secrets` 子表),字段名、类型、长度以明文保存,主密钥由 Argon2id 从主密码派生并存入平台安全存储macOS Keychain / Windows Credential Manager / Linux keyutils
**每次提交和推送前必须执行以下检查,无论是否明确「发版」:**
1. 涉及 `crates/**`、根目录 `Cargo.toml`/`Cargo.lock``secrets-mcp` 行为变更的提交,默认视为**需要发版**,除非明确说明「本次不发版」。
2. 提交前检查 `crates/secrets-mcp/Cargo.toml``version`,再查 tag`git tag -l 'secrets-mcp-*'`。若当前版本对应 tag 已存在且有代码变更,**必须 bump 版本号**并 `cargo build` 同步 `Cargo.lock`
3. 提交前运行 `./scripts/release-check.sh`(版本/tag + `fmt` + `clippy --locked` + `test --locked`)。若脚本不存在或不可用,至少运行 `cargo fmt -- --check && cargo clippy --locked -- -D warnings && cargo test --locked`
## 项目结构
```
secrets/
src/
main.rs # CLI 入口clap 命令定义auto-migrate--verbose 全局参数
output.rs # OutputMode 枚举(默认 json-o text 供人类使用)
config.rs # 配置读写:~/.config/secrets/config.tomldatabase_url
db.rs # PgPool 创建 + 建表/索引DROP+CREATE含所有表
crypto.rs # AES-256-GCM 加解密、Argon2id 派生、OS 钥匙串
models.rs # Entry + SecretField 结构体sqlx::FromRow + serde
audit.rs # 审计写入log_tx事务内
commands/
init.rs # init 命令:主密钥初始化(每台设备一次)
add.rs # add 命令upsert entries + 逐字段写入 secrets含历史快照
config.rs # config 命令set-db / show / path持久化 database_url
search.rs # search 命令:多条件查询,展示 secrets 字段 schema无需 master_key
delete.rs # delete 命令事务化CASCADE 删除 secrets含历史快照
update.rs # update 命令增量更新secrets 行级 UPSERT/DELETECAS 并发保护
rollback.rs # rollback 命令:按 entry_version 恢复 entry + secrets
history.rs # history 命令:查看 entry 变更历史列表
run.rs # run 命令:仅 secrets 逐字段解密 + key_ref 引用解析(不含 metadata
upgrade.rs # upgrade 命令:检查、校验摘要并下载最新版本,自动替换二进制
export_cmd.rs # export 命令:批量导出记录,支持 JSON/TOML/YAML含解密明文
import_cmd.rs # import 命令批量导入记录冲突检测dry-run重新加密写入
Cargo.toml
crates/
secrets-core/ # db / crypto / models / audit / service
secrets-mcp/ # rmcp tools、axum、OAuth、Dashboard
scripts/
release-check.sh # 发版前检查版本号/tag 是否重复,并执行 fmt/clippy/test
setup-gitea-actions.sh # 配置 Gitea Actions 变量与 Secrets
.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**: `<host>:<port>`
- **Database**: `secrets`
- **连接串**: `postgres://postgres:<password>@<host>:<port>/secrets`
- **表**: `entries`(主表)+ `secrets`(加密字段子表)+ `entries_history` + `secrets_history` + `audit_log` + `kv_config`首次连接自动建表auto-migrate
- **建议库名**`secrets-mcp`(专用实例,与历史库名区分)。
- **连接**:环境变量 **`SECRETS_DATABASE_URL`**(本分支无本地配置文件路径)。
- **表**`entries`(含 `user_id`)、`secrets``entries_history``secrets_history``audit_log``users``oauth_accounts`,首次连接 **auto-migrate**`secrets-core``migrate`)。
- **Web 会话**:与上项 **同一数据库 URL**`secrets-mcp` 启动时对 tower-sessions 的 PostgreSQL 存储 **auto-migrate**(会话表与业务表共存于该实例,无需第二套连接串)。
### 表结构
### 表结构(摘录)
```sql
entries (
id UUID PRIMARY KEY DEFAULT uuidv7(), -- PG18 时间有序 UUID
namespace VARCHAR(64) NOT NULL, -- 一级隔离: "refining" | "ricnsmart"
kind VARCHAR(64) NOT NULL, -- 类型: "server" | "service" | "key"(可扩展)
name VARCHAR(256) NOT NULL, -- 人类可读标识
tags TEXT[] NOT NULL DEFAULT '{}', -- 灵活标签: ["aliyun","hongkong"]
metadata JSONB NOT NULL DEFAULT '{}', -- 明文描述: ip, desc, domains, location...
version BIGINT NOT NULL DEFAULT 1, -- 乐观锁版本号,每次写操作自增
id UUID PRIMARY KEY DEFAULT uuidv7(),
user_id UUID, -- 多租户NULL=遗留行;非空=归属用户
folder VARCHAR(128) NOT NULL DEFAULT '',
type VARCHAR(64) NOT NULL DEFAULT '',
name VARCHAR(256) NOT NULL,
notes TEXT NOT NULL DEFAULT '',
tags TEXT[] NOT NULL DEFAULT '{}',
metadata JSONB NOT NULL DEFAULT '{}',
version BIGINT NOT NULL DEFAULT 1,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(namespace, kind, name)
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
-- 唯一UNIQUE(user_id, folder, name) WHERE user_id IS NOT NULL
-- UNIQUE(folder, name) WHERE user_id IS NULL单租户遗留
```
```sql
secrets (
id UUID PRIMARY KEY DEFAULT uuidv7(),
entry_id UUID NOT NULL REFERENCES entries(id) ON DELETE CASCADE,
field_name VARCHAR(256) NOT NULL, -- 明文字段名: "username", "token", "ssh_key"
encrypted BYTEA NOT NULL DEFAULT '\x', -- 仅加密值本身nonce(12B)||ciphertext+tag
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(),
@@ -79,588 +65,110 @@ secrets (
)
```
```sql
kv_config (
key TEXT PRIMARY KEY, -- 如 'argon2_salt'
value BYTEA NOT NULL -- Argon2id salt首台设备 init 时生成
)
```
### audit_log 表结构
### users / oauth_accounts
```sql
audit_log (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
action VARCHAR(32) NOT NULL, -- 'add' | 'update' | 'delete' | 'rollback'
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()
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}
api_key TEXT UNIQUE, -- MCP Bearer token当前实现为明文存储
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
```
### entries_history 表结构
```sql
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, -- 'add' | 'update' | 'delete' | 'rollback'
tags TEXT[] NOT NULL DEFAULT '{}',
metadata JSONB NOT NULL DEFAULT '{}',
actor VARCHAR(128) NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
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)
)
-- 另有唯一索引 UNIQUE(user_id, provider)(迁移中 idx_oauth_accounts_user_provider同一用户每种 provider 至多一条关联。
```
### secrets_history 表结构
### audit_log / history
```sql
secrets_history (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
entry_id UUID NOT NULL,
secret_id UUID NOT NULL, -- 对应 secrets.id
entry_version BIGINT NOT NULL, -- 关联 entries_history 的版本号
field_name VARCHAR(256) NOT NULL,
encrypted BYTEA NOT NULL DEFAULT '\x',
action VARCHAR(16) NOT NULL, -- 'add' | 'update' | 'delete' | 'rollback'
actor VARCHAR(128) NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
```
与迁移脚本一致:`audit_log``entries_history``secrets_history` 用于审计与时间旅行恢复;字段定义见 `crates/secrets-core/src/db.rs``migrate` SQL。`audit_log` 含可选 **`user_id`**(多租户下标识操作者;可空以兼容遗留数据)。`audit_log` 中普通业务事件使用 **`folder` / `type` / `name`** 对应 entry 坐标;登录类事件固定使用 **`folder='auth'`**,此时 `type`/`name` 表示认证目标而非 entry 身份。
### 字段职责划分
### MCP 消歧AI 调用)
| 字段 | 存什么 | 示例 |
|------|--------|------|
| `namespace` | 项目/团队隔离 | `refining`, `ricnsmart` |
| `kind` | 记录类型 | `server`, `service`, `key` |
| `name` | 唯一标识名 | `i-example0abcd1234efgh`, `gitea` |
| `tags` | 多维分类标签 | `["aliyun","hongkong","ricn"]` |
| `metadata` | 明文非敏感信息 | `{"ip":"192.0.2.1","desc":"Grafana","key_ref":"my-shared-key"}` |
| `secrets.field_name` | 加密字段名(明文) | `"username"`, `"token"`, `"ssh_key"` |
| `secrets.encrypted` | 仅加密值本身 | AES-256-GCM 密文 |
`name` 定位条目的工具(`get` / `update` / 单条 `delete` / `history` / `rollback`):若该用户下仅一条匹配则直接执行;若多条(同 `name`、不同 `folder`)则返回错误并提示补全 `folder``secrets_delete``dry_run=true` 与真实删除使用相同消歧规则。
### PEM 共享机制key_ref
### 字段职责
同一 PEM 被多台服务器共享时,将 PEM 存为独立的 `kind=key` 记录,服务器通过 `metadata.key_ref` 引用:
| 字段 | 含义 | 示例 |
|------|------|------|
| `folder` | 隔离空间(参与唯一键) | `refining` |
| `type` | 软分类(不参与唯一键) | `server`, `service`, `key`, `person` |
| `name` | 标识名 | `gitea`, `aliyun` |
| `notes` | 非敏感说明 | 自由文本 |
| `tags` | 标签 | `["aliyun","prod"]` |
| `metadata` | 明文描述 | `ip``url``key_ref` |
| `secrets.field_name` | 加密字段名(明文) | `token`, `ssh_key` |
| `secrets.encrypted` | 密文 | AES-GCM |
```bash
# 1. 存共享 PEM
secrets add -n refining --kind key --name my-shared-key \
--tag aliyun --tag hongkong \
-s content=@./keys/my-shared-key.pem
### PEM 共享(`key_ref`
# 2. 服务器通过 metadata.key_ref 引用run 时自动合并 key 的 secrets
secrets add -n refining --kind server --name i-example0xyz789 \
-m ip=192.0.2.1 -m key_ref=my-shared-key \
-s username=ecs-user
# 3. 轮换只需更新 key 记录,所有引用服务器自动生效
secrets update -n refining --kind key --name my-shared-key \
-s content=@./keys/new-key.pem
```
## 数据库配置
首次使用需显式配置数据库连接,设置一次后在该设备上持久生效:
```bash
secrets config set-db "postgres://postgres:<password>@<host>:<port>/secrets"
secrets config show # 查看当前配置(密码脱敏)
secrets config path # 打印配置文件路径
```
`set-db` 会先验证连接可用,成功后才写入配置文件;连接失败时提示 "Database connection failed" 且不修改配置。
配置文件:`~/.config/secrets/config.toml`,权限 0600。`--db-url` 参数可一次性覆盖。
## 主密钥与加密
首次使用(每台设备各执行一次):
```bash
secrets config set-db "postgres://postgres:<password>@<host>:<port>/secrets"
secrets init # 提示输入主密码Argon2id 派生主密钥后存入 OS 钥匙串
```
主密码不存储salt 存于 `kv_config`,首台设备生成后共享,确保同一主密码在所有设备派生出相同主密钥。
主密钥存储后端macOS Keychain、Windows Credential Manager、Linux keyutils会话级重启后需再次 `secrets init`)。
**从旧版(明文 JSONB升级**:升级后执行 `secrets init` 即可(明文记录需手动重新 add 或通过 update 更新)。
## CLI 命令
### AI 使用主路径
**读取一律用 `search`,写入用 `add` / `update`,避免反复查帮助。**
输出格式规则:
- 默认始终输出 `json`pretty-printed无论 TTY 还是管道
- 显式 `-o json-compact` → 单行 JSON管道处理时更紧凑
- 显式 `-o text` → 人类可读文本格式
---
### init — 主密钥初始化(每台设备一次)
```bash
# 首次设备:生成 Argon2id salt 并存库,派生主密钥后存 OS 钥匙串
secrets init
# 后续设备:复用已有 salt派生主密钥后存钥匙串主密码需与首台相同
secrets init
```
### search — 发现与读取
```bash
# 参数说明(带典型值)
# -n / --namespace refining | ricnsmart
# --kind server | service
# --name gitea | i-example0abcd1234efgh | mqtt
# --tag aliyun | hongkong | production
# -q / --query mqtt | grafana | gitea (模糊匹配 name/namespace/kind/tags/metadata
# secrets schema search 默认展示 secrets 字段名、类型与长度(无需 master_key
# -f / --field metadata.ip | metadata.url | metadata.default_org
# --summary 不带值的 flag仅返回摘要name/tags/desc/updated_at
# --limit 20 | 50默认 50
# --offset 0 | 10 | 20分页偏移
# --sort name默认| updated | created
# -o / --output text | json | json-compact
# 发现概览(起步推荐)
secrets search --summary --limit 20
secrets search -n refining --summary --limit 20
secrets search --sort updated --limit 10 --summary
# 精确定位单条记录
secrets search -n refining --kind service --name gitea
secrets search -n refining --kind server --name i-example0abcd1234efgh
# 精确定位并获取完整内容secrets 保持加密占位)
secrets search -n refining --kind service --name gitea -o json
# 直接提取 metadata 字段值(最短路径)
secrets search -n refining --kind service --name gitea -f metadata.url
secrets search -n refining --kind service --name gitea \
-f metadata.url -f metadata.default_org
# 需要 secrets 时,改用 run
secrets run -n refining --kind service --name gitea -- printenv
# 模糊关键词搜索
secrets search -q mqtt
secrets search -q grafana
secrets search -q 192.0.2
# 按条件过滤
secrets search -n refining --kind service
secrets search -n ricnsmart --kind server
secrets search --tag hongkong
secrets search --tag aliyun --summary
# 分页
secrets search -n refining --summary --limit 10 --offset 0
secrets search -n refining --summary --limit 10 --offset 10
# 管道 / AI 调用(默认 json直接可解析
secrets search -n refining --kind service | jq '.[].name'
```
---
### add — 新增或全量覆盖upsert
```bash
# 参数说明(带典型值)
# -n / --namespace refining | ricnsmart
# --kind server | service
# --name gitea | i-example0abcd1234efgh
# --tag aliyun | hongkong可重复
# -m / --meta ip=10.0.0.1 | desc="ECS" | url=https://... | tls:cert@./cert.pem可重复
# -s / --secret token=<value> | ssh_key=@./key.pem | password=secret123 | credentials:content@./key.pem可重复
# 添加服务器
secrets add -n refining --kind server --name i-example0abcd1234efgh \
--tag aliyun --tag shanghai \
-m ip=10.0.0.1 -m desc="Aliyun Shanghai ECS" \
-s username=root -s ssh_key=@./keys/deploy-key.pem
# 添加服务凭据
secrets add -n refining --kind service --name gitea \
--tag gitea \
-m url=https://code.example.com -m default_org=refining -m username=voson \
-s token=<token> -s runner_token=<runner_token>
# 从文件读取 token
secrets add -n ricnsmart --kind service --name mqtt \
-m host=mqtt.example.com -m port=1883 \
-s password=@./mqtt_password.txt
# 多行文件直接写入嵌套 secret 字段
secrets add -n refining --kind server --name i-example0abcd1234efgh \
-s credentials:content@./keys/deploy-key.pem
# 使用类型化值key:=<json>)存储非字符串类型
secrets add -n refining --kind service --name prometheus \
-m scrape_interval:=15 \
-m enabled:=true \
-m labels:='["prod","metrics"]' \
-s api_key=abc123
```
---
### update — 增量更新(记录必须已存在)
只有传入的字段才会变动,其余全部保留。
```bash
# 参数说明(带典型值)
# -n / --namespace refining | ricnsmart
# --kind server | service
# --name gitea | i-example0abcd1234efgh
# --add-tag production | backup不影响已有 tag可重复
# --remove-tag staging | deprecated可重复
# -m / --meta ip=10.0.0.1 | desc="新描述" | credentials:username=root新增或覆盖可重复
# --remove-meta old_port | legacy_key | credentials:content删除 metadata 字段,可重复)
# -s / --secret token=<new> | ssh_key=@./new.pem | credentials:content@./new.pem新增或覆盖可重复
# --remove-secret old_password | deprecated_key | credentials:content删除 secret 字段,可重复)
# 更新单个 metadata 字段
secrets update -n refining --kind server --name i-example0abcd1234efgh \
-m ip=10.0.0.1
# 轮换 token
secrets update -n refining --kind service --name gitea \
-s token=<new-token>
# 新增 tag 并轮换 token
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_password
# 从文件更新嵌套 secret 字段
secrets update -n refining --kind server --name i-example0abcd1234efgh \
-s credentials:content@./keys/deploy-key.pem
# 删除嵌套字段
secrets update -n refining --kind server --name i-example0abcd1234efgh \
--remove-secret credentials:content
# 移除 tag
secrets update -n refining --kind service --name gitea --remove-tag staging
```
---
### delete — 删除记录(支持单条精确删除与批量删除)
删除时会自动将 entry 与所有关联 secret 字段快照到历史表,并写入审计日志,可通过 `rollback` 命令恢复。
```bash
# 参数说明(带典型值)
# -n / --namespace refining | ricnsmart必填
# --kind server | service指定 --name 时必填;批量时可选)
# --name gitea | i-example0abcd1234efgh精确匹配省略则批量删除
# --dry-run 预览将删除的记录,不实际写入(仅批量模式有效)
# -o / --output text | json | json-compact
# 精确删除单条记录(--kind 必填)
secrets delete -n refining --kind service --name legacy-mqtt
secrets delete -n ricnsmart --kind server --name i-old-server-id
# 预览批量删除(不写入数据库)
secrets delete -n refining --dry-run
secrets delete -n ricnsmart --kind server --dry-run
# 批量删除整个 namespace 的所有记录
secrets delete -n ricnsmart
# 批量删除 namespace 下指定 kind 的所有记录
secrets delete -n ricnsmart --kind server
# JSON 输出
secrets delete -n refining --kind service -o json
```
---
### history — 查看变更历史
```bash
# 参数说明
# -n / --namespace refining | ricnsmart
# --kind server | service
# --name 记录名
# --limit 返回条数(默认 20
# 查看某条记录的历史版本列表
secrets history -n refining --kind service --name gitea
# 查最近 5 条
secrets history -n refining --kind service --name gitea --limit 5
# JSON 输出
secrets history -n refining --kind service --name gitea -o json
```
---
### rollback — 回滚到指定版本
```bash
# 参数说明
# -n / --namespace refining | ricnsmart
# --kind server | service
# --name 记录名
# --to-version <N> 目标版本号(省略则恢复最近一次快照)
# 撤销上次修改(回滚到最近一次快照)
secrets rollback -n refining --kind service --name gitea
# 回滚到版本 3
secrets rollback -n refining --kind service --name gitea --to-version 3
```
---
### run — 向子进程注入 secrets 并执行命令
仅注入 secrets 表中的加密字段(解密后),不含 metadata。secrets 仅作用于子进程环境,不修改当前 shell进程退出码透传。
使用 `-s/--secret` 指定只注入哪些字段(最小权限原则);使用 `--dry-run` 预览将注入哪些变量名及来源,不执行命令。
```bash
# 参数说明
# -n / --namespace refining | ricnsmart
# --kind server | service
# --name 记录名
# --tag 按 tag 过滤(可重复)
# -s / --secret 只注入指定字段名(可重复;省略则注入全部)
# --prefix 变量名前缀
# --dry-run 预览变量映射,不执行命令
# -o / --output json默认| json-compact | text
# -- <command> 执行的命令及参数(--dry-run 时可省略)
# 注入全部 secrets 到脚本
secrets run -n refining --kind service --name gitea -- ./deploy.sh
# 只注入特定字段(最小化注入范围)
secrets run -n refining --kind service --name aliyun \
-s access_key_id -s access_key_secret -- aliyun ecs DescribeInstances
# 按 tag 批量注入(多条记录合并)
secrets run --tag production -- env | grep -i token
# 预览将注入哪些变量(不执行命令,默认 JSON 输出)
secrets run -n refining --kind service --name gitea --dry-run
# 配合字段过滤预览
secrets run -n refining --kind service --name gitea -s token --dry-run
# text 模式预览(人类阅读)
secrets run -n refining --kind service --name gitea --dry-run -o text
```
---
### upgrade — 自动更新 CLI 二进制
从 Release 服务器下载最新版本,校验对应 `.sha256` 摘要后替换当前二进制,无需数据库连接或主密钥。
**配置方式**`SECRETS_UPGRADE_URL` 必填。优先用**构建时**`SECRETS_UPGRADE_URL=https://... cargo build`CI 已自动注入。或**运行时**:写在 `.env``export` 后执行。
```bash
# 检查是否有新版本(不下载)
secrets upgrade --check
# 下载、校验 SHA-256 并安装最新版本
secrets upgrade
```
---
### export — 批量导出记录
将匹配的记录(含解密后的明文 secrets导出到文件或 stdout。支持 JSON、TOML、YAML 三种格式,文件格式由扩展名自动推断。使用 `--no-secrets` 时无需主密钥。
```bash
# 参数说明
# -n / --namespace refining | ricnsmart
# --kind server | service
# --name gitea | i-example0abcd1234efgh
# --tag aliyun | production可重复
# -q / --query 模糊关键词
# --file <path> 输出文件路径,格式由扩展名推断(.json / .toml / .yaml / .yml
# --format json | toml | yaml 显式指定格式(输出到 stdout 时必须指定)
# --no-secrets 不导出 secrets无需主密钥
# 全量导出到 JSON 文件
secrets export --file backup.json
# 按 namespace 导出为 TOML
secrets export -n refining --file refining.toml
# 按 kind 导出为 YAML
secrets export -n refining --kind service --file services.yaml
# 按 tag 过滤导出
secrets export --tag production --file prod.json
# 模糊关键词导出
secrets export -q mqtt --file mqtt.json
# 仅导出 schema不含 secrets无需主密钥
secrets export --no-secrets --file schema.json
# 输出到 stdout必须指定 --format
secrets export -n refining --format yaml
secrets export --format json | jq '.'
```
---
### import — 批量导入记录
从导出文件读取记录并写入数据库,自动重新加密 secrets。支持 JSON、TOML、YAML 三种格式,文件格式由扩展名自动推断。
```bash
# 参数说明
# <file> 必选,输入文件路径(格式由扩展名推断)
# --force 冲突时覆盖已有记录(默认:报错并停止)
# --dry-run 预览将执行的操作,不写入数据库
# -o / --output text | json | json-compact
# 导入 JSON 文件(遇到已存在记录报错)
secrets import backup.json
# 导入 TOML 文件,冲突时覆盖
secrets import --force refining.toml
# 导入 YAML 文件,冲突时覆盖
secrets import --force services.yaml
# 预览将执行的操作(不写入)
secrets import --dry-run backup.json
# JSON 格式输出导入摘要
secrets import backup.json -o json
```
---
### config — 配置管理(无需主密钥)
```bash
# 设置数据库连接(每台设备执行一次,之后永久生效;先验证连接可用再写入)
secrets config set-db "postgres://postgres:<password>@<host>:<port>/secrets"
# 查看当前配置(密码脱敏)
secrets config show
# 打印配置文件路径
secrets config path
# 输出: /Users/<user>/.config/secrets/config.toml
```
---
### 全局参数
```bash
# debug 日志(位于子命令之前)
secrets --verbose search -q mqtt
secrets -v add -n refining --kind service --name gitea -m url=xxx -s token=yyy
# 或通过环境变量精细控制
RUST_LOG=secrets=trace secrets search
# 一次性覆盖数据库连接
secrets --db-url "postgres://..." search -n refining
```
将共享 PEM 存为 **`type=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`-f`=field`-o`=output
- 日志:用户可见输出用 `println!`;调试/运维信息用 `tracing::debug!`/`info!`/`warn!`/`error!`
- 审计:`add`/`update`/`delete` 成功后调用 `audit::log_tx`,写入 `audit_log` 表;失败只 warn 不中断
- 加密:`encrypted` 列存储 AES-256-GCM 密文;`add`/`update`/`search`/`delete` 需主密钥(`secrets init` 后从 OS 钥匙串加载)
- 输出:读命令通过 `OutputMode` 支持 text/json/json-compact默认始终 `json`pretty`-o text` 供人类阅读;写命令 `add` 同样支持 `-o json`
- 错误:业务层 `anyhow::Result`避免生产路径 `unwrap()`
- 异步:`tokio` + `sqlx` async
- SQL`sqlx::query` / `query_as` 参数绑定;动态 WHERE 仍须用占位符绑定。
- 日志:运维用 `tracing`;面向用户的 Web 响应走 axum handler。tracing 字段风格:变量名即字段名时用简写(`%var``?var``var`),否则用显式形式(`field = %expr`)。
- 审计:写操作成功后尽量 `audit::log_tx`;失败可 `warn`,不掩盖主错误。
- 加密:密钥由用户密码短语通过 **PBKDF2-SHA256600k 次)** 在客户端派生,服务端只存 `key_salt`/`key_check`/`key_params`不持有原始密钥。Web 客户端在浏览器本地完成加解密MCP 客户端通过 `X-Encryption-Key` 请求头传递密钥,服务端临时解密后返回明文。
- MCPtools 参数与 JSON Schema`schemars`)保持同步,鉴权以请求扩展中的用户上下文为准。
## 提交前检查(必须全部通过)
每次提交代码前,请在本地依次执行以下检查,**全部通过后再 push**
优先使用:
## 提交前检查
```bash
./scripts/release-check.sh
```
它等价于先检查版本号 / tag再执行下面的格式、Lint、测试。
### 1. 版本号(按需)
若本次改动需要发版,请先确认 `Cargo.toml` 中的 `version` 已提升,避免 CI 打出的 Tag 与已有版本重复。**升级版本后需同时更新 `Cargo.lock`**(运行 `cargo build` 即可自动同步),否则 CI 中 `cargo clippy --locked` 会因 lock 与 manifest 不一致而失败。可通过 git tag 判断:
或手动:
```bash
# 查看当前 Cargo.toml 版本
grep '^version' Cargo.toml
# 查看是否已存在该版本对应的 tagCI 使用格式 secrets-<version>
git tag -l 'secrets-*'
cargo fmt -- --check
cargo clippy --locked -- -D warnings
cargo test --locked
```
若当前版本已被 tag例如已有 `secrets-0.3.0``Cargo.toml` 仍为 `0.3.0`),则应在 `Cargo.toml` 中 bump 版本号,再执行 `cargo build` 同步 `Cargo.lock`,最后一并提交,以便 CI 自动打新 Tag 并发布 Release。
### 2. 格式、Lint、测试
发版前确认未重复 tag
```bash
cargo fmt -- --check # 格式检查(不通过则运行 cargo fmt 修复)
cargo clippy -- -D warnings # Lint 检查(消除所有 warning
cargo test # 单元/集成测试
```
或一次性执行:
```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 Actionsrunners: debian / darwin-arm64 / windows
- 触发:`src/**``Cargo.toml``Cargo.lock` 变更推送到 main
- 构建目标:`x86_64-unknown-linux-musl``aarch64-apple-darwin``x86_64-apple-darwin`(由 ARM mac runner 交叉编译)、`x86_64-pc-windows-msvc`
- 新版本自动打 Tag格式 `secrets-<version>`)并上传二进制与对应 `.sha256` 摘要到 Gitea Release
- Release 仅在 Linux/macOS/Windows 构建全部成功后才会从 draft 发布
- 通知:飞书 Webhook`vars.WEBHOOK_URL`
- 所需 secrets/vars`RELEASE_TOKEN`Release 上传Gitea PAT`vars.WEBHOOK_URL`通知,可选)
- **注意**Gitea Actions 的 Secret/Variable 创建时,`data`/`value` 字段需传入**原始值**,不要使用 base64 编码
- **触发**:任意分支 `push`,且路径含 `crates/**``deploy/**`、根目录 `Cargo.toml``Cargo.lock``.gitea/workflows/**`(见 `.gitea/workflows/secrets.yml`)。
- **版本与 tag**:从 `crates/secrets-mcp/Cargo.toml` 读版本;构建成功后打 `secrets-mcp-<version>`:若远端已存在同名 tagCI 会先删后于**当前提交**重建并推送(覆盖式发版)。
- **质量与构建**`fmt` / `clippy --locked` / `test --locked``x86_64-unknown-linux-musl` 发布构建 `secrets-mcp`
- **Release可选**`secrets.RELEASE_TOKEN`Gitea PAT用于通过 API **创建或更新**该 tag 的 Release非 draft、上传 `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_UPGRADE_URL` | upgrade 的 Release API 地址。构建时cargo build或运行时.env/export |
| `SECRETS_DATABASE_URL` | **必填**。PostgreSQL URL。 |
| `BASE_URL` | 对外基址OAuth 回调 `${BASE_URL}/auth/google/callback` |
| `SECRETS_MCP_BIND` | 监听地址,默认 `127.0.0.1:9315`(容器/远程直接暴露时需改为 `0.0.0.0:9315`)。 |
| `GOOGLE_CLIENT_ID` / `GOOGLE_CLIENT_SECRET` | 可选;仅运行时配置。 |
| `RUST_LOG` | 如 `secrets_mcp=debug`。 |
数据库连接通过 `secrets config set-db` 持久化到 `~/.config/secrets/config.toml`,不支持环境变量。
> `SERVER_MASTER_KEY` 已不再需要。新架构下密钥由用户密码短语在客户端派生,服务端不持有。

994
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,33 +1,38 @@
[package]
name = "secrets"
version = "0.9.6"
[workspace]
members = [
"crates/secrets-core",
"crates/secrets-mcp",
]
resolver = "2"
[workspace.package]
edition = "2024"
[dependencies]
aes-gcm = "^0.10.3"
anyhow = "^1.0.102"
argon2 = { version = "^0.5.3", features = ["std"] }
chrono = { version = "^0.4.44", features = ["serde"] }
clap = { version = "^4.6.0", features = ["derive"] }
dirs = "^6.0.0"
dotenvy = "^0.15"
flate2 = "^1.1.9"
keyring = { version = "^3.6.3", features = ["apple-native", "windows-native", "linux-native"] }
rand = "^0.10.0"
reqwest = { version = "^0.12", default-features = false, features = ["rustls-tls", "json"] }
rpassword = "^7.4.0"
self-replace = "^1.5.0"
semver = "^1.0.27"
[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"
sha2 = "^0.10.9"
sqlx = { version = "^0.8.6", features = ["runtime-tokio", "tls-rustls", "postgres", "uuid", "json", "chrono"] }
tar = "^0.4.44"
tempfile = "^3.19"
tokio = { version = "^1.50.0", features = ["rt-multi-thread", "macros", "fs", "io-util", "process", "signal"] }
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"] }
uuid = { version = "^1.22.0", features = ["serde"] }
zip = { version = "^8.2.0", default-features = false, features = ["deflate"] }
dotenvy = "^0.15"
# HTTP
reqwest = { version = "^0.12", default-features = false, features = ["rustls-tls", "json"] }

421
README.md
View File

@@ -1,309 +1,161 @@
# secrets
# secrets-mcp
跨设备密钥与配置管理 CLI基于 Rust + PostgreSQL 18
将服务器信息、服务凭据统一存入数据库,供本地工具和 AI 读取上下文。每个敏感字段单独行存储(`secrets` 子表),字段名、类型、长度以明文保存便于 AI 理解,仅值本身使用 AES-256-GCM 加密;主密钥由 Argon2id 从主密码派生并存入系统钥匙串。
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
```
已有旧版本时,可执行 `secrets upgrade` 自动下载最新版并替换。该命令会校验 Release 附带的 `.sha256` 摘要后再安装
发版产物见 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` | 监听地址,默认 `127.0.0.1:9315`。容器内或直接对外暴露端口时请改为 `0.0.0.0:9315`;反代时常为 `127.0.0.1:9315`。 |
| `GOOGLE_CLIENT_ID` / `GOOGLE_CLIENT_SECRET` | 可选;不配置则无 Google 登录入口。运行时从环境读取,勿写入 CI、勿打入二进制。 |
| `RUST_LOG` | 可选;日志级别,如 `secrets_mcp=debug`。 |
```bash
# 1. 配置数据库连接(会先验证连接可用再写入)
secrets config set-db "postgres://postgres:<password>@<host>:<port>/secrets"
# 2. 初始化主密钥(提示输入至少 8 位的主密码,派生后存入 OS 钥匙串)
secrets init
cargo run -p secrets-mcp
```
主密码不会存储,仅用于派生主密钥,且至少需 8 位。同一主密码在所有设备上会得到相同主密钥salt 存于数据库,首台设备生成后共享)。
- **Web**`BASE_URL`登录、Dashboard、设置密码短语、创建 API Key)。
- **MCP**Streamable HTTP 基址 `{BASE_URL}/mcp`,需 `Authorization: Bearer <api_key>` + `X-Encryption-Key: <hex>` 请求头(读密文工具须带密钥)。
**主密钥存储**macOS → KeychainWindows → Credential ManagerLinux → keyutils会话级重启后需再次 `secrets init`)。
## MCP 与 AI 工作流v0.3+
**从旧版(明文存储)升级**:升级后首次运行需执行 `secrets init` 即可(明文记录需手动重新 add 或通过 update 更新)。
条目在逻辑上以 **`(folder, name)`** 在用户内唯一(数据库唯一索引:`user_id + folder + name`)。同名可在不同 folder 下各存一条(例如 `refining/aliyun``ricnsmart/aliyun`)。
## AI Agent 快速指南
- **`secrets_search`**:发现条目(可按 query / folder / type / name 过滤);不要求加密头。
- **`secrets_get` / `secrets_update` / `secrets_delete`(按 name/ `secrets_history` / `secrets_rollback`**:仅 `name` 且全局唯一则直接命中;若多条同名,返回消歧错误,需在参数中补 **`folder`**。
- **`secrets_delete`**`dry_run=true` 时与真实删除相同的消歧规则——唯一则预览一条,多条则报错并要求 `folder`
这个 CLI 以 AI 使用优先设计。核心路径只有一条:**读取用 `search`,写入用 `add` / `update`**。
## 加密架构(混合 E2EE
### 第一步:发现有哪些数据
### 密钥派生
```bash
# 列出所有记录摘要(默认最多 50 条,安全起步)
secrets search --summary --limit 20
用户在 Web Dashboard 设置**密码短语**,浏览器使用 **Web Crypto APIPBKDF2-SHA256600k 次迭代)**在本地派生 256-bit AES 密钥。
# 按 namespace 过滤
secrets search -n refining --summary --limit 20
- **Salt32B**:首次设置时在浏览器生成,存入服务端 `users.key_salt`
- **key_check**:派生密钥加密已知常量 `"secrets-mcp-key-check"`,存入 `users.key_check`,用于登录时验证密码短语
- **服务端不存储原始密钥**,只存 salt + key_check
# 按最近更新排序
secrets search --sort updated --limit 10 --summary
跨设备同步:新设备登录 → 输入相同密码短语 → 从服务端取 salt → 同样的 PBKDF2 → 得到相同密钥。
### 写入与读取流程
```mermaid
flowchart LR
subgraph Web["Web 浏览器E2E"]
P["密码短语"] --> K["PBKDF2 → 256-bit key"]
K --> Enc["AES-256-GCM 加密"]
K --> Dec["AES-256-GCM 解密"]
end
subgraph AI["AI 客户端MCP"]
HdrKey["X-Encryption-Key: hex"]
end
subgraph Server["secrets-mcp 服务端"]
Middleware["请求中临时持有 key\n请求结束即丢弃"]
DB[(PostgreSQL\nsecrets.encrypted = 密文\nentries.metadata = 明文)]
end
Enc -->|密文| Server
HdrKey -->|key + 请求| Middleware
Middleware <-->|加解密| DB
DB -->|密文| Dec
```
`--summary` 只返回轻量字段namespace、kind、name、tags、desc、updated_at不含完整 metadata 和 secrets。
### 两种客户端对比
### 第二步:精确读取单条记录
| | Web 浏览器 | AI 客户端MCP |
|---|---|---|
| 密钥位置 | 仅在浏览器内存 / sessionStorage | MCP 配置 headers 中 |
| 加解密位置 | 客户端(真正 E2E | 服务端临时(请求级生命周期) |
| 安全边界 | 服务端零知识 | 依赖 TLS + 服务端内存隔离 |
```bash
# 精确定位namespace + kind + name 三元组)
secrets search -n refining --kind service --name gitea
### 敏感数据传输
# 获取完整记录(含 secrets 字段名,无需 master_key
secrets search -n refining --kind service --name gitea -o json
- **OAuth `client_secret`** 只存服务端环境变量,不发给浏览器
- **API Key** 当前存放在 `users.api_key`Dashboard 会明文展示并可重置
- **X-Encryption-Key** 随 MCP 请求经 TLS 传输,服务端仅在请求处理期间持有(不持久化)
- **生产环境必须走 HTTPS/TLS**
# 直接提取单个 metadata 字段值(最短路径)
secrets search -n refining --kind service --name gitea -f metadata.url
## AI 客户端配置
# 同时提取多个 metadata 字段
secrets search -n refining --kind service --name gitea \
-f metadata.url -f metadata.default_org
在 Web Dashboard 设置密码短语后,解锁页面会按客户端格式生成配置。常见客户端示例如下:
# 需要 secrets 时,改用 run只注入 token 字段到子进程)
secrets run -n refining --kind service --name gitea -s token -- ./deploy.sh
`Cursor / Claude Desktop` 风格:
# 预览 run 会注入哪些变量(不执行命令)
secrets run -n refining --kind service --name gitea --dry-run
```json
{
"mcpServers": {
"secrets": {
"url": "https://secrets.example.com/mcp",
"headers": {
"Authorization": "Bearer sk_abc123...",
"X-Encryption-Key": "a1b2c3...64位hex"
}
}
}
}
```
`search` 展示 metadata 与 secrets 的字段名,不展示 secret 值本身;需要 secret 值时用 `run`(仅注入加密字段到子进程,不含 metadata。用 `-s` 指定只注入特定字段,最小化注入范围。
`OpenCode` 风格:
### 输出格式
| 场景 | 推荐命令 |
|------|----------|
| AI 解析 / 管道处理(默认) | jsonpretty-printed |
| 管道紧凑格式 | `-o json-compact` |
| 注入 secrets 到子进程环境 | `run` |
| 人类查看 | `-o text` |
默认始终输出 JSON无论是 TTY 还是管道。`text` 输出中时间按本地时区显示;`json/json-compact` 使用 UTCRFC3339
```bash
# 默认 JSON 输出,直接可 jq 解析
secrets search -n refining --kind service | jq '.[].name'
# 需要 secrets 时,使用 run-s 指定只注入特定字段)
secrets run -n refining --kind service --name gitea -s token -- ./deploy.sh
# 预览 run 会注入哪些变量(不执行命令)
secrets run -n refining --kind service --name gitea --dry-run
```
## 完整命令参考
```bash
# 查看帮助(包含各子命令 EXAMPLES
secrets --help
secrets init --help # 主密钥初始化
secrets search --help
secrets add --help
secrets update --help
secrets delete --help
secrets config --help
secrets upgrade --help # 检查并更新 CLI 版本
secrets export --help # 批量导出JSON/TOML/YAML
secrets import --help # 批量导入JSON/TOML/YAML
# ── search ──────────────────────────────────────────────────────────────────
secrets search --summary --limit 20 # 发现概览
secrets search -n refining --kind service # 按 namespace + kind
secrets search -n refining --kind service --name gitea # 精确查找
secrets search -q mqtt # 关键词模糊搜索
secrets search --tag hongkong # 按 tag 过滤
secrets search -n refining --kind service --name gitea -f metadata.url # 提取 metadata 字段
secrets search -n refining --kind service --name gitea -o json # 完整记录(含 secrets schema
secrets search --sort updated --limit 10 --summary # 最近改动
secrets search -n refining --summary --limit 10 --offset 10 # 翻页
# ── add ──────────────────────────────────────────────────────────────────────
secrets add -n refining --kind server --name my-server \
--tag aliyun --tag shanghai \
-m ip=10.0.0.1 -m desc="Example ECS" \
-s username=root -s ssh_key=@./keys/server.pem
# 多行文件直接写入嵌套 secret 字段
secrets add -n refining --kind server --name my-server \
-s credentials:content@./keys/server.pem
# 使用 typed JSON 写入 secret布尔、数字、数组、对象
secrets add -n refining --kind service --name deploy-bot \
-s enabled:=true \
-s retry_count:=3 \
-s scopes:='["repo","workflow"]' \
-s extra:='{"region":"ap-east-1","verify_tls":true}'
secrets add -n refining --kind service --name gitea \
--tag gitea \
-m url=https://code.example.com -m default_org=myorg \
-s token=<token>
# ── update ───────────────────────────────────────────────────────────────────
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>
secrets update -n refining --kind service --name mqtt --remove-meta old_port --remove-secret old_key
secrets update -n refining --kind server --name my-server --remove-secret credentials:content
# ── delete ───────────────────────────────────────────────────────────────────
secrets delete -n refining --kind service --name legacy-mqtt # 精确删除单条(--kind 必填)
secrets delete -n refining --dry-run # 预览批量删除(不写入)
secrets delete -n ricnsmart # 批量删除整个 namespace
secrets delete -n ricnsmart --kind server # 批量删除指定 kind
# ── init ─────────────────────────────────────────────────────────────────────
secrets init # 主密钥初始化(每台设备一次,主密码至少 8 位,派生后存钥匙串)
# ── config ───────────────────────────────────────────────────────────────────
secrets config set-db "postgres://postgres:<password>@<host>:<port>/secrets" # 先验证再写入
secrets config show # 密码脱敏展示
secrets config path # 打印配置文件路径
# ── upgrade ──────────────────────────────────────────────────────────────────
secrets upgrade --check # 仅检查是否有新版本
secrets upgrade # 下载、校验 SHA-256 并安装最新版(可通过 SECRETS_UPGRADE_URL 自托管)
# ── export ────────────────────────────────────────────────────────────────────
secrets export --file backup.json # 全量导出到 JSON
secrets export -n refining --file refining.toml # 按 namespace 导出为 TOML
secrets export -n refining --kind service --file svc.yaml # 按 kind 导出为 YAML
secrets export --tag production --file prod.json # 按 tag 过滤
secrets export -q mqtt --file mqtt.json # 模糊搜索导出
secrets export --no-secrets --file schema.json # 仅导出 schema无需主密钥
secrets export -n refining --format yaml # 输出到 stdout指定格式
# ── import ────────────────────────────────────────────────────────────────────
secrets import backup.json # 导入(冲突时报错)
secrets import --force refining.toml # 冲突时覆盖已有记录
secrets import --dry-run backup.yaml # 预览将要执行的操作(不写入)
# ── run ───────────────────────────────────────────────────────────────────────
secrets run -n refining --kind service --name gitea -- ./deploy.sh # 注入全部 secrets
secrets run -n refining --kind service --name gitea -s token -- ./deploy.sh # 只注入 token 字段
secrets run -n refining --kind service --name aliyun \
-s access_key_id -s access_key_secret -- aliyun ecs DescribeInstances # 只注入指定字段
secrets run --tag production -- env # 按 tag 批量注入
secrets run -n refining --kind service --name gitea --dry-run # 预览变量映射
secrets run -n refining --kind service --name gitea -s token --dry-run # 过滤后预览
secrets run -n refining --kind service --name gitea --dry-run -o text # 人类可读预览
# ── 调试 ──────────────────────────────────────────────────────────────────────
secrets --verbose search -q mqtt
RUST_LOG=secrets=trace secrets search
```json
{
"mcp": {
"secrets": {
"type": "remote",
"enabled": true,
"url": "https://secrets.example.com/mcp",
"headers": {
"Authorization": "Bearer sk_abc123...",
"X-Encryption-Key": "a1b2c3...64位hex"
}
}
}
}
```
## 数据模型
主表 `entries`namespace、kind、name、tags、metadata+ 子表 `secrets`(每个加密字段一行,含 field_nameencrypted)。首次连接自动建表;同时创建 `audit_log``entries_history``secrets_history` 等表
主表 **`entries`**`folder``type``name``notes``tags``metadata`,多租户时带 `user_id`+ 子表 **`secrets`**(每行一个加密字段:`field_name``encrypted`)。**唯一性**`UNIQUE(user_id, folder, name)``user_id` 为空时为遗留行唯一 `(folder, name)`)。另有 `entries_history``secrets_history``audit_log`,以及 **`users`**(含 `key_salt``key_check``key_params``api_key`)、**`oauth_accounts`**。首次连库自动迁移建表(`secrets-core``migrate`);已有库可对照 [`scripts/migrate-v0.3.0.sql`](scripts/migrate-v0.3.0.sql) 做列重命名与索引重建。**Web 登录会话**tower-sessions使用同一 `SECRETS_DATABASE_URL`,进程启动时对会话存储执行迁移(见 `secrets-mcp``PostgresStore::migrate`),无需额外环境变量
| 位置 | 字段 | 说明 |
|------|------|------|
| entries | namespace | 一级隔离,如 `refining``ricnsmart` |
| entries | kind | 记录类型,如 `server``service``key`(可自由扩展 |
| entries | name | 人类可读唯一标识 |
| entries | tags | 多维标签,如 `["aliyun","hongkong"]` |
| entries | metadata | 明文描述ip、desc、domains、key_ref 等) |
| secrets | field_name | 明文search 可见AI 可推断 run 会注入哪些变量 |
| secrets | encrypted | 仅加密值本身AES-256-GCM |
| entries | folder | 组织/隔离空间,如 `refining``ricnsmart`;参与唯一键 |
| entries | type | 软分类,如 `server``service``key``person`(可扩展,不参与唯一键 |
| entries | name | 人类可读标识;与 `folder` 一起在用户内唯一 |
| entries | notes | 非敏感说明文本 |
| 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` 写入 `secrets` 表的独立行。支持 `key=value``key=@file``key:=<json>`,也支持 `credentials:content@./key.pem` 这类嵌套字段文件写入;删除时支持 `--remove-secret credentials:content`。加解密使用主密钥(由 `secrets init` 设置)。
### PEM 共享(`key_ref`
**PEM 共享**:同一 PEM 被多台服务器共享时,可存为 `kind=key` 记录,服务器通过 `metadata.key_ref` 引用;轮换只需 update 一条 key 记录,所有引用自动生效。详见 [AGENTS.md](AGENTS.md)
### `-m` / `--meta` JSON 语法速查
`-m``-s` 走的是同一套解析规则,只是写入位置不同:`-m` 写到明文 `metadata`,适合端口、开关、标签、描述性配置等非敏感信息。
| 目标值 | 写法示例 | 实际存入 |
|------|------|------|
| 普通字符串 | `-m url=https://code.example.com` | `"https://code.example.com"` |
| 文件内容字符串 | `-m notes=@./service-notes.txt` | `"..."` |
| 布尔值 | `-m enabled:=true` | `true` |
| 数字 | `-m port:=3000` | `3000` |
| `null` | `-m deprecated_at:=null` | `null` |
| 数组 | `-m domains:='["code.example.com","git.example.com"]'` | `["code.example.com","git.example.com"]` |
| 对象 | `-m tls:='{"enabled":true,"redirect_http":true}'` | `{"enabled":true,"redirect_http":true}` |
| 嵌套路径 + JSON | `-m deploy:strategy:='{"type":"rolling","batch":2}'` | `{"deploy":{"strategy":{"type":"rolling","batch":2}}}` |
常见规则:
- `=` 表示按字符串存储。
- `:=` 表示按 JSON 解析。
- shell 中数组和对象建议整体用单引号包住。
- 嵌套字段继续用冒号分隔:`-m runtime:max_open_conns:=20`
示例:新增一条带 typed metadata 的记录
```bash
secrets add -n refining --kind service --name gitea \
-m url=https://code.example.com \
-m port:=3000 \
-m enabled:=true \
-m domains:='["code.example.com","git.example.com"]' \
-m tls:='{"enabled":true,"redirect_http":true}'
```
示例:更新已有记录中的嵌套 metadata
```bash
secrets update -n refining --kind service --name gitea \
-m deploy:strategy:='{"type":"rolling","batch":2}' \
-m runtime:max_open_conns:=20
```
### `-s` / `--secret` JSON 语法速查
当你希望写入的不是普通字符串,而是 `true``123``null`、数组或对象时,用 `:=`,右侧按 JSON 解析。
| 目标值 | 写法示例 | 实际存入 |
|------|------|------|
| 普通字符串 | `-s token=abc123` | `"abc123"` |
| 文件内容字符串 | `-s ssh_key=@./id_ed25519` | `"-----BEGIN ..."` |
| 布尔值 | `-s enabled:=true` | `true` |
| 数字 | `-s retry_count:=3` | `3` |
| `null` | `-s deprecated_at:=null` | `null` |
| 数组 | `-s scopes:='["repo","workflow"]'` | `["repo","workflow"]` |
| 对象 | `-s extra:='{"region":"ap-east-1","verify_tls":true}'` | `{"region":"ap-east-1","verify_tls":true}` |
| 嵌套路径 + JSON | `-s auth:policy:='{"mfa":true,"ttl":3600}'` | `{"auth":{"policy":{"mfa":true,"ttl":3600}}}` |
常见规则:
- `=` 表示按字符串存储,不做 JSON 解析。
- `:=` 表示按 JSON 解析,适合布尔、数字、数组、对象、`null`
- shell 里对象和数组通常要整体加引号,推荐单引号:`-s flags:='["a","b"]'`
- 嵌套字段继续用冒号分隔:`-s credentials:enabled:=true`
- 如果你就是想存一个“JSON 字符串字面量”,可以写成 `-s note:='"hello"'`,但大多数字符串场景直接用 `=` 更直观。
示例:新增一条同时包含字符串、文件、布尔、数组、对象的记录
```bash
secrets add -n refining --kind service --name deploy-bot \
-s token=abc123 \
-s ssh_key=@./keys/deploy-bot.pem \
-s enabled:=true \
-s scopes:='["repo","workflow"]' \
-s policy:='{"ttl":3600,"mfa":true}'
```
示例:更新已有记录中的嵌套 JSON 字段
```bash
secrets update -n refining --kind service --name deploy-bot \
-s auth:config:='{"issuer":"gitea","rotate":true}' \
-s auth:retry:=5
```
同一 PEM 可被多条 `server` 等记录引用:将 PEM 存为 **`type=key`** 的 entry在其它条目的 `metadata.key_ref` 中写该 key 条目的 `name`;轮换时只更新 key 对应记录即可
## 审计日志
`add``update``delete` 操作成功后自动向 `audit_log` 表写入一条记录,包含操作类型、操作对象和变更摘要不含 secret 值)。操作者取自 `$USER` 环境变量
`add``update``delete` 等写操作写入 **`audit_log`**(操作类型、对象、摘要不含 secret 明文)。多租户场景下可写 **`user_id`**(可空,兼容遗留行)
业务条目事件使用 **`folder` / `type` / `name`**;登录类事件使用 **`folder='auth'`**,此时 `type`/`name` 表示认证目标(例如 `oauth` / `google`),不表示某条 secrets entry。
```sql
-- 查看最近 20 条审计记录
SELECT action, namespace, kind, name, actor, detail, created_at
SELECT action, folder, type, name, detail, user_id, created_at
FROM audit_log
ORDER BY created_at DESC
LIMIT 20;
@@ -312,49 +164,26 @@ LIMIT 20;
## 项目结构
```
src/
main.rs # CLI 入口clap含各子命令 after_help 示例
output.rs # OutputMode 枚举 + TTY 检测
config.rs # 配置读写(~/.config/secrets/config.toml
db.rs # 连接池 + auto-migrateentries + secrets + entries_history + secrets_history + audit_log + kv_config
crypto.rs # AES-256-GCM 加解密、Argon2id 派生、OS 钥匙串
models.rs # Entry + SecretField 结构体
audit.rs # 审计日志写入audit_log 表)
commands/
init.rs # 主密钥初始化(首次/新设备)
add.rs # upsert entries + secrets 行,支持 -o json
config.rs # config set-db/show/path
search.rs # 多条件查询,展示 secrets schema-f/-o/--summary/--limit/--offset/--sort
delete.rs # 删除CASCADE 删除 secrets
update.rs # 增量更新tags/metadata + secrets 行级 UPSERT/DELETE
rollback.rs # rollback / history按 entry_version 恢复
run.rs # run仅 secrets 逐字段解密 + key_ref 引用解析(不含 metadata
upgrade.rs # 从 Gitea Release 自更新
export_cmd.rs # export批量导出支持 JSON/TOML/YAML含解密明文
import_cmd.rs # import批量导入冲突检测dry-run重新加密写入
Cargo.toml
crates/secrets-core/ # db / crypto / models / audit / service
crates/secrets-mcp/ # MCP HTTP、Web、OAuth、API Key
scripts/
setup-gitea-actions.sh # 配置 Gitea Actions 变量与 Secrets
migrate-v0.3.0.sql # 可选:手动 SQL 迁移namespace/kind → folder/type、唯一键含 folder
deploy/ # systemd、.env 示例
```
## CI/CDGitea Actions
推送 `main` 分支时自动fmt/clippy/test 检查 → Linux/macOS/Windows 构建 → 上传二进制与 `.sha256` 摘要 → 所有平台成功后发布 Release
见 [`.gitea/workflows/secrets.yml`](.gitea/workflows/secrets.yml)
**首次使用需配置 Actions 变量和 Secrets**
- **触发**:任意分支 `push`,且变更路径包含 `crates/**``deploy/**`、根目录 `Cargo.toml` / `Cargo.lock``.gitea/workflows/**`
- **流水线**:解析 `crates/secrets-mcp/Cargo.toml` 版本 → `cargo fmt` / `clippy --locked` / `test --locked` → 交叉编译 `x86_64-unknown-linux-musl``secrets-mcp` → 构建成功后打 tag `secrets-mcp-<version>`(若远端已存在同名 tag会先删除再于**当前提交**重建并推送,覆盖式发版)。
- **Release可选**:配置仓库 Secret `RELEASE_TOKEN`Gitea PAT明文勿 base64会通过 API **创建或更新**已指向该 tag 的 Release非 draft、上传 `tar.gz``.sha256`;未配置则跳过 API Release仅 tag + 构建结果。
- **部署(可选)**:仅在 `main``feat/mcp``mcp` 分支且构建成功时,若已配置 `vars.DEPLOY_HOST``vars.DEPLOY_USER``secrets.DEPLOY_SSH_KEY`,则 `deploy-mcp` 通过 SCP/SSH 更新目标机二进制并 `systemctl restart secrets-mcp`
- **通知(可选)**`vars.WEBHOOK_URL` 为飞书 Webhook 时,构建/部署/发布节点会推送简要状态。
```bash
# 需有 ~/.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飞书通知可选
- **注意**Secret/Variable 的 `data`/`value` 字段需传入原始值,不要 base64 编码
当前 Release 预编译产物覆盖:
- Linux `x86_64-unknown-linux-musl`
- macOS Apple Silicon `aarch64-apple-darwin`
- macOS Intel `x86_64-apple-darwin`(由 ARM mac runner 交叉编译)
- Windows `x86_64-pc-windows-msvc`
详见 [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,88 @@
use serde_json::{Value, json};
use sqlx::{PgPool, Postgres, Transaction};
use uuid::Uuid;
pub const ACTION_LOGIN: &str = "login";
pub const FOLDER_AUTH: &str = "auth";
fn login_detail(provider: &str, client_ip: Option<&str>, user_agent: Option<&str>) -> Value {
json!({
"provider": provider,
"client_ip": client_ip,
"user_agent": user_agent,
})
}
/// Write a login audit entry without requiring an explicit transaction.
pub async fn log_login(
pool: &PgPool,
entry_type: &str,
provider: &str,
user_id: Uuid,
client_ip: Option<&str>,
user_agent: Option<&str>,
) {
let detail = login_detail(provider, client_ip, user_agent);
let result: Result<_, sqlx::Error> = sqlx::query(
"INSERT INTO audit_log (user_id, action, folder, type, name, detail) \
VALUES ($1, $2, $3, $4, $5, $6)",
)
.bind(user_id)
.bind(ACTION_LOGIN)
.bind(FOLDER_AUTH)
.bind(entry_type)
.bind(provider)
.bind(&detail)
.execute(pool)
.await;
if let Err(e) = result {
tracing::warn!(error = %e, entry_type, provider, "failed to write login audit log");
} else {
tracing::debug!(entry_type, provider, ?user_id, "login audit logged");
}
}
/// Write an audit entry within an existing transaction.
pub async fn log_tx(
tx: &mut Transaction<'_, Postgres>,
user_id: Option<Uuid>,
action: &str,
folder: &str,
entry_type: &str,
name: &str,
detail: Value,
) {
let result: Result<_, sqlx::Error> = sqlx::query(
"INSERT INTO audit_log (user_id, action, folder, type, name, detail) \
VALUES ($1, $2, $3, $4, $5, $6)",
)
.bind(user_id)
.bind(action)
.bind(folder)
.bind(entry_type)
.bind(name)
.bind(&detail)
.execute(&mut **tx)
.await;
if let Err(e) = result {
tracing::warn!(error = %e, "failed to write audit log");
} else {
tracing::debug!(action, folder, entry_type, name, "audit logged");
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn login_detail_includes_expected_fields() {
let detail = login_detail("google", Some("127.0.0.1"), Some("Mozilla/5.0"));
assert_eq!(detail["provider"], "google");
assert_eq!(detail["client_ip"], "127.0.0.1");
assert_eq!(detail["user_agent"], "Mozilla/5.0");
}
}

View File

@@ -0,0 +1,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

@@ -3,39 +3,10 @@ use aes_gcm::{
aead::{Aead, AeadCore, KeyInit, OsRng},
};
use anyhow::{Context, Result, bail};
use argon2::{Argon2, Params, Version};
use serde_json::Value;
const KEYRING_SERVICE: &str = "secrets-cli";
const KEYRING_USER: &str = "master-key";
const NONCE_LEN: usize = 12;
// Argon2id parameters — OWASP recommended (m=64 MiB, t=3 iterations, p=4 threads, key=32 B)
const ARGON2_M_COST: u32 = 65_536;
const ARGON2_T_COST: u32 = 3;
const ARGON2_P_COST: u32 = 4;
const ARGON2_KEY_LEN: usize = 32;
// ─── Argon2id key derivation ─────────────────────────────────────────────────
/// Derive a 32-byte Master Key from a password and salt using Argon2id.
/// Parameters: m=65536 KiB (64 MB), t=3, p=4 — OWASP recommended.
pub fn derive_master_key(password: &str, salt: &[u8]) -> Result<[u8; 32]> {
let params = Params::new(
ARGON2_M_COST,
ARGON2_T_COST,
ARGON2_P_COST,
Some(ARGON2_KEY_LEN),
)
.context("invalid Argon2id params")?;
let argon2 = Argon2::new(argon2::Algorithm::Argon2id, Version::V0x13, params);
let mut key = [0u8; 32];
argon2
.hash_password_into(password.as_bytes(), salt, &mut key)
.map_err(|e| anyhow::anyhow!("Argon2id derivation failed: {}", e))?;
Ok(key)
}
// ─── AES-256-GCM encrypt / decrypt ───────────────────────────────────────────
/// Encrypt plaintext bytes with AES-256-GCM.
@@ -84,20 +55,14 @@ pub fn decrypt_json(master_key: &[u8; 32], data: &[u8]) -> Result<Value> {
serde_json::from_slice(&bytes).context("deserialize decrypted JSON")
}
// ─── OS Keychain ──────────────────────────────────────────────────────────────
// ─── Client-supplied key extraction ──────────────────────────────────────────
/// Load the Master Key from the OS Keychain.
/// Returns an error with a helpful message if it hasn't been initialized.
pub fn load_master_key() -> Result<[u8; 32]> {
let entry =
keyring::Entry::new(KEYRING_SERVICE, KEYRING_USER).context("create keychain entry")?;
let hex = entry.get_password().map_err(|_| {
anyhow::anyhow!("Master key not found in keychain. Run `secrets init` first.")
})?;
let bytes = hex::decode_hex(&hex)?;
/// 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!(
"stored master key has unexpected length {}; re-run `secrets init`",
"X-Encryption-Key must be 64 hex chars (32 bytes), got {} bytes",
bytes.len()
);
}
@@ -106,20 +71,9 @@ pub fn load_master_key() -> Result<[u8; 32]> {
Ok(key)
}
/// Store the Master Key in the OS Keychain (overwrites any existing value).
pub fn store_master_key(key: &[u8; 32]) -> Result<()> {
let entry =
keyring::Entry::new(KEYRING_SERVICE, KEYRING_USER).context("create keychain entry")?;
let hex = hex::encode_hex(key);
entry
.set_password(&hex)
.map_err(|e| anyhow::anyhow!("keychain write failed: {}", e))?;
Ok(())
}
// ─── Public hex helpers ───────────────────────────────────────────────────────
// ─── Minimal hex helpers (avoid extra dep) ────────────────────────────────────
mod hex {
pub mod hex {
use anyhow::{Result, bail};
pub fn encode_hex(bytes: &[u8]) -> String {
@@ -127,6 +81,7 @@ mod hex {
}
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");
}
@@ -156,7 +111,6 @@ mod tests {
let plaintext = b"hello world";
let enc1 = encrypt(&key, plaintext).unwrap();
let enc2 = encrypt(&key, plaintext).unwrap();
// Different nonces → different ciphertexts
assert_ne!(enc1, enc2);
}
@@ -176,20 +130,4 @@ mod tests {
let dec = decrypt_json(&key, &enc).unwrap();
assert_eq!(dec, value);
}
#[test]
fn derive_master_key_deterministic() {
let salt = b"fixed_test_salt_";
let k1 = derive_master_key("password", salt).unwrap();
let k2 = derive_master_key("password", salt).unwrap();
assert_eq!(k1, k2);
}
#[test]
fn derive_master_key_different_passwords() {
let salt = b"fixed_test_salt_";
let k1 = derive_master_key("password1", salt).unwrap();
let k2 = derive_master_key("password2", salt).unwrap();
assert_ne!(k1, k2);
}
}

View File

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

View File

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

View File

@@ -4,14 +4,18 @@ use serde_json::Value;
use std::collections::BTreeMap;
use uuid::Uuid;
/// A top-level entry (server, service, key, …).
/// A top-level entry (server, service, key, person, …).
/// Sensitive fields are stored separately in `secrets`.
#[derive(Debug, Serialize, Deserialize, sqlx::FromRow)]
pub struct Entry {
pub id: Uuid,
pub namespace: String,
pub kind: String,
pub user_id: Option<Uuid>,
pub folder: String,
#[serde(rename = "type")]
#[sqlx(rename = "type")]
pub entry_type: String,
pub name: String,
pub notes: String,
pub tags: Vec<String>,
pub metadata: Value,
pub version: i64,
@@ -39,8 +43,12 @@ pub struct SecretField {
pub struct EntryRow {
pub id: Uuid,
pub version: i64,
pub folder: String,
#[sqlx(rename = "type")]
pub entry_type: String,
pub tags: Vec<String>,
pub metadata: Value,
pub notes: String,
}
/// Minimal secret field row fetched before snapshots or cascade deletes.
@@ -61,20 +69,10 @@ pub enum ExportFormat {
Yaml,
}
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();
Self::from_str(&ext).map_err(|_| {
anyhow::anyhow!(
"Cannot infer format from extension '.{}'. Use --format json|toml|yaml",
ext
)
})
}
impl std::str::FromStr for ExportFormat {
type Err = anyhow::Error;
/// Parse from --format CLI value.
pub fn from_str(s: &str) -> anyhow::Result<Self> {
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_lowercase().as_str() {
"json" => Ok(Self::Json),
"toml" => Ok(Self::Toml),
@@ -82,6 +80,19 @@ impl ExportFormat {
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> {
@@ -124,10 +135,14 @@ pub struct ExportData {
/// 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 folder: String,
#[serde(default, rename = "type")]
pub entry_type: String,
#[serde(default)]
pub notes: String,
#[serde(default)]
pub tags: Vec<String>,
#[serde(default)]
pub metadata: Value,
@@ -136,6 +151,56 @@ pub struct ExportEntry {
pub secrets: Option<BTreeMap<String, Value>>,
}
// ── Multi-user models ──────────────────────────────────────────────────────────
/// A registered user (created on first OAuth login).
#[derive(Debug, Serialize, Deserialize, sqlx::FromRow)]
pub struct User {
pub id: Uuid,
pub email: Option<String>,
pub name: String,
pub avatar_url: Option<String>,
/// PBKDF2 salt (32 B). NULL until user sets up passphrase.
pub key_salt: Option<Vec<u8>>,
/// AES-256-GCM encryption of the known constant "secrets-mcp-key-check".
/// Used to verify the passphrase without storing the key itself.
pub key_check: Option<Vec<u8>>,
/// Key derivation parameters, e.g. {"alg":"pbkdf2-sha256","iterations":600000}.
pub key_params: Option<serde_json::Value>,
/// Plaintext API key for MCP Bearer authentication. Auto-created on first login.
pub api_key: Option<String>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
/// An OAuth account linked to a user.
#[derive(Debug, Serialize, Deserialize, sqlx::FromRow)]
pub struct OauthAccount {
pub id: Uuid,
pub user_id: Uuid,
pub provider: String,
pub provider_id: String,
pub email: Option<String>,
pub name: Option<String>,
pub avatar_url: Option<String>,
pub created_at: DateTime<Utc>,
}
/// A single audit log row, optionally scoped to a business user.
#[derive(Debug, Serialize, Deserialize, sqlx::FromRow)]
pub struct AuditLogEntry {
pub id: i64,
pub user_id: Option<Uuid>,
pub action: String,
pub folder: String,
#[serde(rename = "type")]
#[sqlx(rename = "type")]
pub entry_type: String,
pub name: String,
pub detail: Value,
pub created_at: DateTime<Utc>,
}
// ── TOML ↔ JSON value conversion ──────────────────────────────────────────────
/// Convert a serde_json Value to a toml Value.

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,397 @@
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 name: String,
pub folder: String,
#[serde(rename = "type")]
pub entry_type: String,
}
#[derive(Debug, serde::Serialize)]
pub struct DeleteResult {
pub deleted: Vec<DeletedEntry>,
pub dry_run: bool,
}
pub struct DeleteParams<'a> {
/// If set, delete a single entry by name.
pub name: Option<&'a str>,
/// Folder filter for bulk delete.
pub folder: Option<&'a str>,
/// Type filter for bulk delete.
pub entry_type: Option<&'a str>,
pub dry_run: bool,
pub user_id: Option<Uuid>,
}
pub async fn run(pool: &PgPool, params: DeleteParams<'_>) -> Result<DeleteResult> {
match params.name {
Some(name) => delete_one(pool, name, params.folder, params.dry_run, params.user_id).await,
None => {
if params.folder.is_none() && params.entry_type.is_none() {
anyhow::bail!(
"Bulk delete requires at least one of: name, folder, or type filter."
);
}
delete_bulk(
pool,
params.folder,
params.entry_type,
params.dry_run,
params.user_id,
)
.await
}
}
}
async fn delete_one(
pool: &PgPool,
name: &str,
folder: Option<&str>,
dry_run: bool,
user_id: Option<Uuid>,
) -> Result<DeleteResult> {
if dry_run {
// Dry-run uses the same disambiguation logic as actual delete:
// - 0 matches → nothing to delete
// - 1 match → show what would be deleted (with correct folder/type)
// - 2+ matches → disambiguation error (same as non-dry-run)
#[derive(sqlx::FromRow)]
struct DryRunRow {
folder: String,
#[sqlx(rename = "type")]
entry_type: String,
}
let rows: Vec<DryRunRow> = if let Some(uid) = user_id {
if let Some(f) = folder {
sqlx::query_as(
"SELECT folder, type FROM entries WHERE user_id = $1 AND folder = $2 AND name = $3",
)
.bind(uid)
.bind(f)
.bind(name)
.fetch_all(pool)
.await?
} else {
sqlx::query_as("SELECT folder, type FROM entries WHERE user_id = $1 AND name = $2")
.bind(uid)
.bind(name)
.fetch_all(pool)
.await?
}
} else if let Some(f) = folder {
sqlx::query_as(
"SELECT folder, type FROM entries WHERE user_id IS NULL AND folder = $1 AND name = $2",
)
.bind(f)
.bind(name)
.fetch_all(pool)
.await?
} else {
sqlx::query_as("SELECT folder, type FROM entries WHERE user_id IS NULL AND name = $1")
.bind(name)
.fetch_all(pool)
.await?
};
return match rows.len() {
0 => Ok(DeleteResult {
deleted: vec![],
dry_run: true,
}),
1 => {
let row = rows.into_iter().next().unwrap();
Ok(DeleteResult {
deleted: vec![DeletedEntry {
name: name.to_string(),
folder: row.folder,
entry_type: row.entry_type,
}],
dry_run: true,
})
}
_ => {
let folders: Vec<&str> = rows.iter().map(|r| r.folder.as_str()).collect();
anyhow::bail!(
"Ambiguous: {} entries named '{}' found in folders: [{}]. \
Specify 'folder' to disambiguate.",
rows.len(),
name,
folders.join(", ")
)
}
};
}
let mut tx = pool.begin().await?;
// Fetch matching rows with FOR UPDATE; use folder when provided to resolve ambiguity.
let rows: Vec<EntryRow> = if let Some(uid) = user_id {
if let Some(f) = folder {
sqlx::query_as(
"SELECT id, version, folder, type, tags, metadata, notes FROM entries \
WHERE user_id = $1 AND folder = $2 AND name = $3 FOR UPDATE",
)
.bind(uid)
.bind(f)
.bind(name)
.fetch_all(&mut *tx)
.await?
} else {
sqlx::query_as(
"SELECT id, version, folder, type, tags, metadata, notes FROM entries \
WHERE user_id = $1 AND name = $2 FOR UPDATE",
)
.bind(uid)
.bind(name)
.fetch_all(&mut *tx)
.await?
}
} else if let Some(f) = folder {
sqlx::query_as(
"SELECT id, version, folder, type, tags, metadata, notes FROM entries \
WHERE user_id IS NULL AND folder = $1 AND name = $2 FOR UPDATE",
)
.bind(f)
.bind(name)
.fetch_all(&mut *tx)
.await?
} else {
sqlx::query_as(
"SELECT id, version, folder, type, tags, metadata, notes FROM entries \
WHERE user_id IS NULL AND name = $1 FOR UPDATE",
)
.bind(name)
.fetch_all(&mut *tx)
.await?
};
let row = match rows.len() {
0 => {
tx.rollback().await?;
return Ok(DeleteResult {
deleted: vec![],
dry_run: false,
});
}
1 => rows.into_iter().next().unwrap(),
_ => {
tx.rollback().await?;
let folders: Vec<&str> = rows.iter().map(|r| r.folder.as_str()).collect();
anyhow::bail!(
"Ambiguous: {} entries named '{}' found in folders: [{}]. \
Specify 'folder' to disambiguate.",
rows.len(),
name,
folders.join(", ")
)
}
};
let folder = row.folder.clone();
let entry_type = row.entry_type.clone();
snapshot_and_delete(&mut tx, &folder, &entry_type, name, &row, user_id).await?;
crate::audit::log_tx(
&mut tx,
user_id,
"delete",
&folder,
&entry_type,
name,
json!({}),
)
.await;
tx.commit().await?;
Ok(DeleteResult {
deleted: vec![DeletedEntry {
name: name.to_string(),
folder,
entry_type,
}],
dry_run: false,
})
}
async fn delete_bulk(
pool: &PgPool,
folder: Option<&str>,
entry_type: Option<&str>,
dry_run: bool,
user_id: Option<Uuid>,
) -> Result<DeleteResult> {
#[derive(Debug, sqlx::FromRow)]
struct FullEntryRow {
id: Uuid,
version: i64,
folder: String,
#[sqlx(rename = "type")]
entry_type: String,
name: String,
metadata: serde_json::Value,
tags: Vec<String>,
notes: String,
}
let mut conditions: Vec<String> = Vec::new();
let mut idx: i32 = 1;
if user_id.is_some() {
conditions.push(format!("user_id = ${}", idx));
idx += 1;
} else {
conditions.push("user_id IS NULL".to_string());
}
if folder.is_some() {
conditions.push(format!("folder = ${}", idx));
idx += 1;
}
if entry_type.is_some() {
conditions.push(format!("type = ${}", idx));
}
let where_clause = format!("WHERE {}", conditions.join(" AND "));
let sql = format!(
"SELECT id, version, folder, type, name, metadata, tags, notes \
FROM entries {where_clause} ORDER BY type, name"
);
let mut q = sqlx::query_as::<_, FullEntryRow>(&sql);
if let Some(uid) = user_id {
q = q.bind(uid);
}
if let Some(f) = folder {
q = q.bind(f);
}
if let Some(t) = entry_type {
q = q.bind(t);
}
let rows = q.fetch_all(pool).await?;
if dry_run {
let deleted = rows
.iter()
.map(|r| DeletedEntry {
name: r.name.clone(),
folder: r.folder.clone(),
entry_type: r.entry_type.clone(),
})
.collect();
return Ok(DeleteResult {
deleted,
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,
folder: row.folder.clone(),
entry_type: row.entry_type.clone(),
tags: row.tags.clone(),
metadata: row.metadata.clone(),
notes: row.notes.clone(),
};
let mut tx = pool.begin().await?;
snapshot_and_delete(
&mut tx,
&row.folder,
&row.entry_type,
&row.name,
&entry_row,
user_id,
)
.await?;
crate::audit::log_tx(
&mut tx,
user_id,
"delete",
&row.folder,
&row.entry_type,
&row.name,
json!({"bulk": true}),
)
.await;
tx.commit().await?;
deleted.push(DeletedEntry {
name: row.name.clone(),
folder: row.folder.clone(),
entry_type: row.entry_type.clone(),
});
}
Ok(DeleteResult {
deleted,
dry_run: false,
})
}
async fn snapshot_and_delete(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
folder: &str,
entry_type: &str,
name: &str,
row: &EntryRow,
user_id: Option<Uuid>,
) -> Result<()> {
if let Err(e) = db::snapshot_entry_history(
tx,
db::EntrySnapshotParams {
entry_id: row.id,
user_id,
folder,
entry_type,
name,
version: row.version,
action: "delete",
tags: &row.tags,
metadata: &row.metadata,
},
)
.await
{
tracing::warn!(error = %e, "failed to snapshot entry history before delete");
}
let fields: Vec<SecretFieldRow> =
sqlx::query_as("SELECT id, field_name, encrypted FROM secrets WHERE entry_id = $1")
.bind(row.id)
.fetch_all(&mut **tx)
.await?;
for f in &fields {
if let Err(e) = db::snapshot_secret_history(
tx,
db::SecretSnapshotParams {
entry_id: row.id,
secret_id: f.id,
entry_version: row.version,
field_name: &f.field_name,
encrypted: &f.encrypted,
action: "delete",
},
)
.await
{
tracing::warn!(error = %e, "failed to snapshot secret history before delete");
}
}
sqlx::query("DELETE FROM entries WHERE id = $1")
.bind(row.id)
.execute(&mut **tx)
.await?;
Ok(())
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,308 @@
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 name: String,
pub folder: String,
#[serde(rename = "type")]
pub entry_type: String,
pub add_tags: Vec<String>,
pub remove_tags: Vec<String>,
pub meta_keys: Vec<String>,
pub remove_meta: Vec<String>,
pub secret_keys: Vec<String>,
pub remove_secrets: Vec<String>,
}
pub struct UpdateParams<'a> {
pub name: &'a str,
/// Optional folder for disambiguation when multiple entries share the same name.
pub folder: Option<&'a str>,
pub notes: Option<&'a str>,
pub add_tags: &'a [String],
pub remove_tags: &'a [String],
pub meta_entries: &'a [String],
pub remove_meta: &'a [String],
pub secret_entries: &'a [String],
pub remove_secrets: &'a [String],
pub user_id: Option<Uuid>,
}
pub async fn run(
pool: &PgPool,
params: UpdateParams<'_>,
master_key: &[u8; 32],
) -> Result<UpdateResult> {
let mut tx = pool.begin().await?;
// Fetch matching rows with FOR UPDATE; use folder when provided to resolve ambiguity.
let rows: Vec<EntryRow> = if let Some(uid) = params.user_id {
if let Some(folder) = params.folder {
sqlx::query_as(
"SELECT id, version, folder, type, tags, metadata, notes FROM entries \
WHERE user_id = $1 AND folder = $2 AND name = $3 FOR UPDATE",
)
.bind(uid)
.bind(folder)
.bind(params.name)
.fetch_all(&mut *tx)
.await?
} else {
sqlx::query_as(
"SELECT id, version, folder, type, tags, metadata, notes FROM entries \
WHERE user_id = $1 AND name = $2 FOR UPDATE",
)
.bind(uid)
.bind(params.name)
.fetch_all(&mut *tx)
.await?
}
} else if let Some(folder) = params.folder {
sqlx::query_as(
"SELECT id, version, folder, type, tags, metadata, notes FROM entries \
WHERE user_id IS NULL AND folder = $1 AND name = $2 FOR UPDATE",
)
.bind(folder)
.bind(params.name)
.fetch_all(&mut *tx)
.await?
} else {
sqlx::query_as(
"SELECT id, version, folder, type, tags, metadata, notes FROM entries \
WHERE user_id IS NULL AND name = $1 FOR UPDATE",
)
.bind(params.name)
.fetch_all(&mut *tx)
.await?
};
let row = match rows.len() {
0 => {
tx.rollback().await?;
anyhow::bail!(
"Not found: '{}'. Use `add` to create it first.",
params.name
)
}
1 => rows.into_iter().next().unwrap(),
_ => {
tx.rollback().await?;
let folders: Vec<&str> = rows.iter().map(|r| r.folder.as_str()).collect();
anyhow::bail!(
"Ambiguous: {} entries named '{}' found in folders: [{}]. \
Specify 'folder' to disambiguate.",
rows.len(),
params.name,
folders.join(", ")
)
}
};
if let Err(e) = db::snapshot_entry_history(
&mut tx,
db::EntrySnapshotParams {
entry_id: row.id,
user_id: params.user_id,
folder: &row.folder,
entry_type: &row.entry_type,
name: params.name,
version: row.version,
action: "update",
tags: &row.tags,
metadata: &row.metadata,
},
)
.await
{
tracing::warn!(error = %e, "failed to snapshot entry history before update");
}
let mut tags: Vec<String> = row.tags.clone();
for t in params.add_tags {
if !tags.contains(t) {
tags.push(t.clone());
}
}
tags.retain(|t| !params.remove_tags.contains(t));
let mut meta_map: Map<String, Value> = match row.metadata.clone() {
Value::Object(m) => m,
_ => Map::new(),
};
for entry in params.meta_entries {
let (path, value) = parse_kv(entry)?;
insert_path(&mut meta_map, &path, value)?;
}
for key in params.remove_meta {
let path = parse_key_path(key)?;
remove_path(&mut meta_map, &path)?;
}
let metadata = Value::Object(meta_map);
let new_notes = params.notes.unwrap_or(&row.notes);
let result = sqlx::query(
"UPDATE entries SET tags = $1, metadata = $2, notes = $3, \
version = version + 1, updated_at = NOW() \
WHERE id = $4 AND version = $5",
)
.bind(&tags)
.bind(&metadata)
.bind(new_notes)
.bind(row.id)
.bind(row.version)
.execute(&mut *tx)
.await?;
if result.rows_affected() == 0 {
tx.rollback().await?;
anyhow::bail!(
"Concurrent modification detected for '{}'. Please retry.",
params.name
);
}
let new_version = row.version + 1;
for entry in params.secret_entries {
let (path, field_value) = parse_kv(entry)?;
let flat = flatten_json_fields("", &{
let mut m = Map::new();
insert_path(&mut m, &path, field_value)?;
Value::Object(m)
});
for (field_name, fv) in &flat {
let encrypted = crypto::encrypt_json(master_key, fv)?;
#[derive(sqlx::FromRow)]
struct ExistingField {
id: Uuid,
encrypted: Vec<u8>,
}
let ef: Option<ExistingField> = sqlx::query_as(
"SELECT id, encrypted FROM secrets WHERE entry_id = $1 AND field_name = $2",
)
.bind(row.id)
.bind(field_name)
.fetch_optional(&mut *tx)
.await?;
if let Some(ef) = &ef
&& let Err(e) = db::snapshot_secret_history(
&mut tx,
db::SecretSnapshotParams {
entry_id: row.id,
secret_id: ef.id,
entry_version: row.version,
field_name,
encrypted: &ef.encrypted,
action: "update",
},
)
.await
{
tracing::warn!(error = %e, "failed to snapshot secret field history");
}
sqlx::query(
"INSERT INTO secrets (entry_id, field_name, encrypted) VALUES ($1, $2, $3) \
ON CONFLICT (entry_id, field_name) DO UPDATE SET \
encrypted = EXCLUDED.encrypted, version = secrets.version + 1, updated_at = NOW()",
)
.bind(row.id)
.bind(field_name)
.bind(&encrypted)
.execute(&mut *tx)
.await?;
}
}
for key in params.remove_secrets {
let path = parse_key_path(key)?;
let field_name = path.join(".");
#[derive(sqlx::FromRow)]
struct FieldToDelete {
id: Uuid,
encrypted: Vec<u8>,
}
let field: Option<FieldToDelete> = sqlx::query_as(
"SELECT id, encrypted FROM secrets WHERE entry_id = $1 AND field_name = $2",
)
.bind(row.id)
.bind(&field_name)
.fetch_optional(&mut *tx)
.await?;
if let Some(f) = field {
if let Err(e) = db::snapshot_secret_history(
&mut tx,
db::SecretSnapshotParams {
entry_id: row.id,
secret_id: f.id,
entry_version: new_version,
field_name: &field_name,
encrypted: &f.encrypted,
action: "delete",
},
)
.await
{
tracing::warn!(error = %e, "failed to snapshot secret field history before delete");
}
sqlx::query("DELETE FROM secrets WHERE id = $1")
.bind(f.id)
.execute(&mut *tx)
.await?;
}
}
let meta_keys = collect_key_paths(params.meta_entries)?;
let remove_meta_keys = collect_field_paths(params.remove_meta)?;
let secret_keys = collect_key_paths(params.secret_entries)?;
let remove_secret_keys = collect_field_paths(params.remove_secrets)?;
crate::audit::log_tx(
&mut tx,
params.user_id,
"update",
"",
"",
params.name,
serde_json::json!({
"add_tags": params.add_tags,
"remove_tags": params.remove_tags,
"meta_keys": meta_keys,
"remove_meta": remove_meta_keys,
"secret_keys": secret_keys,
"remove_secrets": remove_secret_keys,
}),
)
.await;
tx.commit().await?;
Ok(UpdateResult {
name: params.name.to_string(),
folder: row.folder.clone(),
entry_type: row.entry_type.clone(),
add_tags: params.add_tags.to_vec(),
remove_tags: params.remove_tags.to_vec(),
meta_keys,
remove_meta: remove_meta_keys,
secret_keys,
remove_secrets: remove_secret_keys,
})
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,797 @@
use askama::Template;
use chrono::SecondsFormat;
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,
base_url: String,
version: &'static str,
}
#[derive(Template)]
#[template(path = "home.html")]
struct HomeTemplate {
is_logged_in: bool,
base_url: String,
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 {
/// RFC3339 UTC for `<time datetime>`; rendered as browser-local in audit.html.
created_at_iso: 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> {
match session.get::<String>(SESSION_USER_ID).await {
Ok(opt) => match opt {
Some(s) => match Uuid::parse_str(&s) {
Ok(id) => Some(id),
Err(e) => {
tracing::warn!(error = %e, user_id_str = %s, "invalid user_id UUID in session");
None
}
},
None => None,
},
Err(e) => {
tracing::warn!(error = %e, "failed to read user_id from session");
None
}
}
}
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("/robots.txt", get(robots_txt))
.route("/llms.txt", get(llms_txt))
.route("/ai.txt", get(ai_txt))
.route("/favicon.svg", get(favicon_svg))
.route(
"/favicon.ico",
get(|| async { Redirect::permanent("/favicon.svg") }),
)
.route(
"/.well-known/oauth-protected-resource",
get(oauth_protected_resource_metadata),
)
.route("/", get(home_page))
.route("/login", 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))
}
fn text_asset_response(content: &'static str, content_type: &'static str) -> Response {
Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, content_type)
.header(header::CACHE_CONTROL, "public, max-age=86400")
.body(Body::from(content))
.expect("text asset response")
}
async fn robots_txt() -> Response {
text_asset_response(
include_str!("../static/robots.txt"),
"text/plain; charset=utf-8",
)
}
async fn llms_txt() -> Response {
text_asset_response(
include_str!("../static/llms.txt"),
"text/markdown; charset=utf-8",
)
}
async fn ai_txt() -> Response {
llms_txt().await
}
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")
}
// ── Home page (public) ───────────────────────────────────────────────────────
async fn home_page(
State(state): State<AppState>,
session: Session,
) -> Result<Response, StatusCode> {
let is_logged_in = current_user_id(&session).await.is_some();
let tmpl = HomeTemplate {
is_logged_in,
base_url: state.base_url.clone(),
version: env!("CARGO_PKG_VERSION"),
};
render_template(tmpl)
}
// ── 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(),
base_url: state.base_url.clone(),
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(|e| {
tracing::error!(error = %e, "failed to insert oauth_state into session");
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("/login?error=oauth_error").into_response());
}
let Some(code) = params.code else {
tracing::warn!(provider, "OAuth callback missing code");
return Ok(Redirect::to("/login?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("/login?error=oauth_missing_state").into_response());
};
let expected_state: Option<String> = session.get(SESSION_OAUTH_STATE).await.map_err(|e| {
tracing::error!(provider, error = %e, "failed to read oauth_state from session");
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("/login?error=oauth_state").into_response());
}
if let Err(e) = session.remove::<String>(SESSION_OAUTH_STATE).await {
tracing::warn!(provider, error = %e, "failed to remove oauth_state from session");
}
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 = match session.get::<bool>(SESSION_OAUTH_BIND_MODE).await {
Ok(v) => v.unwrap_or(false),
Err(e) => {
tracing::error!(
provider,
error = %e,
"failed to read oauth_bind_mode from session"
);
return Err(StatusCode::INTERNAL_SERVER_ERROR);
}
};
if bind_mode {
let user_id = current_user_id(session)
.await
.ok_or(StatusCode::UNAUTHORIZED)?;
if let Err(e) = session.remove::<bool>(SESSION_OAUTH_BIND_MODE).await {
tracing::warn!(provider, error = %e, "failed to remove oauth_bind_mode from session after bind");
}
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
})?;
session
.insert(SESSION_USER_ID, user.id.to_string())
.await
.map_err(|e| {
tracing::error!(
error = %e,
user_id = %user.id,
"failed to insert user_id into session after OAuth"
);
StatusCode::INTERNAL_SERVER_ERROR
})?;
session
.insert(SESSION_LOGIN_PROVIDER, &provider)
.await
.map_err(|e| {
tracing::error!(
provider,
error = %e,
"failed to insert login_provider into session after OAuth"
);
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 {
if let Err(e) = session.flush().await {
tracing::warn!(error = %e, "failed to flush session on logout");
}
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("/login").into_response());
};
let user = match get_user_by_id(&state.pool, user_id).await.map_err(|e| {
tracing::error!(error = %e, %user_id, "failed to load user for dashboard");
StatusCode::INTERNAL_SERVER_ERROR
})? {
Some(u) => u,
None => return Ok(Redirect::to("/login").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("/login").into_response());
};
let user = match get_user_by_id(&state.pool, user_id).await.map_err(|e| {
tracing::error!(error = %e, %user_id, "failed to load user for audit page");
StatusCode::INTERNAL_SERVER_ERROR
})? {
Some(u) => u,
None => return Ok(Redirect::to("/login").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_iso: row.created_at.to_rfc3339_opts(SecondsFormat::Secs, true),
action: row.action,
target: format_audit_target(&row.folder, &row.entry_type, &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(|e| {
tracing::error!(error = %e, "failed to insert oauth_bind_mode into session");
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();
if let Err(e) = session.insert(SESSION_OAUTH_STATE, &st).await {
tracing::error!(error = %e, "failed to insert oauth_state for account bind flow");
if let Err(rm) = session.remove::<bool>(SESSION_OAUTH_BIND_MODE).await {
tracing::warn!(error = %rm, "failed to roll back oauth_bind_mode after oauth_state insert failure");
}
return Err(StatusCode::INTERNAL_SERVER_ERROR);
}
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(|e| {
tracing::error!(error = %e, "failed to read login_provider from session");
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(|e| {
tracing::error!(error = %e, %user_id, "failed to load user for key-salt API");
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(|e| {
tracing::warn!(error = %e, "invalid hex in key-setup salt");
StatusCode::BAD_REQUEST
})?;
let key_check = hex::decode_hex(&body.key_check).map_err(|e| {
tracing::warn!(error = %e, "invalid hex in key-setup key_check");
StatusCode::BAD_REQUEST
})?;
if salt.len() != 32 {
tracing::warn!(salt_len = salt.len(), "key-setup salt must be 32 bytes");
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(|e| {
tracing::error!(error = %e, %user_id, "ensure_api_key failed");
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(|e| {
tracing::error!(error = %e, %user_id, "regenerate_api_key failed");
StatusCode::INTERNAL_SERVER_ERROR
})?;
Ok(Json(ApiKeyResponse { api_key }))
}
// ── OAuth / Well-known ────────────────────────────────────────────────────────
/// RFC 9728 — OAuth 2.0 Protected Resource Metadata.
///
/// Advertises that this server accepts Bearer tokens in the `Authorization`
/// header. We deliberately omit `authorization_servers` because this service
/// issues its own API keys (no external OAuth AS is involved). MCP clients
/// that probe this endpoint will see the resource identifier and stop looking
/// for a delegated OAuth flow.
async fn oauth_protected_resource_metadata(State(state): State<AppState>) -> impl IntoResponse {
let body = serde_json::json!({
"resource": state.base_url,
"bearer_methods_supported": ["header"],
"resource_documentation": format!("{}/dashboard", state.base_url),
});
(
StatusCode::OK,
[(header::CONTENT_TYPE, "application/json")],
axum::Json(body),
)
}
// ── 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(folder: &str, entry_type: &str, name: &str) -> String {
// Auth events (folder="auth") use entry_type/name as provider-scoped target.
if folder == "auth" {
format!("{}/{}", entry_type, name)
} else if !folder.is_empty() && !entry_type.is_empty() {
format!("[{}/{}] {}", folder, entry_type, name)
} else if !folder.is_empty() {
format!("[{}] {}", folder, name)
} else {
name.to_string()
}
}

View File

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

After

Width:  |  Height:  |  Size: 212 B

View File

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

View File

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

View File

@@ -0,0 +1,154 @@
<!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"><time class="audit-local-time" datetime="{{ entry.created_at_iso }}">{{ entry.created_at_iso }}</time></td>
<td class="col-action mono">{{ entry.action }}</td>
<td class="col-target mono">{{ entry.target }}</td>
<td class="col-detail"><pre class="detail">{{ entry.detail }}</pre></td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
</section>
</main>
</div>
</div>
<script>
(function () {
document.querySelectorAll('time.audit-local-time[datetime]').forEach(function (el) {
var raw = el.getAttribute('datetime');
var d = raw ? new Date(raw) : null;
if (d && !isNaN(d.getTime())) {
el.textContent = d.toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'medium' });
el.title = raw + ' (UTC)';
}
});
})();
</script>
</body>
</html>

View File

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

View File

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

31
deploy/.env.example Normal file
View File

@@ -0,0 +1,31 @@
# Secrets MCP Server 环境变量配置
# 复制此文件为 .env 并填写真实值
# ─── 数据库 ───────────────────────────────────────────────────────────
# Web 会话tower-sessions与业务数据共用此库启动时会自动 migrate 会话表,无需额外环境变量。
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=
# ─── 日志(可选)──────────────────────────────────────────────────────
# RUST_LOG=secrets_mcp=debug
# ─── 注意 ─────────────────────────────────────────────────────────────
# 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"]

View File

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

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

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

View File

@@ -5,19 +5,20 @@ set -euo pipefail
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$repo_root"
version="$(grep -m1 '^version' Cargo.toml | sed 's/.*"\(.*\)".*/\1/')"
tag="secrets-${version}"
version="$(grep -m1 '^version' crates/secrets-mcp/Cargo.toml | sed 's/.*"\(.*\)".*/\1/')"
tag="secrets-mcp-${version}"
echo "==> 当前版本: ${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 Cargo.toml 中的 version再执行 cargo build 同步 Cargo.lock。"
exit 1
echo "提示: 已存在 tag ${tag},将按重复构建处理,不阻断检查。"
echo "如需创建新的发布版本,请先 bump crates/secrets-mcp/Cargo.toml 中的 version。"
else
echo "==> 未发现重复 tag将创建新版本"
fi
echo "==> 未发现重复 tag开始执行检查"
echo "==> 开始执行检查"
cargo fmt -- --check
cargo clippy --locked -- -D warnings
cargo test --locked

View File

@@ -4,17 +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
# - Secret/Variable 的 data/value 字段需传入原始值,不要使用 base64 编码
# - 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
@@ -23,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"
@@ -52,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
# 优先使用环境变量
@@ -86,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
@@ -109,8 +110,7 @@ echo "配置 Gitea Actions: $OWNER/$REPO"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo ""
# 1. 创建 Secret: RELEASE_TOKEN
# 注意: Gitea Actions API 的 data 字段需传入原始值,不要使用 base64 编码
# 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 \
@@ -129,8 +129,7 @@ else
exit 1
fi
# 2. 创建/更新 Variable: WEBHOOK_URL可选
# 注意: Secret 和 Variable 均使用原始值,不要 base64 编码
# 2. 创建/更新 Variable: WEBHOOK_URL可选value 为原始 URL 字符串,勿 base64
WEBHOOK_VALUE="${WEBHOOK_URL:-$GITEA_WEBHOOK_URL}"
if [[ -n "$WEBHOOK_VALUE" ]]; then
echo ""
@@ -168,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 "✓ 配置完成"
@@ -176,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,37 +0,0 @@
use serde_json::Value;
use sqlx::{Postgres, Transaction};
/// 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()
}
/// Write an audit entry within an existing transaction.
pub async fn log_tx(
tx: &mut Transaction<'_, Postgres>,
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 (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(&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");
}
}

View File

@@ -1,459 +0,0 @@
use anyhow::Result;
use serde_json::{Map, Value, json};
use sqlx::PgPool;
use std::fs;
use crate::crypto;
use crate::db;
use crate::models::EntryRow;
use crate::output::{OutputMode, print_json};
// ── Key/value parsing helpers (shared with update.rs) ───────────────────────
/// Parse secret / metadata entries into a nested key path and JSON value.
/// - `key=value` → stores the literal string `value`
/// - `key:=<json>` → parses `<json>` as a typed JSON value
/// - `key=@file` → reads the file content as a string
/// - `a:b=value` → writes nested fields: `{ "a": { "b": "value" } }`
/// - `a:b@./file.txt` → shorthand for nested file reads without manual JSON escaping
pub(crate) fn parse_kv(entry: &str) -> Result<(Vec<String>, Value)> {
// Typed JSON form: key:=<json>
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));
}
// Plain string form: key=value or key=@file
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)));
}
// Shorthand file form: nested:key@file
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(crate) 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(crate) fn key_path_to_string(path: &[String]) -> String {
path.join(":")
}
pub(crate) 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(crate) 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(crate) 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(crate) 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(crate) 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)
}
/// Flatten a (potentially nested) JSON object into dot-separated field entries.
/// e.g. `{"credentials": {"type": "ssh", "content": "..."}}` →
/// `[("credentials.type", "ssh"), ("credentials.content", "...")]`
/// Top-level non-object values are emitted directly.
pub(crate) 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())],
}
}
// ── Add command ──────────────────────────────────────────────────────────────
pub struct AddArgs<'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],
pub output: OutputMode,
}
pub async fn run(pool: &PgPool, args: AddArgs<'_>, master_key: &[u8; 32]) -> Result<()> {
let metadata = build_json(args.meta_entries)?;
let secret_json = build_json(args.secret_entries)?;
tracing::debug!(args.namespace, args.kind, args.name, "upserting entry");
let meta_keys = collect_key_paths(args.meta_entries)?;
let secret_keys = collect_key_paths(args.secret_entries)?;
let mut tx = pool.begin().await?;
// Upsert the entry row (tags + metadata).
let existing: Option<EntryRow> = sqlx::query_as(
"SELECT id, version, tags, metadata FROM entries \
WHERE namespace = $1 AND kind = $2 AND name = $3",
)
.bind(args.namespace)
.bind(args.kind)
.bind(args.name)
.fetch_optional(&mut *tx)
.await?;
// Snapshot the current entry state before overwriting.
if let Some(ref ex) = existing
&& let Err(e) = db::snapshot_entry_history(
&mut tx,
db::EntrySnapshotParams {
entry_id: ex.id,
namespace: args.namespace,
kind: args.kind,
name: args.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::Uuid = 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)
DO UPDATE SET
tags = EXCLUDED.tags,
metadata = EXCLUDED.metadata,
version = entries.version + 1,
updated_at = NOW()
RETURNING id
"#,
)
.bind(args.namespace)
.bind(args.kind)
.bind(args.name)
.bind(args.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?;
// Snapshot existing secret fields before replacing.
if existing.is_some() {
#[derive(sqlx::FromRow)]
struct ExistingField {
id: uuid::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");
}
}
// Delete existing secret fields so we can re-insert the full set.
sqlx::query("DELETE FROM secrets WHERE entry_id = $1")
.bind(entry_id)
.execute(&mut *tx)
.await?;
}
// Insert new secret fields.
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,
"add",
args.namespace,
args.kind,
args.name,
json!({
"tags": args.tags,
"meta_keys": meta_keys,
"secret_keys": secret_keys,
}),
)
.await;
tx.commit().await?;
let result_json = json!({
"action": "added",
"namespace": args.namespace,
"kind": args.kind,
"name": args.name,
"tags": args.tags,
"meta_keys": meta_keys,
"secret_keys": secret_keys,
});
match args.output {
OutputMode::Json | OutputMode::JsonCompact => {
print_json(&result_json, &args.output)?;
}
_ => {
println!("Added: [{}/{}] {}", args.namespace, args.kind, args.name);
if !args.tags.is_empty() {
println!(" tags: {}", args.tags.join(", "));
}
if !args.meta_entries.is_empty() {
println!(" metadata: {}", meta_keys.join(", "));
}
if !args.secret_entries.is_empty() {
println!(" secrets: {}", secret_keys.join(", "));
}
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::{build_json, flatten_json_fields, key_path_to_string, parse_kv, remove_path};
use serde_json::Value;
use std::fs;
use std::path::PathBuf;
use std::time::{SystemTime, UNIX_EPOCH};
fn temp_file_path(name: &str) -> PathBuf {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("clock should be after unix epoch")
.as_nanos();
std::env::temp_dir().join(format!("secrets-{name}-{nanos}.txt"))
}
#[test]
fn parse_nested_file_shorthand() {
let path = temp_file_path("ssh-key");
fs::write(&path, "line1\nline2\n").expect("should write temp file");
let entry = format!("credentials:content@{}", path.display());
let (path_parts, value) = parse_kv(&entry).expect("should parse nested file shorthand");
assert_eq!(key_path_to_string(&path_parts), "credentials:content");
assert_eq!(value, serde_json::Value::String("line1\nline2\n".into()));
fs::remove_file(path).expect("should remove temp file");
}
#[test]
fn build_nested_json_from_mixed_entries() {
let payload = vec![
"credentials:type=ssh".to_string(),
"credentials:enabled:=true".to_string(),
"username=root".to_string(),
];
let value = build_json(&payload).expect("should build nested json");
assert_eq!(
value,
serde_json::json!({
"credentials": {
"type": "ssh",
"enabled": true
},
"username": "root"
})
);
}
#[test]
fn remove_nested_path_prunes_empty_parents() {
let mut value = serde_json::json!({
"credentials": {
"content": "pem-data"
},
"username": "root"
});
let map = match &mut value {
Value::Object(map) => map,
_ => panic!("expected object"),
};
let removed = remove_path(map, &["credentials".to_string(), "content".to_string()])
.expect("should remove nested field");
assert!(removed);
assert_eq!(value, serde_json::json!({ "username": "root" }));
}
#[test]
fn flatten_json_fields_nested() {
let v = serde_json::json!({
"username": "root",
"credentials": {
"type": "ssh",
"content": "pem-data"
}
});
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

@@ -1,55 +0,0 @@
use crate::config::{self, Config, config_path};
use anyhow::Result;
pub async fn run(action: crate::ConfigAction) -> Result<()> {
match action {
crate::ConfigAction::SetDb { url } => {
// Verify connection before writing config
let pool = crate::db::create_pool(&url)
.await
.map_err(|e| anyhow::anyhow!("Database connection failed: {}", e))?;
drop(pool);
println!("Database connection successful.");
let cfg = Config {
database_url: Some(url.clone()),
};
config::save_config(&cfg)?;
println!("Database URL saved to: {}", config_path()?.display());
println!(" {}", mask_password(&url));
}
crate::ConfigAction::Show => {
let cfg = config::load_config()?;
match cfg.database_url {
Some(url) => {
println!("database_url = {}", mask_password(&url));
println!("config file: {}", config_path()?.display());
}
None => {
println!("Database URL not configured.");
println!("Run: secrets config set-db <DATABASE_URL>");
}
}
}
crate::ConfigAction::Path => {
println!("{}", config_path()?.display());
}
}
Ok(())
}
/// Mask the password in a postgres://user:password@host/db URL.
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,291 +0,0 @@
use anyhow::Result;
use serde_json::json;
use sqlx::PgPool;
use uuid::Uuid;
use crate::db;
use crate::models::{EntryRow, SecretFieldRow};
use crate::output::{OutputMode, print_json};
pub struct DeleteArgs<'a> {
pub namespace: &'a str,
/// Kind filter. Required when --name is given; optional for bulk deletes.
pub kind: Option<&'a str>,
/// Exact record name. When None, bulk-delete all matching records.
pub name: Option<&'a str>,
/// Preview without writing to the database (bulk mode only).
pub dry_run: bool,
pub output: OutputMode,
}
// ── Internal row type used for bulk queries ────────────────────────────────
#[derive(Debug, sqlx::FromRow)]
struct FullEntryRow {
pub id: Uuid,
pub version: i64,
pub kind: String,
pub name: String,
pub metadata: serde_json::Value,
pub tags: Vec<String>,
}
// ── Entry point ────────────────────────────────────────────────────────────
pub async fn run(pool: &PgPool, args: DeleteArgs<'_>) -> Result<()> {
match args.name {
Some(name) => {
let kind = args
.kind
.ok_or_else(|| anyhow::anyhow!("--kind is required when --name is specified"))?;
delete_one(pool, args.namespace, kind, name, args.output).await
}
None => delete_bulk(pool, args.namespace, args.kind, args.dry_run, args.output).await,
}
}
// ── Single-record delete (original behaviour) ─────────────────────────────
async fn delete_one(
pool: &PgPool,
namespace: &str,
kind: &str,
name: &str,
output: OutputMode,
) -> Result<()> {
tracing::debug!(namespace, kind, name, "deleting entry");
let mut tx = pool.begin().await?;
let row: Option<EntryRow> = sqlx::query_as(
"SELECT id, version, tags, metadata FROM entries \
WHERE 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?;
tracing::warn!(namespace, kind, name, "entry not found for deletion");
let v = json!({"action":"not_found","namespace":namespace,"kind":kind,"name":name});
match output {
OutputMode::Text => println!("Not found: [{}/{}] {}", namespace, kind, name),
ref mode => print_json(&v, mode)?,
}
return Ok(());
};
snapshot_and_delete(&mut tx, namespace, kind, name, &row).await?;
crate::audit::log_tx(&mut tx, "delete", namespace, kind, name, json!({})).await;
tx.commit().await?;
let v = json!({"action":"deleted","namespace":namespace,"kind":kind,"name":name});
match output {
OutputMode::Text => println!("Deleted: [{}/{}] {}", namespace, kind, name),
ref mode => print_json(&v, mode)?,
}
Ok(())
}
// ── Bulk delete by namespace (+ optional kind filter) ─────────────────────
async fn delete_bulk(
pool: &PgPool,
namespace: &str,
kind: Option<&str>,
dry_run: bool,
output: OutputMode,
) -> Result<()> {
tracing::debug!(namespace, ?kind, dry_run, "bulk-deleting entries");
let rows: Vec<FullEntryRow> = if let Some(k) = kind {
sqlx::query_as(
"SELECT id, version, kind, name, metadata, tags FROM entries \
WHERE namespace = $1 AND kind = $2 \
ORDER BY name",
)
.bind(namespace)
.bind(k)
.fetch_all(pool)
.await?
} else {
sqlx::query_as(
"SELECT id, version, kind, name, metadata, tags FROM entries \
WHERE namespace = $1 \
ORDER BY kind, name",
)
.bind(namespace)
.fetch_all(pool)
.await?
};
if rows.is_empty() {
let v = json!({
"action": "noop",
"namespace": namespace,
"kind": kind,
"deleted": 0,
"dry_run": dry_run
});
match output {
OutputMode::Text => println!(
"No records found in namespace \"{}\"{}.",
namespace,
kind.map(|k| format!(" with kind \"{}\"", k))
.unwrap_or_default()
),
ref mode => print_json(&v, mode)?,
}
return Ok(());
}
if dry_run {
let count = rows.len();
match output {
OutputMode::Text => {
println!(
"dry-run: would delete {} record(s) in namespace \"{}\":",
count, namespace
);
for r in &rows {
println!(" [{}/{}] {}", namespace, r.kind, r.name);
}
}
ref mode => {
let items: Vec<_> = rows
.iter()
.map(|r| json!({"namespace": namespace, "kind": r.kind, "name": r.name}))
.collect();
print_json(
&json!({
"action": "dry_run",
"namespace": namespace,
"kind": kind,
"would_delete": count,
"entries": items
}),
mode,
)?;
}
}
return Ok(());
}
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).await?;
crate::audit::log_tx(
&mut tx,
"delete",
namespace,
&row.kind,
&row.name,
json!({"bulk": true}),
)
.await;
tx.commit().await?;
deleted.push(json!({"namespace": namespace, "kind": row.kind, "name": row.name}));
tracing::info!(namespace, kind = %row.kind, name = %row.name, "bulk deleted");
}
let count = deleted.len();
match output {
OutputMode::Text => {
for item in &deleted {
println!(
"Deleted: [{}/{}] {}",
item["namespace"].as_str().unwrap_or(""),
item["kind"].as_str().unwrap_or(""),
item["name"].as_str().unwrap_or("")
);
}
println!("Total: {} record(s) deleted.", count);
}
ref mode => print_json(
&json!({
"action": "deleted",
"namespace": namespace,
"kind": kind,
"deleted": count,
"entries": deleted
}),
mode,
)?,
}
Ok(())
}
// ── Shared helper: snapshot history then DELETE ────────────────────────────
async fn snapshot_and_delete(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
namespace: &str,
kind: &str,
name: &str,
row: &EntryRow,
) -> Result<()> {
if let Err(e) = db::snapshot_entry_history(
tx,
db::EntrySnapshotParams {
entry_id: row.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

@@ -1,109 +0,0 @@
use anyhow::Result;
use sqlx::PgPool;
use std::collections::BTreeMap;
use std::io::Write;
use crate::commands::search::{fetch_entries, fetch_secrets_for_entries};
use crate::crypto;
use crate::models::{ExportData, ExportEntry, ExportFormat};
pub struct ExportArgs<'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>,
/// Output file path. None means write to stdout.
pub file: Option<&'a str>,
/// Explicit format override (e.g. from --format flag).
pub format: Option<&'a str>,
/// When true, secrets are omitted and master_key is not used.
pub no_secrets: bool,
}
pub async fn run(pool: &PgPool, args: ExportArgs<'_>, master_key: Option<&[u8; 32]>) -> Result<()> {
// Determine output format: --format > file extension > default JSON.
let format = if let Some(fmt_str) = args.format {
ExportFormat::from_str(fmt_str)?
} else if let Some(path) = args.file {
ExportFormat::from_extension(path).unwrap_or(ExportFormat::Json)
} else {
ExportFormat::Json
};
let entries = fetch_entries(
pool,
args.namespace,
args.kind,
args.name,
args.tags,
args.query,
)
.await?;
let entry_ids: Vec<uuid::Uuid> = entries.iter().map(|e| e.id).collect();
let secrets_map = if !args.no_secrets && !entry_ids.is_empty() {
fetch_secrets_for_entries(pool, &entry_ids).await?
} else {
std::collections::HashMap::new()
};
let key = if !args.no_secrets { master_key } else { None };
let mut export_entries: Vec<ExportEntry> = Vec::with_capacity(entries.len());
for entry in &entries {
let secrets = if args.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 =
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,
});
}
let data = ExportData {
version: 1,
exported_at: chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string(),
entries: export_entries,
};
let serialized = format.serialize(&data)?;
if let Some(path) = args.file {
std::fs::write(path, &serialized)?;
println!(
"Exported {} record(s) to {} ({:?})",
data.entries.len(),
path,
format
);
} else {
std::io::stdout().write_all(serialized.as_bytes())?;
// Ensure trailing newline on stdout.
if !serialized.ends_with('\n') {
println!();
}
}
Ok(())
}

View File

@@ -1,78 +0,0 @@
use anyhow::Result;
use serde_json::{Value, json};
use sqlx::{FromRow, PgPool};
use crate::output::{OutputMode, format_local_time, print_json};
pub struct HistoryArgs<'a> {
pub namespace: &'a str,
pub kind: &'a str,
pub name: &'a str,
pub limit: u32,
pub output: OutputMode,
}
/// List history entries for an entry.
pub async fn run(pool: &PgPool, args: HistoryArgs<'_>) -> Result<()> {
#[derive(FromRow)]
struct HistorySummary {
version: i64,
action: String,
actor: String,
created_at: chrono::DateTime<chrono::Utc>,
}
let rows: Vec<HistorySummary> = sqlx::query_as(
"SELECT version, action, actor, created_at FROM entries_history \
WHERE namespace = $1 AND kind = $2 AND name = $3 \
ORDER BY id DESC LIMIT $4",
)
.bind(args.namespace)
.bind(args.kind)
.bind(args.name)
.bind(args.limit as i64)
.fetch_all(pool)
.await?;
match args.output {
OutputMode::Json | OutputMode::JsonCompact => {
let arr: Vec<Value> = rows
.iter()
.map(|r| {
json!({
"version": r.version,
"action": r.action,
"actor": r.actor,
"created_at": r.created_at.format("%Y-%m-%dT%H:%M:%SZ").to_string(),
})
})
.collect();
print_json(&Value::Array(arr), &args.output)?;
}
_ => {
if rows.is_empty() {
println!(
"No history found for [{}/{}] {}.",
args.namespace, args.kind, args.name
);
return Ok(());
}
println!(
"History for [{}/{}] {}:",
args.namespace, args.kind, args.name
);
for r in &rows {
println!(
" v{:<4} {:8} {} {}",
r.version,
r.action,
r.actor,
format_local_time(r.created_at)
);
}
println!(" (use `secrets rollback --to-version <N>` to restore)");
}
}
Ok(())
}

View File

@@ -1,217 +0,0 @@
use anyhow::Result;
use serde_json::Value;
use sqlx::PgPool;
use std::collections::BTreeMap;
use crate::commands::add::{self, AddArgs};
use crate::models::ExportFormat;
use crate::output::{OutputMode, print_json};
pub struct ImportArgs<'a> {
pub file: &'a str,
/// Overwrite existing records when there is a conflict (upsert).
/// Without this flag, the import aborts on the first conflict.
/// A future `--skip` flag could allow silently skipping conflicts and continuing.
pub force: bool,
/// Check and preview operations without writing to the database.
pub dry_run: bool,
pub output: OutputMode,
}
pub async fn run(pool: &PgPool, args: ImportArgs<'_>, master_key: &[u8; 32]) -> Result<()> {
let format = ExportFormat::from_extension(args.file)?;
let content = std::fs::read_to_string(args.file)
.map_err(|e| anyhow::anyhow!("Cannot read file '{}': {}", args.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 {
// Check if record already exists.
let exists: bool = sqlx::query_scalar(
"SELECT EXISTS(SELECT 1 FROM entries \
WHERE namespace = $1 AND kind = $2 AND name = $3)",
)
.bind(&entry.namespace)
.bind(&entry.kind)
.bind(&entry.name)
.fetch_one(pool)
.await
.unwrap_or(false);
if exists && !args.force {
let v = serde_json::json!({
"action": "conflict",
"namespace": entry.namespace,
"kind": entry.kind,
"name": entry.name,
});
match args.output {
OutputMode::Text => eprintln!(
"[{}/{}/{}] conflict — record already exists (use --force to overwrite)",
entry.namespace, entry.kind, entry.name
),
ref mode => {
// Write conflict notice to stderr so it does not mix with summary JSON.
eprint!(
"{}",
if *mode == OutputMode::Json {
serde_json::to_string_pretty(&v)?
} else {
serde_json::to_string(&v)?
}
);
eprintln!();
}
}
return Err(anyhow::anyhow!(
"Import aborted: conflict on [{}/{}/{}]",
entry.namespace,
entry.kind,
entry.name
));
}
let action = if exists { "upsert" } else { "insert" };
if args.dry_run {
let v = serde_json::json!({
"action": action,
"namespace": entry.namespace,
"kind": entry.kind,
"name": entry.name,
"dry_run": true,
});
match args.output {
OutputMode::Text => println!(
"[dry-run] {} [{}/{}/{}]",
action, entry.namespace, entry.kind, entry.name
),
ref mode => print_json(&v, mode)?,
}
if exists {
skipped += 1;
} else {
inserted += 1;
}
continue;
}
// Build secret_entries: convert BTreeMap<String, Value> to Vec<String> ("key:=json")
let secret_entries = build_secret_entries(entry.secrets.as_ref());
// Build meta_entries from metadata JSON object.
let meta_entries = build_meta_entries(&entry.metadata);
match add::run(
pool,
AddArgs {
namespace: &entry.namespace,
kind: &entry.kind,
name: &entry.name,
tags: &entry.tags,
meta_entries: &meta_entries,
secret_entries: &secret_entries,
output: OutputMode::Text,
},
master_key,
)
.await
{
Ok(()) => {
let v = serde_json::json!({
"action": action,
"namespace": entry.namespace,
"kind": entry.kind,
"name": entry.name,
});
match args.output {
OutputMode::Text => println!(
"Imported [{}/{}/{}]",
entry.namespace, entry.kind, entry.name
),
ref mode => print_json(&v, mode)?,
}
inserted += 1;
}
Err(e) => {
eprintln!(
"Error importing [{}/{}/{}]: {}",
entry.namespace, entry.kind, entry.name, e
);
failed += 1;
}
}
}
let summary = serde_json::json!({
"total": total,
"inserted": inserted,
"skipped": skipped,
"failed": failed,
"dry_run": args.dry_run,
});
match args.output {
OutputMode::Text => {
if args.dry_run {
println!(
"\n[dry-run] {} total: {} would insert, {} would skip, {} would fail",
total, inserted, skipped, failed
);
} else {
println!(
"\nImport done: {} total — {} inserted, {} skipped, {} failed",
total, inserted, skipped, failed
);
}
}
ref mode => print_json(&summary, mode)?,
}
if failed > 0 {
anyhow::bail!("{} record(s) failed to import", failed);
}
Ok(())
}
/// Convert metadata JSON object into Vec<String> of "key:=json_value" entries.
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
}
/// Convert a BTreeMap<String, Value> (secrets) into Vec<String> of "key:=json_value" entries.
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
}
/// Convert a key/value pair to a CLI-style entry string.
/// Strings use `key=value`; everything else uses `key:=<json>`.
fn value_to_kv_entry(key: &str, value: &Value) -> String {
match value {
Value::String(s) => format!("{}={}", key, s),
other => format!("{}:={}", key, other),
}
}

View File

@@ -1,70 +0,0 @@
use anyhow::{Context, Result};
use rand::RngExt;
use sqlx::PgPool;
use crate::{crypto, db};
const MIN_MASTER_PASSWORD_LEN: usize = 8;
pub async fn run(pool: &PgPool) -> Result<()> {
println!("Initializing secrets master key...");
println!();
// Read password (no echo)
let password = rpassword::prompt_password(format!(
"Enter master password (at least {} characters): ",
MIN_MASTER_PASSWORD_LEN
))
.context("failed to read password")?;
if password.chars().count() < MIN_MASTER_PASSWORD_LEN {
anyhow::bail!(
"Master password must be at least {} characters.",
MIN_MASTER_PASSWORD_LEN
);
}
let confirm = rpassword::prompt_password("Confirm master password: ")
.context("failed to read password confirmation")?;
if password != confirm {
anyhow::bail!("Passwords do not match.");
}
// Get or create Argon2id salt
let salt = match db::load_argon2_salt(pool).await? {
Some(existing) => {
println!("Found existing salt in database (not the first device).");
existing
}
None => {
println!("Generating new Argon2id salt and storing in database...");
let mut salt = vec![0u8; 16];
rand::rng().fill(&mut salt[..]);
db::store_argon2_salt(pool, &salt).await?;
salt
}
};
// Derive master key
print!("Deriving master key (Argon2id, this takes a moment)... ");
let master_key = crypto::derive_master_key(&password, &salt)?;
println!("done.");
// Store in OS Keychain
crypto::store_master_key(&master_key)?;
// Self-test: encrypt and decrypt a canary value
let canary = b"secrets-cli-canary";
let enc = crypto::encrypt(&master_key, canary)?;
let dec = crypto::decrypt(&master_key, &enc)?;
if dec != canary {
anyhow::bail!("Self-test failed: encryption roundtrip mismatch");
}
println!();
println!("Master key stored in OS Keychain.");
println!("You can now use `secrets add` / `secrets search` commands.");
println!();
println!("IMPORTANT: Remember your master password — it is not stored anywhere.");
println!(" On a new device, run `secrets init` with the same password.");
Ok(())
}

View File

@@ -1,12 +0,0 @@
pub mod add;
pub mod config;
pub mod delete;
pub mod export_cmd;
pub mod history;
pub mod import_cmd;
pub mod init;
pub mod rollback;
pub mod run;
pub mod search;
pub mod update;
pub mod upgrade;

View File

@@ -1,256 +0,0 @@
use anyhow::Result;
use serde_json::{Value, json};
use sqlx::{FromRow, PgPool};
use uuid::Uuid;
use crate::crypto;
use crate::db;
use crate::output::{OutputMode, print_json};
pub struct RollbackArgs<'a> {
pub namespace: &'a str,
pub kind: &'a str,
pub name: &'a str,
/// Target entry version to restore. None → restore the most recent history entry.
pub to_version: Option<i64>,
pub output: OutputMode,
}
pub async fn run(pool: &PgPool, args: RollbackArgs<'_>, master_key: &[u8; 32]) -> Result<()> {
// ── Find the target entry history snapshot ────────────────────────────────
#[derive(FromRow)]
struct EntryHistoryRow {
entry_id: Uuid,
version: i64,
action: String,
tags: Vec<String>,
metadata: Value,
}
let snap: Option<EntryHistoryRow> = if let Some(ver) = args.to_version {
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 \
ORDER BY id DESC LIMIT 1",
)
.bind(args.namespace)
.bind(args.kind)
.bind(args.name)
.bind(ver)
.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 \
ORDER BY id DESC LIMIT 1",
)
.bind(args.namespace)
.bind(args.kind)
.bind(args.name)
.fetch_optional(pool)
.await?
};
let snap = snap.ok_or_else(|| {
anyhow::anyhow!(
"No history found for [{}/{}] {}{}.",
args.namespace,
args.kind,
args.name,
args.to_version
.map(|v| format!(" at version {}", v))
.unwrap_or_default()
)
})?;
// ── Find the matching secret field snapshots ──────────────────────────────
#[derive(FromRow)]
struct SecretHistoryRow {
secret_id: Uuid,
field_name: String,
encrypted: Vec<u8>,
action: String,
}
let field_snaps: Vec<SecretHistoryRow> = sqlx::query_as(
"SELECT secret_id, 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?;
// Validate: try decrypting all encrypted fields before writing anything.
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?;
// ── Snapshot the current live state before overwriting ────────────────────
#[derive(sqlx::FromRow)]
struct LiveEntry {
id: Uuid,
version: i64,
tags: Vec<String>,
metadata: Value,
}
let live: Option<LiveEntry> = sqlx::query_as(
"SELECT id, version, tags, metadata FROM entries \
WHERE namespace = $1 AND kind = $2 AND name = $3 FOR UPDATE",
)
.bind(args.namespace)
.bind(args.kind)
.bind(args.name)
.fetch_optional(&mut *tx)
.await?;
if let Some(ref lr) = live {
if let Err(e) = db::snapshot_entry_history(
&mut tx,
db::EntrySnapshotParams {
entry_id: lr.id,
namespace: args.namespace,
kind: args.kind,
name: args.name,
version: lr.version,
action: "rollback",
tags: &lr.tags,
metadata: &lr.metadata,
},
)
.await
{
tracing::warn!(error = %e, "failed to snapshot entry before rollback");
}
// Snapshot existing secret fields.
#[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");
}
}
}
// ── Restore entry row ─────────────────────────────────────────────────────
sqlx::query(
"INSERT INTO entries (id, namespace, kind, name, tags, metadata, version, updated_at) \
VALUES ($1, $2, $3, $4, $5, $6, $7, NOW()) \
ON CONFLICT (namespace, kind, name) DO UPDATE SET \
tags = EXCLUDED.tags, \
metadata = EXCLUDED.metadata, \
version = entries.version + 1, \
updated_at = NOW()",
)
.bind(snap.entry_id)
.bind(args.namespace)
.bind(args.kind)
.bind(args.name)
.bind(&snap.tags)
.bind(&snap.metadata)
.bind(snap.version)
.execute(&mut *tx)
.await?;
// ── Restore secret fields ─────────────────────────────────────────────────
// Delete all current fields and re-insert from snapshot
// (only non-deleted fields from the snapshot are restored).
sqlx::query("DELETE FROM secrets WHERE entry_id = $1")
.bind(snap.entry_id)
.execute(&mut *tx)
.await?;
for f in &field_snaps {
if f.action == "delete" {
// Field was deleted at this snapshot point — don't restore it.
continue;
}
sqlx::query(
"INSERT INTO secrets (id, entry_id, field_name, encrypted) \
VALUES ($1, $2, $3, $4) \
ON CONFLICT (entry_id, field_name) DO UPDATE SET \
encrypted = EXCLUDED.encrypted, \
version = secrets.version + 1, \
updated_at = NOW()",
)
.bind(f.secret_id)
.bind(snap.entry_id)
.bind(&f.field_name)
.bind(&f.encrypted)
.execute(&mut *tx)
.await?;
}
crate::audit::log_tx(
&mut tx,
"rollback",
args.namespace,
args.kind,
args.name,
json!({
"restored_version": snap.version,
"original_action": snap.action,
}),
)
.await;
tx.commit().await?;
let result_json = json!({
"action": "rolled_back",
"namespace": args.namespace,
"kind": args.kind,
"name": args.name,
"restored_version": snap.version,
});
match args.output {
OutputMode::Text => println!(
"Rolled back: [{}/{}] {} → version {}",
args.namespace, args.kind, args.name, snap.version
),
ref mode => print_json(&result_json, mode)?,
}
Ok(())
}

View File

@@ -1,248 +0,0 @@
use anyhow::Result;
use serde_json::json;
use sqlx::PgPool;
use std::collections::HashMap;
use crate::commands::search::{build_injected_env_map, fetch_entries, fetch_secrets_for_entries};
use crate::output::OutputMode;
pub struct RunArgs<'a> {
pub namespace: Option<&'a str>,
pub kind: Option<&'a str>,
pub name: Option<&'a str>,
pub tags: &'a [String],
pub secret_fields: &'a [String],
pub prefix: &'a str,
pub dry_run: bool,
pub output: OutputMode,
pub command: &'a [String],
}
/// A single environment variable with its origin for dry-run display.
pub struct EnvMapping {
pub var_name: String,
pub source: String,
pub field: String,
}
struct CollectArgs<'a> {
namespace: Option<&'a str>,
kind: Option<&'a str>,
name: Option<&'a str>,
tags: &'a [String],
secret_fields: &'a [String],
prefix: &'a str,
}
/// Fetch entries matching the filter and build a flat env map (decrypted secrets only, no metadata).
/// If `secret_fields` is non-empty, only those fields are decrypted and included.
async fn collect_env_map(
pool: &PgPool,
args: &CollectArgs<'_>,
master_key: &[u8; 32],
) -> Result<HashMap<String, String>> {
if args.namespace.is_none()
&& args.kind.is_none()
&& args.name.is_none()
&& args.tags.is_empty()
{
anyhow::bail!(
"At least one filter (--namespace, --kind, --name, or --tag) is required for run"
);
}
let entries =
fetch_entries(pool, args.namespace, args.kind, args.name, args.tags, None).await?;
if entries.is_empty() {
anyhow::bail!("No records matched the given filters.");
}
let entry_ids: Vec<uuid::Uuid> = entries.iter().map(|e| e.id).collect();
let fields_map = fetch_secrets_for_entries(pool, &entry_ids).await?;
let mut map = HashMap::new();
for entry in &entries {
let empty = vec![];
let all_fields = fields_map.get(&entry.id).unwrap_or(&empty);
let filtered_fields: Vec<_> = if args.secret_fields.is_empty() {
all_fields.iter().collect()
} else {
all_fields
.iter()
.filter(|f| args.secret_fields.contains(&f.field_name))
.collect()
};
let row_map =
build_injected_env_map(pool, entry, args.prefix, master_key, &filtered_fields).await?;
for (k, v) in row_map {
map.insert(k, v);
}
}
Ok(map)
}
/// Like `collect_env_map` but also returns per-variable source info for dry-run display.
async fn collect_env_map_with_source(
pool: &PgPool,
args: &CollectArgs<'_>,
master_key: &[u8; 32],
) -> Result<(HashMap<String, String>, Vec<EnvMapping>)> {
if args.namespace.is_none()
&& args.kind.is_none()
&& args.name.is_none()
&& args.tags.is_empty()
{
anyhow::bail!(
"At least one filter (--namespace, --kind, --name, or --tag) is required for run"
);
}
let entries =
fetch_entries(pool, args.namespace, args.kind, args.name, args.tags, None).await?;
if entries.is_empty() {
anyhow::bail!("No records matched the given filters.");
}
let entry_ids: Vec<uuid::Uuid> = entries.iter().map(|e| e.id).collect();
let fields_map = fetch_secrets_for_entries(pool, &entry_ids).await?;
let mut map = HashMap::new();
let mut mappings: Vec<EnvMapping> = Vec::new();
for entry in &entries {
let empty = vec![];
let all_fields = fields_map.get(&entry.id).unwrap_or(&empty);
let filtered_fields: Vec<_> = if args.secret_fields.is_empty() {
all_fields.iter().collect()
} else {
all_fields
.iter()
.filter(|f| args.secret_fields.contains(&f.field_name))
.collect()
};
let row_map =
build_injected_env_map(pool, entry, args.prefix, master_key, &filtered_fields).await?;
let source = format!("{}/{}/{}", entry.namespace, entry.kind, entry.name);
for field in &filtered_fields {
let var_name = format!(
"{}_{}",
env_prefix_name(&entry.name, args.prefix),
field.field_name.to_uppercase().replace(['-', '.'], "_")
);
if row_map.contains_key(&var_name) {
mappings.push(EnvMapping {
var_name: var_name.clone(),
source: source.clone(),
field: field.field_name.clone(),
});
}
}
for (k, v) in row_map {
map.insert(k, v);
}
}
Ok((map, mappings))
}
fn env_prefix_name(entry_name: &str, prefix: &str) -> String {
let name_part = entry_name.to_uppercase().replace(['-', '.', ' '], "_");
if prefix.is_empty() {
name_part
} else {
format!(
"{}_{}",
prefix.to_uppercase().replace(['-', '.', ' '], "_"),
name_part
)
}
}
/// `run` command: inject secrets into a child process environment and execute.
/// With `--dry-run`, prints the variable mapping (names and sources only) without executing.
pub async fn run_exec(pool: &PgPool, args: RunArgs<'_>, master_key: &[u8; 32]) -> Result<()> {
if !args.dry_run && args.command.is_empty() {
anyhow::bail!(
"No command specified. Usage: secrets run [filter flags] -- <command> [args]"
);
}
let collect = CollectArgs {
namespace: args.namespace,
kind: args.kind,
name: args.name,
tags: args.tags,
secret_fields: args.secret_fields,
prefix: args.prefix,
};
if args.dry_run {
let (env_map, mappings) = collect_env_map_with_source(pool, &collect, master_key).await?;
let total_vars = env_map.len();
let total_records = {
let mut seen = std::collections::HashSet::new();
for m in &mappings {
seen.insert(&m.source);
}
seen.len()
};
match args.output {
OutputMode::Text => {
for m in &mappings {
println!("{:<40} <- {} :: {}", m.var_name, m.source, m.field);
}
println!("---");
println!(
"{} variable(s) from {} record(s).",
total_vars, total_records
);
}
OutputMode::Json | OutputMode::JsonCompact => {
let vars: Vec<_> = mappings
.iter()
.map(|m| {
json!({
"name": m.var_name,
"source": m.source,
"field": m.field,
})
})
.collect();
let out = json!({
"variables": vars,
"total_vars": total_vars,
"total_records": total_records,
});
if args.output == OutputMode::Json {
println!("{}", serde_json::to_string_pretty(&out)?);
} else {
println!("{}", serde_json::to_string(&out)?);
}
}
}
return Ok(());
}
let env_map = collect_env_map(pool, &collect, master_key).await?;
tracing::debug!(
vars = env_map.len(),
cmd = args.command[0].as_str(),
"injecting secrets into child process"
);
let status = std::process::Command::new(&args.command[0])
.args(&args.command[1..])
.envs(&env_map)
.status()
.map_err(|e| anyhow::anyhow!("Failed to execute '{}': {}", args.command[0], e))?;
if !status.success() {
let code = status.code().unwrap_or(1);
std::process::exit(code);
}
Ok(())
}

View File

@@ -1,568 +0,0 @@
use anyhow::Result;
use serde_json::{Value, json};
use sqlx::PgPool;
use std::collections::HashMap;
use crate::crypto;
use crate::models::{Entry, SecretField};
use crate::output::{OutputMode, format_local_time};
pub struct SearchArgs<'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 fields: &'a [String],
pub summary: bool,
pub limit: u32,
pub offset: u32,
pub sort: &'a str,
pub output: OutputMode,
}
pub async fn run(pool: &PgPool, args: SearchArgs<'_>) -> Result<()> {
validate_safe_search_args(args.fields)?;
let rows = fetch_entries_paged(
pool,
PagedFetchArgs {
namespace: args.namespace,
kind: args.kind,
name: args.name,
tags: args.tags,
query: args.query,
sort: args.sort,
limit: args.limit,
offset: args.offset,
},
)
.await?;
// -f/--field: extract specific metadata field values directly
if !args.fields.is_empty() {
return print_fields(&rows, args.fields);
}
// Fetch secret schemas for all returned entries (no master key needed).
let entry_ids: Vec<uuid::Uuid> = rows.iter().map(|r| r.id).collect();
let schema_map = if !args.summary && !entry_ids.is_empty() {
fetch_secret_schemas(pool, &entry_ids).await?
} else {
HashMap::new()
};
match args.output {
OutputMode::Json | OutputMode::JsonCompact => {
let arr: Vec<Value> = rows
.iter()
.map(|r| to_json(r, args.summary, schema_map.get(&r.id).map(Vec::as_slice)))
.collect();
let out = if args.output == OutputMode::Json {
serde_json::to_string_pretty(&arr)?
} else {
serde_json::to_string(&arr)?
};
println!("{}", out);
}
OutputMode::Text => {
if rows.is_empty() {
println!("No records found.");
return Ok(());
}
for row in &rows {
print_text(
row,
args.summary,
schema_map.get(&row.id).map(Vec::as_slice),
)?;
}
println!("{} record(s) found.", rows.len());
if rows.len() == args.limit as usize {
println!(
" (showing up to {}; use --offset {} to see more)",
args.limit,
args.offset + args.limit
);
}
}
}
Ok(())
}
fn validate_safe_search_args(fields: &[String]) -> Result<()> {
if let Some(field) = fields.iter().find(|field| is_secret_field(field)) {
anyhow::bail!(
"Field '{}' is sensitive. `search -f` only supports metadata.* fields; use `secrets run` for secrets.",
field
);
}
Ok(())
}
fn is_secret_field(field: &str) -> bool {
matches!(
field.split_once('.').map(|(section, _)| section),
Some("secret" | "secrets" | "encrypted")
)
}
// ── Entry fetching ────────────────────────────────────────────────────────────
struct PagedFetchArgs<'a> {
namespace: Option<&'a str>,
kind: Option<&'a str>,
name: Option<&'a str>,
tags: &'a [String],
query: Option<&'a str>,
sort: &'a str,
limit: u32,
offset: u32,
}
/// A very large limit used when callers need all matching records (export, run).
/// Postgres will stop scanning when this many rows are found; adjust if needed.
pub const FETCH_ALL_LIMIT: u32 = 100_000;
/// Fetch entries matching the given filters (used by search, run).
/// `limit` caps the result set; pass `FETCH_ALL_LIMIT` when you need all matching records.
pub async fn fetch_entries(
pool: &PgPool,
namespace: Option<&str>,
kind: Option<&str>,
name: Option<&str>,
tags: &[String],
query: Option<&str>,
) -> Result<Vec<Entry>> {
fetch_entries_with_limit(pool, namespace, kind, name, tags, query, FETCH_ALL_LIMIT).await
}
/// Like `fetch_entries` but with an explicit limit. Used internally by `search`.
pub(crate) async fn fetch_entries_with_limit(
pool: &PgPool,
namespace: Option<&str>,
kind: Option<&str>,
name: Option<&str>,
tags: &[String],
query: Option<&str>,
limit: u32,
) -> Result<Vec<Entry>> {
fetch_entries_paged(
pool,
PagedFetchArgs {
namespace,
kind,
name,
tags,
query,
sort: "name",
limit,
offset: 0,
},
)
.await
}
async fn fetch_entries_paged(pool: &PgPool, a: PagedFetchArgs<'_>) -> Result<Vec<Entry>> {
let mut conditions: Vec<String> = Vec::new();
let mut idx: i32 = 1;
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[{}]", 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 where_clause = if conditions.is_empty() {
String::new()
} else {
format!("WHERE {}", conditions.join(" AND "))
};
let order = match a.sort {
"updated" => "updated_at DESC",
"created" => "created_at DESC",
_ => "namespace, kind, name",
};
let sql = format!(
"SELECT * FROM entries {} ORDER BY {} LIMIT ${} OFFSET ${}",
where_clause,
order,
idx,
idx + 1
);
tracing::debug!(sql, "executing search query");
let mut q = sqlx::query_as::<_, Entry>(&sql);
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 v in a.tags {
q = q.bind(v.as_str());
}
if let Some(v) = a.query {
q = q.bind(format!(
"%{}%",
v.replace('\\', "\\\\")
.replace('%', "\\%")
.replace('_', "\\_")
));
}
q = q.bind(a.limit as i64).bind(a.offset as i64);
Ok(q.fetch_all(pool).await?)
}
// ── Secret schema fetching (no master key) ───────────────────────────────────
/// Fetch secret field names for a set of entry ids.
/// Returns a map from entry_id to list of SecretField.
async fn fetch_secret_schemas(
pool: &PgPool,
entry_ids: &[uuid::Uuid],
) -> Result<HashMap<uuid::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::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::Uuid],
) -> Result<HashMap<uuid::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::Uuid, Vec<SecretField>> = HashMap::new();
for f in fields {
map.entry(f.entry_id).or_default().push(f);
}
Ok(map)
}
// ── Display helpers ───────────────────────────────────────────────────────────
fn env_prefix(entry: &Entry, prefix: &str) -> String {
let name_part = entry.name.to_uppercase().replace(['-', '.', ' '], "_");
if prefix.is_empty() {
name_part
} else {
format!(
"{}_{}",
prefix.to_uppercase().replace(['-', '.', ' '], "_"),
name_part
)
}
}
/// Build a flat KEY=VALUE map from decrypted secret fields only.
/// Resolves key_ref: if metadata.key_ref is set, merges secret fields from that key entry.
pub async fn build_injected_env_map(
pool: &PgPool,
entry: &Entry,
prefix: &str,
master_key: &[u8; 32],
fields: &[&SecretField],
) -> Result<HashMap<String, String>> {
let effective_prefix = env_prefix(entry, prefix);
let mut map = HashMap::new();
// Decrypt each secret field and add to env map.
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_value_to_env_string(&decrypted));
}
// Resolve key_ref: merge secrets from the referenced key entry.
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,
)
.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_value_to_env_string(&decrypted));
}
} else {
tracing::warn!(key_ref, "key_ref target not found");
}
}
Ok(map)
}
fn json_value_to_env_string(v: &Value) -> String {
match v {
Value::String(s) => s.clone(),
Value::Null => String::new(),
other => other.to_string(),
}
}
fn to_json(entry: &Entry, summary: bool, schema: Option<&[SecretField]>) -> Value {
if summary {
let desc = entry
.metadata
.get("desc")
.or_else(|| entry.metadata.get("url"))
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
return json!({
"namespace": entry.namespace,
"kind": entry.kind,
"name": entry.name,
"tags": entry.tags,
"desc": desc,
"updated_at": entry.updated_at.format("%Y-%m-%dT%H:%M:%SZ").to_string(),
});
}
let secrets_val: Value = match schema {
Some(fields) if !fields.is_empty() => {
let schema_arr: Vec<Value> = fields
.iter()
.map(|f| {
json!({
"field_name": f.field_name,
})
})
.collect();
Value::Array(schema_arr)
}
_ => Value::Array(vec![]),
};
json!({
"id": entry.id,
"namespace": entry.namespace,
"kind": entry.kind,
"name": entry.name,
"tags": entry.tags,
"metadata": entry.metadata,
"secrets": secrets_val,
"version": entry.version,
"created_at": entry.created_at.format("%Y-%m-%dT%H:%M:%SZ").to_string(),
"updated_at": entry.updated_at.format("%Y-%m-%dT%H:%M:%SZ").to_string(),
})
}
fn print_text(entry: &Entry, summary: bool, schema: Option<&[SecretField]>) -> Result<()> {
println!("[{}/{}] {}", entry.namespace, entry.kind, entry.name);
if summary {
let desc = entry
.metadata
.get("desc")
.or_else(|| entry.metadata.get("url"))
.and_then(|v| v.as_str())
.unwrap_or("-");
if !entry.tags.is_empty() {
println!(" tags: [{}]", entry.tags.join(", "));
}
println!(" desc: {}", desc);
println!(" updated: {}", format_local_time(entry.updated_at));
} else {
println!(" id: {}", entry.id);
if !entry.tags.is_empty() {
println!(" tags: [{}]", entry.tags.join(", "));
}
if entry.metadata.as_object().is_some_and(|m| !m.is_empty()) {
println!(
" metadata: {}",
serde_json::to_string_pretty(&entry.metadata)?
);
}
match schema {
Some(fields) if !fields.is_empty() => {
let schema_str: Vec<String> = fields.iter().map(|f| f.field_name.clone()).collect();
println!(" secrets: {}", schema_str.join(", "));
println!(" (use `secrets run` to get values)");
}
_ => {}
}
println!(" version: {}", entry.version);
println!(" created: {}", format_local_time(entry.created_at));
}
println!();
Ok(())
}
/// Extract one or more metadata field paths like `metadata.url`.
fn print_fields(rows: &[Entry], fields: &[String]) -> Result<()> {
for row in rows {
for field in fields {
let val = extract_field(row, field)?;
println!("{}", val);
}
}
Ok(())
}
fn extract_field(entry: &Entry, field: &str) -> Result<String> {
let (section, key) = field
.split_once('.')
.ok_or_else(|| anyhow::anyhow!("Invalid field path '{}'. Use metadata.<key>.", field))?;
let obj = match section {
"metadata" | "meta" => &entry.metadata,
other => anyhow::bail!("Unknown field section '{}'. Use 'metadata'.", other),
};
obj.get(key)
.and_then(|v| {
v.as_str()
.map(|s| s.to_string())
.or_else(|| Some(v.to_string()))
})
.ok_or_else(|| {
anyhow::anyhow!(
"Field '{}' not found in record [{}/{}/{}]",
field,
entry.namespace,
entry.kind,
entry.name
)
})
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::Utc;
use serde_json::json;
use uuid::Uuid;
fn sample_entry() -> Entry {
Entry {
id: Uuid::nil(),
namespace: "refining".to_string(),
kind: "service".to_string(),
name: "gitea.main".to_string(),
tags: vec!["prod".to_string()],
metadata: json!({"url": "https://code.example.com", "enabled": true}),
version: 1,
created_at: Utc::now(),
updated_at: Utc::now(),
}
}
fn sample_fields() -> Vec<SecretField> {
let key = [0x42u8; 32];
let enc = crypto::encrypt_json(&key, &json!("abc123")).unwrap();
vec![SecretField {
id: Uuid::nil(),
entry_id: Uuid::nil(),
field_name: "token".to_string(),
encrypted: enc,
version: 1,
created_at: Utc::now(),
updated_at: Utc::now(),
}]
}
#[test]
fn rejects_secret_field_extraction() {
let fields = vec!["secret.token".to_string()];
let err = validate_safe_search_args(&fields).unwrap_err();
assert!(err.to_string().contains("sensitive"));
}
#[test]
fn to_json_full_includes_secrets_schema() {
let entry = sample_entry();
let fields = sample_fields();
let v = to_json(&entry, false, Some(&fields));
let secrets = v.get("secrets").unwrap().as_array().unwrap();
assert_eq!(secrets.len(), 1);
assert_eq!(secrets[0]["field_name"], "token");
}
#[test]
fn to_json_summary_omits_secrets_schema() {
let entry = sample_entry();
let fields = sample_fields();
let v = to_json(&entry, true, Some(&fields));
assert!(v.get("secrets").is_none());
}
}

View File

@@ -1,293 +0,0 @@
use anyhow::Result;
use serde_json::{Map, Value, json};
use sqlx::PgPool;
use uuid::Uuid;
use super::add::{
collect_field_paths, collect_key_paths, flatten_json_fields, insert_path, parse_key_path,
parse_kv, remove_path,
};
use crate::crypto;
use crate::db;
use crate::models::EntryRow;
use crate::output::{OutputMode, print_json};
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 output: OutputMode,
}
pub async fn run(pool: &PgPool, args: UpdateArgs<'_>, master_key: &[u8; 32]) -> Result<()> {
let mut tx = pool.begin().await?;
let row: Option<EntryRow> = sqlx::query_as(
"SELECT id, version, tags, metadata \
FROM entries \
WHERE namespace = $1 AND kind = $2 AND name = $3 \
FOR UPDATE",
)
.bind(args.namespace)
.bind(args.kind)
.bind(args.name)
.fetch_optional(&mut *tx)
.await?;
let row = row.ok_or_else(|| {
anyhow::anyhow!(
"Not found: [{}/{}] {}. Use `add` to create it first.",
args.namespace,
args.kind,
args.name
)
})?;
// Snapshot current entry state before modifying.
if let Err(e) = db::snapshot_entry_history(
&mut tx,
db::EntrySnapshotParams {
entry_id: row.id,
namespace: args.namespace,
kind: args.kind,
name: args.name,
version: row.version,
action: "update",
tags: &row.tags,
metadata: &row.metadata,
},
)
.await
{
tracing::warn!(error = %e, "failed to snapshot entry history before update");
}
// ── 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 (path, value) = parse_kv(entry)?;
insert_path(&mut meta_map, &path, value)?;
}
for key in args.remove_meta {
let path = parse_key_path(key)?;
remove_path(&mut meta_map, &path)?;
}
let metadata = Value::Object(meta_map);
// CAS update of the entry row.
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.",
args.namespace,
args.kind,
args.name
);
}
let new_version = row.version + 1;
// ── Update secret fields ──────────────────────────────────────────────────
for entry in args.secret_entries {
let (path, field_value) = parse_kv(entry)?;
// For nested paths (e.g. credentials:type), flatten into dot-separated names
// and treat the sub-value as the individual field to store.
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)?;
// Snapshot existing field before replacing.
#[derive(sqlx::FromRow)]
struct ExistingField {
id: Uuid,
encrypted: Vec<u8>,
}
let existing_field: 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) = &existing_field
&& 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?;
}
}
// ── Remove secret fields ──────────────────────────────────────────────────
for key in args.remove_secrets {
let path = parse_key_path(key)?;
// Dot-join the path to match flattened field_name storage.
let field_name = path.join(".");
// Snapshot before delete.
#[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(args.meta_entries)?;
let remove_meta_keys = collect_field_paths(args.remove_meta)?;
let secret_keys = collect_key_paths(args.secret_entries)?;
let remove_secret_keys = collect_field_paths(args.remove_secrets)?;
crate::audit::log_tx(
&mut tx,
"update",
args.namespace,
args.kind,
args.name,
json!({
"add_tags": args.add_tags,
"remove_tags": args.remove_tags,
"meta_keys": meta_keys,
"remove_meta": remove_meta_keys,
"secret_keys": secret_keys,
"remove_secrets": remove_secret_keys,
}),
)
.await;
tx.commit().await?;
let result_json = json!({
"action": "updated",
"namespace": args.namespace,
"kind": args.kind,
"name": args.name,
"add_tags": args.add_tags,
"remove_tags": args.remove_tags,
"meta_keys": meta_keys,
"remove_meta": remove_meta_keys,
"secret_keys": secret_keys,
"remove_secrets": remove_secret_keys,
});
match args.output {
OutputMode::Json | OutputMode::JsonCompact => {
print_json(&result_json, &args.output)?;
}
_ => {
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() {
println!(" +metadata: {}", meta_keys.join(", "));
}
if !args.remove_meta.is_empty() {
println!(" -metadata: {}", remove_meta_keys.join(", "));
}
if !args.secret_entries.is_empty() {
println!(" +secrets: {}", secret_keys.join(", "));
}
if !args.remove_secrets.is_empty() {
println!(" -secrets: {}", remove_secret_keys.join(", "));
}
}
}
Ok(())
}

View File

@@ -1,411 +0,0 @@
use anyhow::{Context, Result, bail};
use flate2::read::GzDecoder;
use serde::Deserialize;
use sha2::{Digest, Sha256};
use std::io::{Cursor, Read, Write};
use std::time::Duration;
const CURRENT_VERSION: &str = env!("CARGO_PKG_VERSION");
/// Build-time config via `option_env!("SECRETS_UPGRADE_URL")`. Set during `cargo build`, e.g.:
/// SECRETS_UPGRADE_URL=https://... cargo build --release
const BUILD_UPGRADE_URL: Option<&'static str> = option_env!("SECRETS_UPGRADE_URL");
fn upgrade_api_url() -> Result<String> {
if let Some(url) = BUILD_UPGRADE_URL.filter(|s| !s.trim().is_empty()) {
return Ok(url.to_string());
}
let url = std::env::var("SECRETS_UPGRADE_URL").context(
"SECRETS_UPGRADE_URL is not set at build or runtime. Set it when building: \
SECRETS_UPGRADE_URL=https://... cargo build, or export before running secrets upgrade.",
)?;
if url.trim().is_empty() {
anyhow::bail!("SECRETS_UPGRADE_URL is empty.");
}
Ok(url)
}
#[derive(Debug, Deserialize)]
struct Release {
tag_name: String,
assets: Vec<Asset>,
}
#[derive(Debug, Deserialize)]
struct Asset {
name: String,
browser_download_url: String,
}
fn available_assets(assets: &[Asset]) -> String {
assets
.iter()
.map(|a| a.name.as_str())
.collect::<Vec<_>>()
.join(", ")
}
fn release_asset_name(tag_name: &str, suffix: &str) -> String {
format!("secrets-{tag_name}-{suffix}")
}
fn find_asset_by_name<'a>(assets: &'a [Asset], name: &str) -> Result<&'a Asset> {
assets.iter().find(|a| a.name == name).with_context(|| {
format!(
"no matching release asset found: {name}\navailable: {}",
available_assets(assets)
)
})
}
/// Detect the asset suffix for the current platform/arch at compile time.
fn platform_asset_suffix() -> Result<&'static str> {
#[cfg(all(target_os = "linux", target_arch = "x86_64"))]
{
Ok("x86_64-linux-musl.tar.gz")
}
#[cfg(all(target_os = "macos", target_arch = "aarch64"))]
{
Ok("aarch64-macos.tar.gz")
}
#[cfg(all(target_os = "macos", target_arch = "x86_64"))]
{
Ok("x86_64-macos.tar.gz")
}
#[cfg(all(target_os = "windows", target_arch = "x86_64"))]
{
Ok("x86_64-windows.zip")
}
#[cfg(not(any(
all(target_os = "linux", target_arch = "x86_64"),
all(target_os = "macos", target_arch = "aarch64"),
all(target_os = "macos", target_arch = "x86_64"),
all(target_os = "windows", target_arch = "x86_64"),
)))]
bail!(
"Unsupported platform: {}/{}",
std::env::consts::OS,
std::env::consts::ARCH
)
}
/// Strip the "secrets-" prefix from the tag and parse as semver.
fn parse_tag_version(tag: &str) -> Result<semver::Version> {
let ver_str = tag
.strip_prefix("secrets-")
.with_context(|| format!("unexpected tag format: {tag}"))?;
semver::Version::parse(ver_str)
.with_context(|| format!("failed to parse version from tag: {tag}"))
}
fn sha256_hex(bytes: &[u8]) -> String {
let digest = Sha256::digest(bytes);
format!("{digest:x}")
}
fn verify_checksum(asset_name: &str, archive: &[u8], checksum_contents: &str) -> Result<String> {
let expected_checksum = parse_checksum_file(checksum_contents)?;
let actual_checksum = sha256_hex(archive);
if actual_checksum != expected_checksum {
bail!(
"checksum verification failed for {}: expected {}, got {}",
asset_name,
expected_checksum,
actual_checksum
);
}
Ok(actual_checksum)
}
fn parse_checksum_file(contents: &str) -> Result<String> {
let checksum = contents
.split_whitespace()
.next()
.context("checksum file is empty")?
.trim()
.to_ascii_lowercase();
if checksum.len() != 64 || !checksum.bytes().all(|b| b.is_ascii_hexdigit()) {
bail!("invalid SHA-256 checksum format")
}
Ok(checksum)
}
async fn download_bytes(client: &reqwest::Client, url: &str, context: &str) -> Result<Vec<u8>> {
Ok(client
.get(url)
.send()
.await
.with_context(|| format!("{context}: request failed"))?
.error_for_status()
.with_context(|| format!("{context}: server returned an error"))?
.bytes()
.await
.with_context(|| format!("{context}: failed to read response body"))?
.to_vec())
}
/// Extract the binary from a tar.gz archive (first file whose name == "secrets").
fn extract_from_targz(bytes: &[u8]) -> Result<Vec<u8>> {
let gz = GzDecoder::new(Cursor::new(bytes));
let mut archive = tar::Archive::new(gz);
for entry in archive.entries().context("failed to read tar entries")? {
let mut entry = entry.context("bad tar entry")?;
let path = entry.path().context("bad tar entry path")?.into_owned();
let fname = path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or_default();
if fname == "secrets" || fname == "secrets.exe" {
let mut buf = Vec::new();
entry.read_to_end(&mut buf).context("read tar entry")?;
return Ok(buf);
}
}
bail!("binary not found inside tar.gz archive")
}
/// Extract the binary from a zip archive (first file whose name matches).
#[cfg(target_os = "windows")]
fn extract_from_zip(bytes: &[u8]) -> Result<Vec<u8>> {
let reader = Cursor::new(bytes);
let mut archive = zip::ZipArchive::new(reader).context("failed to open zip archive")?;
for i in 0..archive.len() {
let mut file = archive.by_index(i).context("bad zip entry")?;
let fname = file.name().to_owned();
if fname.ends_with("secrets.exe") || fname.ends_with("secrets") {
let mut buf = Vec::new();
file.read_to_end(&mut buf).context("read zip entry")?;
return Ok(buf);
}
}
bail!("binary not found inside zip archive")
}
pub async fn run(check_only: bool) -> Result<()> {
let current = semver::Version::parse(CURRENT_VERSION).context("invalid current version")?;
println!("Current version: v{current}");
println!("Checking for updates...");
let client = reqwest::Client::builder()
.user_agent(format!("secrets-cli/{CURRENT_VERSION}"))
.connect_timeout(Duration::from_secs(10))
.timeout(Duration::from_secs(120))
.build()
.context("failed to build HTTP client")?;
let api_url = upgrade_api_url()?;
let release: Release = client
.get(&api_url)
.send()
.await
.context("failed to fetch release info")?
.error_for_status()
.context("release API returned an error")?
.json()
.await
.context("failed to parse release JSON")?;
let latest = parse_tag_version(&release.tag_name)?;
if latest <= current {
println!("Already up to date (v{current})");
return Ok(());
}
println!("New version available: v{latest}");
if check_only {
println!("Run `secrets upgrade` to update.");
return Ok(());
}
let suffix = platform_asset_suffix()?;
let asset_name = release_asset_name(&release.tag_name, suffix);
let asset = find_asset_by_name(&release.assets, &asset_name)?;
let checksum_name = format!("{}.sha256", asset.name);
let checksum_asset = find_asset_by_name(&release.assets, &checksum_name)?;
println!("Downloading {}...", asset.name);
let archive = download_bytes(&client, &asset.browser_download_url, "archive download").await?;
let checksum_contents = download_bytes(
&client,
&checksum_asset.browser_download_url,
"checksum download",
)
.await?;
let actual_checksum = verify_checksum(
&asset.name,
&archive,
std::str::from_utf8(&checksum_contents).context("checksum file is not valid UTF-8")?,
)?;
println!("Verified SHA-256: {actual_checksum}");
println!("Extracting...");
let binary = if suffix.ends_with(".tar.gz") {
extract_from_targz(&archive)?
} else {
#[cfg(target_os = "windows")]
{
extract_from_zip(&archive)?
}
#[cfg(not(target_os = "windows"))]
bail!("zip extraction is only supported on Windows")
};
// Write to a temporary file, set executable permission, then atomically replace.
let mut tmp = tempfile::NamedTempFile::new().context("failed to create temp file")?;
tmp.write_all(&binary)
.context("failed to write temp binary")?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let perms = std::fs::Permissions::from_mode(0o755);
std::fs::set_permissions(tmp.path(), perms).context("failed to chmod temp binary")?;
}
self_replace::self_replace(tmp.path()).context("failed to replace current binary")?;
println!("Updated: v{current} → v{latest}");
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use flate2::Compression;
use flate2::write::GzEncoder;
use tar::Builder;
#[test]
fn parse_tag_version_accepts_release_tag() {
let version = parse_tag_version("secrets-0.6.1").expect("version should parse");
assert_eq!(version, semver::Version::new(0, 6, 1));
}
#[test]
fn parse_tag_version_rejects_invalid_tag() {
let err = parse_tag_version("v0.6.1").expect_err("tag should be rejected");
assert!(err.to_string().contains("unexpected tag format"));
}
#[test]
fn parse_checksum_file_accepts_sha256sum_format() {
let checksum = parse_checksum_file(
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef secrets.tar.gz",
)
.expect("checksum should parse");
assert_eq!(
checksum,
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
);
}
#[test]
fn parse_checksum_file_rejects_invalid_checksum() {
let err = parse_checksum_file("not-a-sha256").expect_err("checksum should be rejected");
assert!(err.to_string().contains("invalid SHA-256 checksum format"));
}
#[test]
fn release_asset_name_matches_release_tag() {
assert_eq!(
release_asset_name("secrets-0.7.0", "x86_64-linux-musl.tar.gz"),
"secrets-secrets-0.7.0-x86_64-linux-musl.tar.gz"
);
}
#[test]
fn find_asset_by_name_rejects_stale_platform_match() {
let assets = vec![
Asset {
name: "secrets-secrets-0.6.9-x86_64-linux-musl.tar.gz".into(),
browser_download_url: "https://example.invalid/old".into(),
},
Asset {
name: "secrets-secrets-0.7.0-aarch64-macos.tar.gz".into(),
browser_download_url: "https://example.invalid/other".into(),
},
];
let err = find_asset_by_name(&assets, "secrets-secrets-0.7.0-x86_64-linux-musl.tar.gz")
.expect_err("stale asset should not match");
assert!(err.to_string().contains("no matching release asset found"));
}
#[test]
fn sha256_hex_matches_known_value() {
assert_eq!(
sha256_hex(b"abc"),
"ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
);
}
#[test]
fn verify_checksum_rejects_mismatch() {
let err = verify_checksum(
"secrets.tar.gz",
b"abc",
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef secrets.tar.gz",
)
.expect_err("checksum mismatch should fail");
assert!(err.to_string().contains("checksum verification failed"));
}
#[test]
fn extract_from_targz_reads_binary() {
let payload = b"fake-secrets-binary";
let archive = make_test_targz("secrets", payload);
let extracted = extract_from_targz(&archive).expect("binary should extract");
assert_eq!(extracted, payload);
}
fn make_test_targz(name: &str, payload: &[u8]) -> Vec<u8> {
let encoder = GzEncoder::new(Vec::new(), Compression::default());
let mut builder = Builder::new(encoder);
let mut header = tar::Header::new_gnu();
header.set_mode(0o755);
header.set_size(payload.len() as u64);
header.set_cksum();
builder
.append_data(&mut header, name, payload)
.expect("append tar entry");
let encoder = builder.into_inner().expect("finish tar builder");
encoder.finish().expect("finish gzip")
}
#[cfg(target_os = "windows")]
#[test]
fn extract_from_zip_reads_binary() {
use zip::write::SimpleFileOptions;
let cursor = Cursor::new(Vec::<u8>::new());
let mut writer = zip::ZipWriter::new(cursor);
writer
.start_file("secrets.exe", SimpleFileOptions::default())
.expect("start zip file");
writer
.write_all(b"fake-secrets-binary")
.expect("write zip payload");
let bytes = writer.finish().expect("finish zip").into_inner();
let extracted = extract_from_zip(&bytes).expect("binary should extract");
assert_eq!(extracted, b"fake-secrets-binary");
}
}

View File

@@ -1,77 +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() -> Result<PathBuf> {
let dir = dirs::config_dir()
.or_else(|| dirs::home_dir().map(|h| h.join(".config")))
.context(
"Cannot determine config directory: \
neither XDG_CONFIG_HOME nor HOME is set",
)?
.join("secrets");
Ok(dir)
}
pub fn config_path() -> Result<PathBuf> {
Ok(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!("failed to read config file: {}", path.display()))?;
let config: Config = toml::from_str(&content)
.with_context(|| format!("failed to parse config file: {}", path.display()))?;
Ok(config)
}
pub fn save_config(config: &Config) -> Result<()> {
let dir = config_dir()?;
fs::create_dir_all(&dir)
.with_context(|| format!("failed to create config dir: {}", dir.display()))?;
let path = dir.join("config.toml");
let content = toml::to_string_pretty(config).context("failed to serialize config")?;
fs::write(&path, &content)
.with_context(|| format!("failed to write config file: {}", path.display()))?;
// Set file permissions to 0600 (owner read/write only)
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let perms = fs::Permissions::from_mode(0o600);
fs::set_permissions(&path, perms)
.with_context(|| format!("failed to set file permissions: {}", path.display()))?;
}
Ok(())
}
/// Resolve database URL by priority:
/// 1. --db-url CLI flag (if non-empty)
/// 2. database_url in ~/.config/secrets/config.toml
/// 3. Error with setup instructions
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!("Database not configured. Run:\n\n secrets config set-db <DATABASE_URL>\n")
}

215
src/db.rs
View File

@@ -1,215 +0,0 @@
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(5)
.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 (server, service, key, …) ──────────────
CREATE TABLE IF NOT EXISTS entries (
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 '{}',
version BIGINT NOT NULL DEFAULT 1,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(namespace, kind, name)
);
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_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, plaintext schema metadata ────
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);
-- ── kv_config: global key-value store (Argon2id salt, etc.) ────────────
CREATE TABLE IF NOT EXISTS kv_config (
key TEXT PRIMARY KEY,
value BYTEA NOT NULL
);
-- ── audit_log: append-only operation log ────────────────────────────────
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);
-- ── entries_history: entry-level snapshot (tags + metadata) ─────────────
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);
-- ── 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);
"#,
)
.execute(pool)
.await?;
tracing::debug!("migrations complete");
Ok(())
}
// ── Entry-level history snapshot ────────────────────────────────────────────
pub struct EntrySnapshotParams<'a> {
pub entry_id: 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,
}
/// Snapshot an entry row into `entries_history` before a write operation.
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) \
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)",
)
.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)
.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,
}
/// Snapshot a single secret field into `secrets_history`.
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(())
}
// ── Argon2 salt helpers ──────────────────────────────────────────────────────
/// Load the Argon2id salt from the database.
pub async fn load_argon2_salt(pool: &PgPool) -> Result<Option<Vec<u8>>> {
let row: Option<(Vec<u8>,)> =
sqlx::query_as("SELECT value FROM kv_config WHERE key = 'argon2_salt'")
.fetch_optional(pool)
.await?;
Ok(row.map(|(v,)| v))
}
/// Store the Argon2id salt in the database (only called once on first device init).
pub async fn store_argon2_salt(pool: &PgPool, salt: &[u8]) -> Result<()> {
sqlx::query(
"INSERT INTO kv_config (key, value) VALUES ('argon2_salt', $1) \
ON CONFLICT (key) DO NOTHING",
)
.bind(salt)
.execute(pool)
.await?;
Ok(())
}

View File

@@ -1,853 +0,0 @@
mod audit;
mod commands;
mod config;
mod crypto;
mod db;
mod models;
mod output;
use anyhow::Result;
/// Load .env from current or parent directories (best-effort, no error if missing).
fn load_dotenv() {
let _ = dotenvy::dotenv();
}
use clap::{Parser, Subcommand};
use tracing_subscriber::EnvFilter;
use output::resolve_output_mode;
#[derive(Parser)]
#[command(
name = "secrets",
version,
about = "Secrets & config manager backed by PostgreSQL — optimised for AI agents",
after_help = "QUICK START:
# 1. Configure database (once per device)
secrets config set-db \"postgres://postgres:<password>@<host>:<port>/secrets\"
# 2. Initialize master key (once per device)
secrets init
# Discover what namespaces / kinds exist
secrets search --summary --limit 20
# Precise lookup (JSON output for easy parsing)
secrets search -n refining --kind service --name gitea -o json
# Extract a single metadata field directly
secrets search -n refining --kind service --name gitea -f metadata.url
# Pipe-friendly (non-TTY defaults to json-compact automatically)
secrets search -n refining --kind service | jq '.[].name'
# Run a command with secrets injected into its child process environment
secrets run -n refining --kind service --name gitea -- printenv"
)]
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 {
/// Initialize master key on this device (run once per device).
///
/// Prompts for a master password, derives a key with Argon2id, and stores
/// it in the OS Keychain. Use the same password on every device.
///
/// NOTE: Run `secrets config set-db <URL>` first if database is not configured.
#[command(after_help = "PREREQUISITE:
Database must be configured first. Run: secrets config set-db <DATABASE_URL>
EXAMPLES:
# First device: generates a new Argon2id salt and stores master key
secrets init
# Subsequent devices: reuses existing salt from the database
secrets init")]
Init,
/// Add or update a record (upsert). Use -m for plaintext metadata, -s for secrets.
#[command(after_help = "EXAMPLES:
# Add a server
secrets add -n refining --kind server --name my-server \\
--tag aliyun --tag shanghai \\
-m ip=10.0.0.1 -m desc=\"Example ECS\" \\
-s username=root -s ssh_key=@./keys/server.pem
# Add a service credential
secrets add -n refining --kind service --name gitea \\
--tag gitea \\
-m url=https://code.example.com -m default_org=myorg \\
-s token=<token>
# Add typed JSON metadata
secrets add -n refining --kind service --name gitea \\
-m port:=3000 \\
-m enabled:=true \\
-m domains:='[\"code.example.com\",\"git.example.com\"]' \\
-m tls:='{\"enabled\":true,\"redirect_http\":true}'
# Add with token read from a file
secrets add -n ricnsmart --kind service --name mqtt \\
-m host=mqtt.example.com -m port=1883 \\
-s password=@./mqtt_password.txt
# Add typed JSON secrets
secrets add -n refining --kind service --name deploy-bot \\
-s enabled:=true \\
-s retry_count:=3 \\
-s scopes:='[\"repo\",\"workflow\"]' \\
-s extra:='{\"region\":\"ap-east-1\",\"verify_tls\":true}'
# Write a multiline file into a nested secret field
secrets add -n refining --kind server --name my-server \\
-s credentials:content@./keys/server.pem
# Shared PEM (key_ref): store key once, reference from multiple servers
secrets add -n refining --kind key --name my-shared-key \\
--tag aliyun -s content=@./keys/shared.pem
secrets add -n refining --kind server --name i-abc123 \\
-m ip=10.0.0.1 -m key_ref=my-shared-key -s username=ecs-user")]
Add {
/// Namespace, e.g. refining, ricnsmart
#[arg(short, long)]
namespace: String,
/// Kind of record: server, service, key, ...
#[arg(long)]
kind: String,
/// Human-readable unique name, e.g. gitea, i-example0abcd1234efgh
#[arg(long)]
name: String,
/// Tag for categorization (repeatable), e.g. --tag aliyun --tag hongkong
#[arg(long = "tag")]
tags: Vec<String>,
/// Plaintext metadata: key=value, key:=<json>, key=@file, or nested:path@file.
/// Use key_ref=<name> to reference a shared key entry (kind=key); run merges its secrets.
#[arg(long = "meta", short = 'm')]
meta: Vec<String>,
/// Secret entry: key=value, key:=<json>, key=@file, or nested:path@file
#[arg(long = "secret", short = 's')]
secrets: Vec<String>,
/// Output format: text (default on TTY), json, json-compact
#[arg(short, long = "output")]
output: Option<String>,
},
/// Search / read records. This is the primary read command for AI agents.
///
/// Supports fuzzy search (-q), exact lookup (--name), field extraction (-f),
/// summary view (--summary), pagination (--limit / --offset), and structured
/// output (-o json / json-compact). When stdout is not a TTY, output
/// defaults to json-compact automatically.
#[command(after_help = "EXAMPLES:
# Discover all records (summary, safe default limit)
secrets search --summary --limit 20
# Filter by namespace and kind
secrets search -n refining --kind service
# Exact lookup — returns 0 or 1 record
secrets search -n refining --kind service --name gitea
# Fuzzy keyword search (matches name, namespace, kind, tags, metadata)
secrets search -q mqtt
# Extract a single metadata field value
secrets search -n refining --kind service --name gitea -f metadata.url
# Multiple fields at once
secrets search -n refining --kind service --name gitea \\
-f metadata.url -f metadata.default_org
# Run a command with decrypted secrets only when needed
secrets run -n refining --kind service --name gitea -- printenv
# Paginate large result sets
secrets search -n refining --summary --limit 10 --offset 0
secrets search -n refining --summary --limit 10 --offset 10
# Sort by most recently updated
secrets search --sort updated --limit 5 --summary
# Non-TTY / pipe: output is json-compact by default
secrets search -n refining --kind service | jq '.[].name'")]
Search {
/// Filter by namespace, e.g. refining, ricnsmart
#[arg(short, long)]
namespace: Option<String>,
/// Filter by kind, e.g. server, service
#[arg(long)]
kind: Option<String>,
/// Exact name filter, e.g. gitea, i-example0abcd1234efgh
#[arg(long)]
name: Option<String>,
/// Filter by tag, e.g. --tag aliyun (repeatable for AND intersection)
#[arg(long)]
tag: Vec<String>,
/// Fuzzy keyword (matches name, namespace, kind, tags, metadata text)
#[arg(short, long)]
query: Option<String>,
/// Extract metadata field value(s) directly: metadata.<key> (repeatable)
#[arg(short = 'f', long = "field")]
fields: Vec<String>,
/// Return lightweight summary only (namespace, kind, name, tags, desc, updated_at)
#[arg(long)]
summary: bool,
/// Maximum number of records to return [default: 50]
#[arg(long, default_value = "50")]
limit: u32,
/// Skip this many records (for pagination)
#[arg(long, default_value = "0")]
offset: u32,
/// Sort order: name (default), updated, created
#[arg(long, default_value = "name")]
sort: String,
/// Output format: text (default on TTY), json, json-compact
#[arg(short, long = "output")]
output: Option<String>,
},
/// Delete one record precisely, or bulk-delete by namespace.
///
/// With --name: deletes exactly that record (--kind also required).
/// Without --name: bulk-deletes all records matching namespace + optional --kind.
/// Use --dry-run to preview bulk deletes before committing.
#[command(after_help = "EXAMPLES:
# Delete a single record (exact match)
secrets delete -n refining --kind service --name legacy-mqtt
# Preview what a bulk delete would remove (no writes)
secrets delete -n refining --dry-run
# Bulk-delete all records in a namespace
secrets delete -n ricnsmart
# Bulk-delete only server records in a namespace
secrets delete -n ricnsmart --kind server
# JSON output
secrets delete -n refining --kind service -o json")]
Delete {
/// Namespace, e.g. refining
#[arg(short, long)]
namespace: String,
/// Kind filter, e.g. server, service (required with --name; optional for bulk)
#[arg(long)]
kind: Option<String>,
/// Exact name of the record to delete (omit for bulk delete)
#[arg(long)]
name: Option<String>,
/// Preview what would be deleted without making any changes (bulk mode only)
#[arg(long)]
dry_run: bool,
/// Output format: text (default on TTY), json, json-compact
#[arg(short, long = "output")]
output: Option<String>,
},
/// Incrementally update an existing record (merge semantics; record must exist).
///
/// Only the fields you pass are changed — everything else is preserved.
/// Use --add-tag / --remove-tag to modify tags without touching other fields.
#[command(after_help = "EXAMPLES:
# Update a single metadata field (all other fields unchanged)
secrets update -n refining --kind server --name my-server -m ip=10.0.0.1
# Rotate a secret token
secrets update -n refining --kind service --name gitea -s token=<new-token>
# Update typed JSON metadata
secrets update -n refining --kind service --name gitea \\
-m deploy:strategy:='{\"type\":\"rolling\",\"batch\":2}' \\
-m runtime:max_open_conns:=20
# Add a tag and rotate password at the same time
secrets update -n refining --kind service --name gitea \\
--add-tag production -s token=<new-token>
# Remove a deprecated metadata field and a stale secret key
secrets update -n refining --kind service --name mqtt \\
--remove-meta old_port --remove-secret old_password
# Remove a nested field
secrets update -n refining --kind server --name my-server \\
--remove-secret credentials:content
# Remove a tag
secrets update -n refining --kind service --name gitea --remove-tag staging
# Update a nested secret field from a file
secrets update -n refining --kind server --name my-server \\
-s credentials:content@./keys/server.pem
# Update nested typed JSON fields
secrets update -n refining --kind service --name deploy-bot \\
-s auth:config:='{\"issuer\":\"gitea\",\"rotate\":true}' \\
-s auth:retry:=5
# Rotate shared PEM (all servers with key_ref=my-shared-key get the new key)
secrets update -n refining --kind key --name my-shared-key \\
-s content=@./keys/new-shared.pem")]
Update {
/// Namespace, e.g. refining, ricnsmart
#[arg(short, long)]
namespace: String,
/// Kind of record: server, service, key, ...
#[arg(long)]
kind: String,
/// Human-readable unique name
#[arg(long)]
name: String,
/// Add a tag (repeatable; does not affect existing tags)
#[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, key:=<json>, key=@file, or nested:path@file.
/// Use key_ref=<name> to reference a shared key entry (kind=key).
#[arg(long = "meta", short = 'm')]
meta: Vec<String>,
/// Delete a metadata field by key or nested path, e.g. old_port or credentials:content
#[arg(long = "remove-meta")]
remove_meta: Vec<String>,
/// Set or overwrite a secret field: key=value, key:=<json>, key=@file, or nested:path@file
#[arg(long = "secret", short = 's')]
secrets: Vec<String>,
/// Delete a secret field by key or nested path, e.g. old_password or credentials:content
#[arg(long = "remove-secret")]
remove_secrets: Vec<String>,
/// Output format: text (default on TTY), json, json-compact
#[arg(short, long = "output")]
output: Option<String>,
},
/// Manage CLI configuration (database connection, etc.)
#[command(after_help = "EXAMPLES:
# Configure the database URL (run once per device; persisted to config file)
secrets config set-db \"postgres://postgres:<password>@<host>:<port>/secrets\"
# Show current config (password is masked)
secrets config show
# Print path to the config file
secrets config path")]
Config {
#[command(subcommand)]
action: ConfigAction,
},
/// Show the change history for a record.
#[command(after_help = "EXAMPLES:
# Show last 20 versions for a service record
secrets history -n refining --kind service --name gitea
# Show last 5 versions
secrets history -n refining --kind service --name gitea --limit 5")]
History {
#[arg(short, long)]
namespace: String,
#[arg(long)]
kind: String,
#[arg(long)]
name: String,
/// Number of history entries to show [default: 20]
#[arg(long, default_value = "20")]
limit: u32,
/// Output format: text (default on TTY), json, json-compact
#[arg(short, long = "output")]
output: Option<String>,
},
/// Roll back a record to a previous version.
#[command(after_help = "EXAMPLES:
# Roll back to the most recent snapshot (undo last change)
secrets rollback -n refining --kind service --name gitea
# Roll back to a specific version number
secrets rollback -n refining --kind service --name gitea --to-version 3")]
Rollback {
#[arg(short, long)]
namespace: String,
#[arg(long)]
kind: String,
#[arg(long)]
name: String,
/// Target version to restore. Omit to restore the most recent snapshot.
#[arg(long)]
to_version: Option<i64>,
/// Output format: text (default on TTY), json, json-compact
#[arg(short, long = "output")]
output: Option<String>,
},
/// Run a command with secrets injected as environment variables.
///
/// Secrets are available only to the child process; the current shell
/// environment is not modified. The process exit code is propagated.
///
/// Use -s/--secret to inject only specific fields. Use --dry-run to preview
/// which variables would be injected without executing the command.
#[command(after_help = "EXAMPLES:
# Run a script with a single service's secrets injected
secrets run -n refining --kind service --name gitea -- ./deploy.sh
# Inject only specific fields (minimal exposure)
secrets run -n refining --kind service --name aliyun \\
-s access_key_id -s access_key_secret -- aliyun ecs DescribeInstances
# Run with a tag filter (all matched records merged)
secrets run --tag production -- env | grep GITEA
# With prefix
secrets run -n refining --kind service --name gitea --prefix GITEA -- printenv
# Preview which variables would be injected (no command executed)
secrets run -n refining --kind service --name gitea --dry-run
# Preview with field filter and JSON output
secrets run -n refining --kind service --name gitea -s token --dry-run -o json
# metadata.key_ref entries get key secrets merged (e.g. server + shared PEM)")]
Run {
#[arg(short, long)]
namespace: Option<String>,
#[arg(long)]
kind: Option<String>,
#[arg(long)]
name: Option<String>,
#[arg(long)]
tag: Vec<String>,
/// Only inject these secret field names (repeatable). Omit to inject all fields.
#[arg(long = "secret", short = 's')]
secret_fields: Vec<String>,
/// Prefix to prepend to every variable name (uppercased automatically)
#[arg(long, default_value = "")]
prefix: String,
/// Preview variables that would be injected without executing the command
#[arg(long)]
dry_run: bool,
/// Output format for --dry-run: json (default), json-compact, text
#[arg(short, long = "output")]
output: Option<String>,
/// Command and arguments to execute with injected environment
#[arg(last = true)]
command: Vec<String>,
},
/// Check for a newer version and update the binary in-place.
///
/// Downloads the latest release and replaces the current binary. No database connection or master key required.
/// Release URL defaults to the upstream server; override via SECRETS_UPGRADE_URL for self-hosted or fork.
#[command(after_help = "EXAMPLES:
# Check for updates only (no download)
secrets upgrade --check
# Download and install the latest version
secrets upgrade")]
Upgrade {
/// Only check if a newer version is available; do not download
#[arg(long)]
check: bool,
},
/// Export records to a file (JSON, TOML, or YAML).
///
/// Decrypts and exports all matched records. Requires master key unless --no-secrets is used.
#[command(after_help = "EXAMPLES:
# Export everything to JSON
secrets export --file backup.json
# Export a specific namespace to TOML
secrets export -n refining --file refining.toml
# Export a specific kind
secrets export -n refining --kind service --file services.yaml
# Export by tag
secrets export --tag production --file prod.json
# Export schema only (no decryption needed)
secrets export --no-secrets --file schema.json
# Print to stdout in YAML
secrets export -n refining --format yaml")]
Export {
/// Filter by namespace
#[arg(short, long)]
namespace: Option<String>,
/// Filter by kind, e.g. server, service
#[arg(long)]
kind: Option<String>,
/// Exact name filter
#[arg(long)]
name: Option<String>,
/// Filter by tag (repeatable)
#[arg(long)]
tag: Vec<String>,
/// Fuzzy keyword search
#[arg(short, long)]
query: Option<String>,
/// Output file path (format inferred from extension: .json / .toml / .yaml / .yml)
#[arg(long)]
file: Option<String>,
/// Explicit format: json, toml, or yaml (overrides file extension; required for stdout)
#[arg(long)]
format: Option<String>,
/// Omit secrets from output (no master key required)
#[arg(long)]
no_secrets: bool,
},
/// Import records from a file (JSON, TOML, or YAML).
///
/// Reads an export file and inserts or updates entries. Requires master key to re-encrypt secrets.
#[command(after_help = "EXAMPLES:
# Import a JSON backup (conflict = error by default)
secrets import backup.json
# Import and overwrite existing records
secrets import --force refining.toml
# Preview what would be imported (no writes)
secrets import --dry-run backup.yaml
# JSON output for the import summary
secrets import backup.json -o json")]
Import {
/// Input file path (format inferred from extension: .json / .toml / .yaml / .yml)
file: String,
/// Overwrite existing records on conflict (default: error and abort)
#[arg(long)]
force: bool,
/// Preview operations without writing to the database
#[arg(long)]
dry_run: bool,
/// Output format: text (default on TTY), json, json-compact
#[arg(short, long = "output")]
output: Option<String>,
},
}
#[derive(Subcommand)]
enum ConfigAction {
/// Save database URL to config file (~/.config/secrets/config.toml)
SetDb {
/// PostgreSQL connection string, e.g. postgres://user:pass@<host>:<port>/dbname
url: String,
},
/// Show current configuration (password masked)
Show,
/// Print path to the config file
Path,
}
#[tokio::main]
async fn main() -> Result<()> {
load_dotenv();
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 subcommand needs no database or master key
if let Commands::Config { action } = cli.command {
return commands::config::run(action).await;
}
// upgrade needs no database or master key either
if let Commands::Upgrade { check } = cli.command {
return commands::upgrade::run(check).await;
}
let db_url = config::resolve_db_url(&cli.db_url)?;
let pool = db::create_pool(&db_url).await?;
db::migrate(&pool).await?;
// init needs a pool but sets up the master key — handle before loading it
if let Commands::Init = cli.command {
return commands::init::run(&pool).await;
}
// All remaining commands require the master key from the OS Keychain,
// except delete which operates on plaintext metadata only.
match cli.command {
Commands::Init | Commands::Config { .. } | Commands::Upgrade { .. } => unreachable!(),
Commands::Add {
namespace,
kind,
name,
tags,
meta,
secrets,
output,
} => {
let master_key = crypto::load_master_key()?;
let _span =
tracing::info_span!("cmd", command = "add", %namespace, %kind, %name).entered();
let out = resolve_output_mode(output.as_deref())?;
commands::add::run(
&pool,
commands::add::AddArgs {
namespace: &namespace,
kind: &kind,
name: &name,
tags: &tags,
meta_entries: &meta,
secret_entries: &secrets,
output: out,
},
&master_key,
)
.await?;
}
Commands::Search {
namespace,
kind,
name,
tag,
query,
fields,
summary,
limit,
offset,
sort,
output,
} => {
let _span = tracing::info_span!("cmd", command = "search").entered();
let out = resolve_output_mode(output.as_deref())?;
commands::search::run(
&pool,
commands::search::SearchArgs {
namespace: namespace.as_deref(),
kind: kind.as_deref(),
name: name.as_deref(),
tags: &tag,
query: query.as_deref(),
fields: &fields,
summary,
limit,
offset,
sort: &sort,
output: out,
},
)
.await?;
}
Commands::Delete {
namespace,
kind,
name,
dry_run,
output,
} => {
let _span =
tracing::info_span!("cmd", command = "delete", %namespace, ?kind, ?name).entered();
let out = resolve_output_mode(output.as_deref())?;
commands::delete::run(
&pool,
commands::delete::DeleteArgs {
namespace: &namespace,
kind: kind.as_deref(),
name: name.as_deref(),
dry_run,
output: out,
},
)
.await?;
}
Commands::Update {
namespace,
kind,
name,
add_tags,
remove_tags,
meta,
remove_meta,
secrets,
remove_secrets,
output,
} => {
let master_key = crypto::load_master_key()?;
let _span =
tracing::info_span!("cmd", command = "update", %namespace, %kind, %name).entered();
let out = resolve_output_mode(output.as_deref())?;
commands::update::run(
&pool,
commands::update::UpdateArgs {
namespace: &namespace,
kind: &kind,
name: &name,
add_tags: &add_tags,
remove_tags: &remove_tags,
meta_entries: &meta,
remove_meta: &remove_meta,
secret_entries: &secrets,
remove_secrets: &remove_secrets,
output: out,
},
&master_key,
)
.await?;
}
Commands::History {
namespace,
kind,
name,
limit,
output,
} => {
let out = resolve_output_mode(output.as_deref())?;
commands::history::run(
&pool,
commands::history::HistoryArgs {
namespace: &namespace,
kind: &kind,
name: &name,
limit,
output: out,
},
)
.await?;
}
Commands::Rollback {
namespace,
kind,
name,
to_version,
output,
} => {
let master_key = crypto::load_master_key()?;
let out = resolve_output_mode(output.as_deref())?;
commands::rollback::run(
&pool,
commands::rollback::RollbackArgs {
namespace: &namespace,
kind: &kind,
name: &name,
to_version,
output: out,
},
&master_key,
)
.await?;
}
Commands::Run {
namespace,
kind,
name,
tag,
secret_fields,
prefix,
dry_run,
output,
command,
} => {
let master_key = crypto::load_master_key()?;
let out = resolve_output_mode(output.as_deref())?;
if !dry_run && command.is_empty() {
anyhow::bail!(
"No command specified. Usage: secrets run [filter flags] -- <command> [args]"
);
}
commands::run::run_exec(
&pool,
commands::run::RunArgs {
namespace: namespace.as_deref(),
kind: kind.as_deref(),
name: name.as_deref(),
tags: &tag,
secret_fields: &secret_fields,
prefix: &prefix,
dry_run,
output: out,
command: &command,
},
&master_key,
)
.await?;
}
Commands::Export {
namespace,
kind,
name,
tag,
query,
file,
format,
no_secrets,
} => {
let master_key = if no_secrets {
None
} else {
Some(crypto::load_master_key()?)
};
let _span = tracing::info_span!("cmd", command = "export").entered();
commands::export_cmd::run(
&pool,
commands::export_cmd::ExportArgs {
namespace: namespace.as_deref(),
kind: kind.as_deref(),
name: name.as_deref(),
tags: &tag,
query: query.as_deref(),
file: file.as_deref(),
format: format.as_deref(),
no_secrets,
},
master_key.as_ref(),
)
.await?;
}
Commands::Import {
file,
force,
dry_run,
output,
} => {
let master_key = crypto::load_master_key()?;
let _span = tracing::info_span!("cmd", command = "import").entered();
let out = resolve_output_mode(output.as_deref())?;
commands::import_cmd::run(
&pool,
commands::import_cmd::ImportArgs {
file: &file,
force,
dry_run,
output: out,
},
&master_key,
)
.await?;
}
}
Ok(())
}

View File

@@ -1,60 +0,0 @@
use chrono::{DateTime, Local, Utc};
use std::str::FromStr;
/// Output format for all commands.
#[derive(Debug, Clone, Default, PartialEq)]
pub enum OutputMode {
/// Human-readable text (default when stdout is a TTY)
#[default]
Text,
/// Pretty-printed JSON
Json,
/// Single-line JSON (default when stdout is NOT a TTY, e.g. piped to jq)
JsonCompact,
}
impl FromStr for OutputMode {
type Err = anyhow::Error;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"text" => Ok(Self::Text),
"json" => Ok(Self::Json),
"json-compact" => Ok(Self::JsonCompact),
other => Err(anyhow::anyhow!(
"Unknown output format '{}'. Valid: text, json, json-compact",
other
)),
}
}
}
/// Resolve the effective output mode.
/// - Explicit value from `--output` takes priority.
/// - Default is always `Json` (AI-first); use `-o text` for human-readable output.
pub fn resolve_output_mode(explicit: Option<&str>) -> anyhow::Result<OutputMode> {
if let Some(s) = explicit {
return s.parse();
}
Ok(OutputMode::Json)
}
/// Format a UTC timestamp for local human-readable output.
pub fn format_local_time(dt: DateTime<Utc>) -> String {
dt.with_timezone(&Local)
.format("%Y-%m-%d %H:%M:%S %:z")
.to_string()
}
/// Print a JSON value to stdout in the requested output mode.
/// - `Json` → pretty-printed
/// - `JsonCompact` → single line
/// - `Text` → no-op (caller is responsible for the text branch)
pub fn print_json(value: &serde_json::Value, mode: &OutputMode) -> anyhow::Result<()> {
match mode {
OutputMode::Json => println!("{}", serde_json::to_string_pretty(value)?),
OutputMode::JsonCompact => println!("{}", serde_json::to_string(value)?),
OutputMode::Text => {}
}
Ok(())
}

View File

@@ -1,3 +0,0 @@
-----BEGIN EXAMPLE KEY PLACEHOLDER-----
This file is for local dev/testing. Replace with a real key when needed.
-----END EXAMPLE KEY PLACEHOLDER-----