Compare commits

...

26 Commits

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

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

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

Bump version to 0.9.4

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

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

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

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

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

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

Bump version to 0.7.5

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

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

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

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

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

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

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

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

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

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

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

Made-with: Cursor
2026-03-18 20:10:13 +08:00
29 changed files with 5592 additions and 660 deletions

View File

@@ -7,7 +7,6 @@ on:
- 'src/**' - 'src/**'
- 'Cargo.toml' - 'Cargo.toml'
- 'Cargo.lock' - 'Cargo.lock'
- '.gitea/workflows/secrets.yml'
concurrency: concurrency:
group: ${{ github.workflow }}-${{ github.ref }} group: ${{ github.workflow }}-${{ github.ref }}
@@ -18,6 +17,7 @@ permissions:
env: env:
BINARY_NAME: secrets BINARY_NAME: secrets
SECRETS_UPGRADE_URL: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}/releases/latest
CARGO_INCREMENTAL: 0 CARGO_INCREMENTAL: 0
CARGO_NET_RETRY: 10 CARGO_NET_RETRY: 10
CARGO_TERM_COLOR: always CARGO_TERM_COLOR: always
@@ -56,6 +56,13 @@ jobs:
echo "将创建新版本 ${tag}" echo "将创建新版本 ${tag}"
fi fi
- name: 严格拦截重复版本
if: steps.ver.outputs.tag_exists == 'true'
run: |
echo "错误: 版本 ${{ steps.ver.outputs.tag }} 已存在,禁止重复发版。"
echo "请先 bump Cargo.toml 中的 version并执行 cargo build 同步 Cargo.lock。"
exit 1
- name: 创建 Tag - name: 创建 Tag
if: steps.ver.outputs.tag_exists == 'false' if: steps.ver.outputs.tag_exists == 'false'
run: | run: |
@@ -204,9 +211,13 @@ jobs:
bin="target/x86_64-unknown-linux-musl/release/${{ env.BINARY_NAME }}" bin="target/x86_64-unknown-linux-musl/release/${{ env.BINARY_NAME }}"
archive="${{ env.BINARY_NAME }}-${tag}-x86_64-linux-musl.tar.gz" archive="${{ env.BINARY_NAME }}-${tag}-x86_64-linux-musl.tar.gz"
tar -czf "$archive" -C "$(dirname "$bin")" "$(basename "$bin")" tar -czf "$archive" -C "$(dirname "$bin")" "$(basename "$bin")"
sha256sum "$archive" > "${archive}.sha256"
curl -fsS -H "Authorization: token $RELEASE_TOKEN" \ curl -fsS -H "Authorization: token $RELEASE_TOKEN" \
-F "attachment=@${archive}" \ -F "attachment=@${archive}" \
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/releases/${{ needs.version.outputs.release_id }}/assets" "${{ 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"
- name: 飞书通知 - name: 飞书通知
if: always() if: always()
@@ -229,7 +240,7 @@ jobs:
curl -sS -H "Content-Type: application/json" -X POST -d "$payload" "$WEBHOOK_URL" curl -sS -H "Content-Type: application/json" -X POST -d "$payload" "$WEBHOOK_URL"
build-macos: build-macos:
name: Build (aarch64-apple-darwin) name: Build (macOS aarch64 + x86_64)
needs: [version, check] needs: [version, check]
runs-on: darwin-arm64 runs-on: darwin-arm64
timeout-minutes: 15 timeout-minutes: 15
@@ -241,6 +252,7 @@ jobs:
fi fi
source "$HOME/.cargo/env" 2>/dev/null || true source "$HOME/.cargo/env" 2>/dev/null || true
rustup target add aarch64-apple-darwin rustup target add aarch64-apple-darwin
rustup target add x86_64-apple-darwin
echo "$HOME/.cargo/bin" >> "$GITHUB_PATH" echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
- uses: actions/checkout@v4 - uses: actions/checkout@v4
@@ -253,12 +265,14 @@ jobs:
~/.cargo/registry/cache ~/.cargo/registry/cache
~/.cargo/git/db ~/.cargo/git/db
target target
key: cargo-aarch64-apple-darwin-${{ hashFiles('Cargo.lock') }} key: cargo-macos-${{ hashFiles('Cargo.lock') }}
restore-keys: | restore-keys: |
cargo-aarch64-apple-darwin- cargo-macos-
- run: cargo build --release --locked --target aarch64-apple-darwin - 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/aarch64-apple-darwin/release/${{ env.BINARY_NAME }}
- run: strip -x target/x86_64-apple-darwin/release/${{ env.BINARY_NAME }}
- name: 上传 Release 产物 - name: 上传 Release 产物
if: needs.version.outputs.release_id != '' if: needs.version.outputs.release_id != ''
@@ -267,12 +281,29 @@ jobs:
run: | run: |
[ -z "$RELEASE_TOKEN" ] && exit 0 [ -z "$RELEASE_TOKEN" ] && exit 0
tag="${{ needs.version.outputs.tag }}" tag="${{ needs.version.outputs.tag }}"
bin="target/aarch64-apple-darwin/release/${{ env.BINARY_NAME }}" release_id="${{ needs.version.outputs.release_id }}"
archive="${{ env.BINARY_NAME }}-${tag}-aarch64-macos.tar.gz"
tar -czf "$archive" -C "$(dirname "$bin")" "$(basename "$bin")" 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" \ curl -fsS -H "Authorization: token $RELEASE_TOKEN" \
-F "attachment=@${archive}" \ -F "attachment=@${arm_archive}" \
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/releases/${{ needs.version.outputs.release_id }}/assets" "${{ 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: 飞书通知 - name: 飞书通知
if: always() if: always()
@@ -285,8 +316,9 @@ jobs:
url="${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_number }}" url="${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_number }}"
result="${{ job.status }}" result="${{ job.status }}"
if [ "$result" = "success" ]; then icon="✅"; else icon="❌"; fi if [ "$result" = "success" ]; then icon="✅"; else icon="❌"; fi
msg="secrets macOS 构建${icon} msg="secrets macOS 双架构构建${icon}
版本:${tag} 版本:${tag}
目标aarch64-apple-darwin, x86_64-apple-darwin
提交:${commit} 提交:${commit}
作者:${{ github.actor }} 作者:${{ github.actor }}
详情:${url}" 详情:${url}"
@@ -302,11 +334,14 @@ jobs:
- name: 安装依赖 - name: 安装依赖
shell: pwsh shell: pwsh
run: | run: |
$cargoBin = Join-Path $env:USERPROFILE ".cargo\bin"
if (-not (Get-Command cargo -ErrorAction SilentlyContinue)) { if (-not (Get-Command cargo -ErrorAction SilentlyContinue)) {
Invoke-WebRequest -Uri "https://win.rustup.rs/x86_64" -OutFile rustup-init.exe Invoke-WebRequest -Uri "https://win.rustup.rs/x86_64" -OutFile rustup-init.exe
.\rustup-init.exe -y --default-toolchain stable .\rustup-init.exe -y --default-toolchain stable
Remove-Item rustup-init.exe 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 rustup target add x86_64-pc-windows-msvc
- uses: actions/checkout@v4 - uses: actions/checkout@v4
@@ -338,10 +373,15 @@ jobs:
$bin = "target\x86_64-pc-windows-msvc\release\${{ env.BINARY_NAME }}.exe" $bin = "target\x86_64-pc-windows-msvc\release\${{ env.BINARY_NAME }}.exe"
$archive = "${{ env.BINARY_NAME }}-${tag}-x86_64-windows.zip" $archive = "${{ env.BINARY_NAME }}-${tag}-x86_64-windows.zip"
Compress-Archive -Path $bin -DestinationPath $archive -Force 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" $url = "${{ github.server_url }}/api/v1/repos/${{ github.repository }}/releases/${{ needs.version.outputs.release_id }}/assets"
Invoke-RestMethod -Uri $url -Method Post ` Invoke-RestMethod -Uri $url -Method Post `
-Headers @{ "Authorization" = "token $env:RELEASE_TOKEN" } ` -Headers @{ "Authorization" = "token $env:RELEASE_TOKEN" } `
-Form @{ attachment = Get-Item $archive } -Form @{ attachment = Get-Item $archive }
Invoke-RestMethod -Uri $url -Method Post `
-Headers @{ "Authorization" = "token $env:RELEASE_TOKEN" } `
-Form @{ attachment = Get-Item "${archive}.sha256" }
- name: 飞书通知 - name: 飞书通知
if: always() if: always()
@@ -362,7 +402,7 @@ jobs:
publish-release: publish-release:
name: 发布草稿 Release name: 发布草稿 Release
needs: [version, build-linux] needs: [version, build-linux, build-macos, build-windows]
if: always() && needs.version.outputs.release_id != '' if: always() && needs.version.outputs.release_id != ''
runs-on: debian runs-on: debian
timeout-minutes: 5 timeout-minutes: 5
@@ -376,8 +416,11 @@ jobs:
[ -z "$RELEASE_TOKEN" ] && exit 0 [ -z "$RELEASE_TOKEN" ] && exit 0
linux_r="${{ needs.build-linux.result }}" linux_r="${{ needs.build-linux.result }}"
if [ "$linux_r" != "success" ]; then macos_r="${{ needs.build-macos.result }}"
echo "Linux 构建未成功 (${linux_r}),保留草稿 Release" 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 exit 0
fi fi
@@ -408,15 +451,16 @@ jobs:
commit=$(git log -1 --pretty=format:"%s" 2>/dev/null || echo "N/A") commit=$(git log -1 --pretty=format:"%s" 2>/dev/null || echo "N/A")
url="${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_number }}" url="${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_number }}"
check_r="${{ needs.version.result }}"
linux_r="${{ needs.build-linux.result }}" linux_r="${{ needs.build-linux.result }}"
macos_r="${{ needs.build-macos.result }}"
windows_r="${{ needs.build-windows.result }}"
publish_r="${{ job.status }}" publish_r="${{ job.status }}"
icon() { case "$1" in success) echo "✅";; skipped) echo "⏭";; *) echo "❌";; esac; } icon() { case "$1" in success) echo "✅";; skipped) echo "⏭";; *) echo "❌";; esac; }
if [ "$linux_r" = "success" ] && [ "$publish_r" = "success" ]; then if [ "$linux_r" = "success" ] && [ "$macos_r" = "success" ] && [ "$windows_r" = "success" ] && [ "$publish_r" = "success" ]; then
status="发布成功 ✅" status="发布成功 ✅"
elif [ "$linux_r" != "success" ]; then elif [ "$linux_r" != "success" ] || [ "$macos_r" != "success" ] || [ "$windows_r" != "success" ]; then
status="构建失败 ❌" status="构建失败 ❌"
else else
status="发布失败 ❌" status="发布失败 ❌"
@@ -430,7 +474,7 @@ jobs:
msg="secrets ${status} msg="secrets ${status}
${version_line} ${version_line}
linux $(icon "$linux_r") | Release $(icon "$publish_r") linux $(icon "$linux_r") | macOS $(icon "$macos_r") | windows $(icon "$windows_r") | Release $(icon "$publish_r")
提交:${commit} 提交:${commit}
作者:${{ github.actor }} 作者:${{ github.actor }}
详情:${url}" 详情:${url}"

8
.vscode/tasks.json vendored
View File

@@ -104,9 +104,9 @@
"dependsOn": "build" "dependsOn": "build"
}, },
{ {
"label": "test: search with secrets revealed", "label": "test: run service secrets",
"type": "shell", "type": "shell",
"command": "./target/debug/secrets search -n refining --kind service --show-secrets", "command": "./target/debug/secrets run -n refining --kind service --name gitea -- printenv",
"dependsOn": "build" "dependsOn": "build"
}, },
{ {
@@ -118,7 +118,7 @@
{ {
"label": "test: add + delete roundtrip", "label": "test: add + delete roundtrip",
"type": "shell", "type": "shell",
"command": "echo '--- add ---' && ./target/debug/secrets add -n test --kind demo --name roundtrip-test --tag test -m foo=bar -s password=secret123 && echo '--- search ---' && ./target/debug/secrets search -n test --show-secrets && echo '--- delete ---' && ./target/debug/secrets delete -n test --kind demo --name roundtrip-test && echo '--- verify deleted ---' && ./target/debug/secrets search -n test", "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" "dependsOn": "build"
}, },
{ {
@@ -142,7 +142,7 @@
{ {
"label": "test: add with file secret", "label": "test: add with file secret",
"type": "shell", "type": "shell",
"command": "echo '--- add key from file ---' && ./target/debug/secrets add -n test --kind key --name test-key --tag test -s content=@./refining/keys/Vultr && echo '--- verify ---' && ./target/debug/secrets search -n test --kind key --show-secrets && echo '--- cleanup ---' && ./target/debug/secrets delete -n test --kind key --name test-key", "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" "dependsOn": "build"
} }
] ]

466
AGENTS.md
View File

@@ -1,6 +1,13 @@
# Secrets CLI — AGENTS.md # Secrets CLI — AGENTS.md
跨设备密钥与配置管理 CLI 工具,将 refining / ricnsmart 两个项目的服务器信息、服务凭据存储到 PostgreSQL 18供 AI 工具读取上下文。 ## 提交 / 发版硬规则(优先于下文其他说明)
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
## 项目结构 ## 项目结构
@@ -8,19 +15,28 @@
secrets/ secrets/
src/ src/
main.rs # CLI 入口clap 命令定义auto-migrate--verbose 全局参数 main.rs # CLI 入口clap 命令定义auto-migrate--verbose 全局参数
output.rs # OutputMode 枚举 + TTY 检测TTY→text非 TTY→json-compact output.rs # OutputMode 枚举(默认 json-o text 供人类使用
config.rs # 配置读写:~/.config/secrets/config.tomldatabase_url config.rs # 配置读写:~/.config/secrets/config.tomldatabase_url
db.rs # PgPool 创建 + 建表/索引(幂等,含 audit_log db.rs # PgPool 创建 + 建表/索引(DROP+CREATE含所有表
models.rs # Secret 结构体sqlx::FromRow + serde crypto.rs # AES-256-GCM 加解密、Argon2id 派生、OS 钥匙串
audit.rs # 审计写入:向 audit_log 表记录所有写操作 models.rs # Entry + SecretField 结构体sqlx::FromRow + serde
audit.rs # 审计写入log_tx事务内
commands/ commands/
add.rs # add 命令upsert支持 --meta key=value / --secret key=@file / -o json init.rs # init 命令:主密钥初始化(每台设备一次)
add.rs # add 命令upsert entries + 逐字段写入 secrets含历史快照
config.rs # config 命令set-db / show / path持久化 database_url config.rs # config 命令set-db / show / path持久化 database_url
search.rs # search 命令:多条件查询,-f/-o/--summary/--limit/--offset/--sort search.rs # search 命令:多条件查询,展示 secrets 字段 schema无需 master_key
delete.rs # delete 命令 delete.rs # delete 命令事务化CASCADE 删除 secrets含历史快照
update.rs # update 命令:增量更新(合并 tags/metadata/encrypted 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重新加密写入
scripts/ scripts/
seed-data.sh # 从 refining/ricnsmart config.toml 导入全量数据 release-check.sh # 发版前检查版本号/tag 是否重复,并执行 fmt/clippy/test
setup-gitea-actions.sh # 配置 Gitea Actions 变量与 Secrets
.gitea/workflows/ .gitea/workflows/
secrets.yml # CIfmt + clippy + musl 构建 + Release 上传 + 飞书通知 secrets.yml # CIfmt + clippy + musl 构建 + Release 上传 + 飞书通知
.vscode/tasks.json # 本地测试任务build / config / search / add+delete / update / audit 等) .vscode/tasks.json # 本地测试任务build / config / search / add+delete / update / audit 等)
@@ -31,50 +47,125 @@ secrets/
- **Host**: `<host>:<port>` - **Host**: `<host>:<port>`
- **Database**: `secrets` - **Database**: `secrets`
- **连接串**: `postgres://postgres:<password>@<host>:<port>/secrets` - **连接串**: `postgres://postgres:<password>@<host>:<port>/secrets`
- **表**: `secrets`表)+ `audit_log`(审计表)首次连接自动建表auto-migrate - **表**: `entries`(主表)+ `secrets`加密字段子表)+ `entries_history` + `secrets_history` + `audit_log` + `kv_config`首次连接自动建表auto-migrate
### 表结构 ### 表结构
```sql ```sql
secrets ( entries (
id UUID PRIMARY KEY DEFAULT uuidv7(), -- PG18 时间有序 UUID id UUID PRIMARY KEY DEFAULT uuidv7(), -- PG18 时间有序 UUID
namespace VARCHAR(64) NOT NULL, -- 一级隔离: "refining" | "ricnsmart" namespace VARCHAR(64) NOT NULL, -- 一级隔离: "refining" | "ricnsmart"
kind VARCHAR(64) NOT NULL, -- 类型: "server" | "service"(可扩展) kind VARCHAR(64) NOT NULL, -- 类型: "server" | "service" | "key"(可扩展)
name VARCHAR(256) NOT NULL, -- 人类可读标识 name VARCHAR(256) NOT NULL, -- 人类可读标识
tags TEXT[] NOT NULL DEFAULT '{}', -- 灵活标签: ["aliyun","hongkong"] tags TEXT[] NOT NULL DEFAULT '{}', -- 灵活标签: ["aliyun","hongkong"]
metadata JSONB NOT NULL DEFAULT '{}', -- 明文描述: ip, desc, domains, location... metadata JSONB NOT NULL DEFAULT '{}', -- 明文描述: ip, desc, domains, location...
encrypted JSONB NOT NULL DEFAULT '{}', -- 敏感数据: ssh_key, password, token... version BIGINT NOT NULL DEFAULT 1, -- 乐观锁版本号,每次写操作自增
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(namespace, kind, name) UNIQUE(namespace, kind, name)
) )
``` ```
```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
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)
)
```
```sql
kv_config (
key TEXT PRIMARY KEY, -- 如 'argon2_salt'
value BYTEA NOT NULL -- Argon2id salt首台设备 init 时生成
)
```
### audit_log 表结构 ### audit_log 表结构
```sql ```sql
audit_log ( audit_log (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
action VARCHAR(32) NOT NULL, -- 'add' | 'update' | 'delete' action VARCHAR(32) NOT NULL, -- 'add' | 'update' | 'delete' | 'rollback'
namespace VARCHAR(64) NOT NULL, namespace VARCHAR(64) NOT NULL,
kind VARCHAR(64) NOT NULL, kind VARCHAR(64) NOT NULL,
name VARCHAR(256) NOT NULL, name VARCHAR(256) NOT NULL,
detail JSONB NOT NULL DEFAULT '{}', -- 变更摘要tags/meta keys/secret keys不含 value detail JSONB NOT NULL DEFAULT '{}', -- 变更摘要tags/meta keys/secret keys不含 value
actor VARCHAR(128) NOT NULL DEFAULT '', -- 操作者($USER 环境变量) actor VARCHAR(128) NOT NULL DEFAULT '', -- 操作者($USER 环境变量)
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() created_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()
)
```
### secrets_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()
)
```
### 字段职责划分 ### 字段职责划分
| 字段 | 存什么 | 示例 | | 字段 | 存什么 | 示例 |
|------|--------|------| |------|--------|------|
| `namespace` | 项目/团队隔离 | `refining`, `ricnsmart` | | `namespace` | 项目/团队隔离 | `refining`, `ricnsmart` |
| `kind` | 记录类型 | `server`, `service` | | `kind` | 记录类型 | `server`, `service`, `key` |
| `name` | 唯一标识名 | `i-uf63f2uookgs5uxmrdyc`, `gitea` | | `name` | 唯一标识名 | `i-example0abcd1234efgh`, `gitea` |
| `tags` | 多维分类标签 | `["aliyun","hongkong","ricn"]` | | `tags` | 多维分类标签 | `["aliyun","hongkong","ricn"]` |
| `metadata` | 明文非敏感信息 | `{"ip":"47.243.154.187","desc":"Grafana","domains":["..."]}` | | `metadata` | 明文非敏感信息 | `{"ip":"192.0.2.1","desc":"Grafana","key_ref":"my-shared-key"}` |
| `encrypted` | 敏感凭据MVP 阶段明文存储,后续对 value 加密) | `{"ssh_key":"-----BEGIN...","password":"..."}` | | `secrets.field_name` | 加密字段名(明文) | `"username"`, `"token"`, `"ssh_key"` |
| `secrets.encrypted` | 仅加密值本身 | AES-256-GCM 密文 |
### PEM 共享机制key_ref
同一 PEM 被多台服务器共享时,将 PEM 存为独立的 `kind=key` 记录,服务器通过 `metadata.key_ref` 引用:
```bash
# 1. 存共享 PEM
secrets add -n refining --kind key --name my-shared-key \
--tag aliyun --tag hongkong \
-s content=@./keys/my-shared-key.pem
# 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
```
## 数据库配置 ## 数据库配置
@@ -86,8 +177,25 @@ secrets config show # 查看当前配置(密码脱敏)
secrets config path # 打印配置文件路径 secrets config path # 打印配置文件路径
``` ```
`set-db` 会先验证连接可用,成功后才写入配置文件;连接失败时提示 "Database connection failed" 且不修改配置。
配置文件:`~/.config/secrets/config.toml`,权限 0600。`--db-url` 参数可一次性覆盖。 配置文件:`~/.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 命令 ## CLI 命令
### AI 使用主路径 ### AI 使用主路径
@@ -95,29 +203,38 @@ secrets config path # 打印配置文件路径
**读取一律用 `search`,写入用 `add` / `update`,避免反复查帮助。** **读取一律用 `search`,写入用 `add` / `update`,避免反复查帮助。**
输出格式规则: 输出格式规则:
- TTY终端直接运行→ 默认 `text` - 默认始终输出 `json`pretty-printed无论 TTY 还是管道
- 非 TTY管道/重定向/AI 调用)→ 自动 `json-compact` - 显式 `-o json-compact` → 单行 JSON管道处理时更紧凑
- 显式 `-o json`美化 JSON - 显式 `-o text`人类可读文本格式
- 显式 `-o env` → KEY=VALUE可 source
--- ---
### init — 主密钥初始化(每台设备一次)
```bash
# 首次设备:生成 Argon2id salt 并存库,派生主密钥后存 OS 钥匙串
secrets init
# 后续设备:复用已有 salt派生主密钥后存钥匙串主密码需与首台相同
secrets init
```
### search — 发现与读取 ### search — 发现与读取
```bash ```bash
# 参数说明(带典型值) # 参数说明(带典型值)
# -n / --namespace refining | ricnsmart # -n / --namespace refining | ricnsmart
# --kind server | service # --kind server | service
# --name gitea | i-uf63f2uookgs5uxmrdyc | mqtt # --name gitea | i-example0abcd1234efgh | mqtt
# --tag aliyun | hongkong | production # --tag aliyun | hongkong | production
# -q / --query mqtt | grafana | gitea (模糊匹配 name/namespace/kind/tags/metadata # -q / --query mqtt | grafana | gitea (模糊匹配 name/namespace/kind/tags/metadata
# --show-secrets 不带值的 flag显示 encrypted 字段内容 # secrets schema search 默认展示 secrets 字段名、类型与长度(无需 master_key
# -f / --field metadata.ip | metadata.url | secret.token | secret.ssh_key # -f / --field metadata.ip | metadata.url | metadata.default_org
# --summary 不带值的 flag仅返回摘要name/tags/desc/updated_at # --summary 不带值的 flag仅返回摘要name/tags/desc/updated_at
# --limit 20 | 50默认 50 # --limit 20 | 50默认 50
# --offset 0 | 10 | 20分页偏移 # --offset 0 | 10 | 20分页偏移
# --sort name默认| updated | created # --sort name默认| updated | created
# -o / --output text | json | json-compact | env # -o / --output text | json | json-compact
# 发现概览(起步推荐) # 发现概览(起步推荐)
secrets search --summary --limit 20 secrets search --summary --limit 20
@@ -126,21 +243,23 @@ secrets search --sort updated --limit 10 --summary
# 精确定位单条记录 # 精确定位单条记录
secrets search -n refining --kind service --name gitea secrets search -n refining --kind service --name gitea
secrets search -n refining --kind server --name i-uf63f2uookgs5uxmrdyc secrets search -n refining --kind server --name i-example0abcd1234efgh
# 精确定位并获取完整内容(secrets # 精确定位并获取完整内容secrets 保持加密占位
secrets search -n refining --kind service --name gitea -o json --show-secrets secrets search -n refining --kind service --name gitea -o json
# 直接提取字段值(最短路径-f secret.* 自动解锁 secrets # 直接提取 metadata 字段值(最短路径)
secrets search -n refining --kind service --name gitea -f secret.token
secrets search -n refining --kind service --name gitea -f metadata.url secrets search -n refining --kind service --name gitea -f metadata.url
secrets search -n refining --kind service --name gitea \ secrets search -n refining --kind service --name gitea \
-f metadata.url -f metadata.default_org -f secret.token -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 mqtt
secrets search -q grafana secrets search -q grafana
secrets search -q 47.117 secrets search -q 192.0.2
# 按条件过滤 # 按条件过滤
secrets search -n refining --kind service secrets search -n refining --kind service
@@ -152,13 +271,8 @@ secrets search --tag aliyun --summary
secrets search -n refining --summary --limit 10 --offset 0 secrets search -n refining --summary --limit 10 --offset 0
secrets search -n refining --summary --limit 10 --offset 10 secrets search -n refining --summary --limit 10 --offset 10
# 管道 / AI 调用(非 TTY 自动 json-compact # 管道 / AI 调用(默认 json直接可解析
secrets search -n refining --kind service | jq '.[].name' secrets search -n refining --kind service | jq '.[].name'
secrets search -n refining --kind service --name gitea --show-secrets | jq '.secrets.token'
# 导出为 env 文件(单条记录)
secrets search -n refining --kind service --name gitea -o env --show-secrets \
> ~/.config/gitea/config.env
``` ```
--- ---
@@ -169,27 +283,38 @@ secrets search -n refining --kind service --name gitea -o env --show-secrets \
# 参数说明(带典型值) # 参数说明(带典型值)
# -n / --namespace refining | ricnsmart # -n / --namespace refining | ricnsmart
# --kind server | service # --kind server | service
# --name gitea | i-uf63f2uookgs5uxmrdyc # --name gitea | i-example0abcd1234efgh
# --tag aliyun | hongkong可重复 # --tag aliyun | hongkong可重复
# -m / --meta ip=47.117.131.22 | desc="Aliyun ECS" | url=https://...(可重复) # -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可重复 # -s / --secret token=<value> | ssh_key=@./key.pem | password=secret123 | credentials:content@./key.pem(可重复)
# 添加服务器 # 添加服务器
secrets add -n refining --kind server --name i-uf63f2uookgs5uxmrdyc \ secrets add -n refining --kind server --name i-example0abcd1234efgh \
--tag aliyun --tag shanghai \ --tag aliyun --tag shanghai \
-m ip=47.117.131.22 -m desc="Aliyun Shanghai ECS" \ -m ip=10.0.0.1 -m desc="Aliyun Shanghai ECS" \
-s username=root -s ssh_key=@./keys/voson_shanghai_e.pem -s username=root -s ssh_key=@./keys/deploy-key.pem
# 添加服务凭据 # 添加服务凭据
secrets add -n refining --kind service --name gitea \ secrets add -n refining --kind service --name gitea \
--tag gitea \ --tag gitea \
-m url=https://gitea.refining.dev -m default_org=refining -m username=voson \ -m url=https://code.example.com -m default_org=refining -m username=voson \
-s token=<token> -s runner_token=<runner_token> -s token=<token> -s runner_token=<runner_token>
# 从文件读取 token # 从文件读取 token
secrets add -n ricnsmart --kind service --name mqtt \ secrets add -n ricnsmart --kind service --name mqtt \
-m host=mqtt.ricnsmart.com -m port=1883 \ -m host=mqtt.example.com -m port=1883 \
-s password=@./mqtt_password.txt -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
``` ```
--- ---
@@ -202,16 +327,16 @@ secrets add -n ricnsmart --kind service --name mqtt \
# 参数说明(带典型值) # 参数说明(带典型值)
# -n / --namespace refining | ricnsmart # -n / --namespace refining | ricnsmart
# --kind server | service # --kind server | service
# --name gitea | i-uf63f2uookgs5uxmrdyc # --name gitea | i-example0abcd1234efgh
# --add-tag production | backup不影响已有 tag可重复 # --add-tag production | backup不影响已有 tag可重复
# --remove-tag staging | deprecated可重复 # --remove-tag staging | deprecated可重复
# -m / --meta ip=10.0.0.1 | desc="新描述"(新增或覆盖,可重复) # -m / --meta ip=10.0.0.1 | desc="新描述" | credentials:username=root(新增或覆盖,可重复)
# --remove-meta old_port | legacy_key删除 metadata 字段,可重复) # --remove-meta old_port | legacy_key | credentials:content(删除 metadata 字段,可重复)
# -s / --secret token=<new> | ssh_key=@./new.pem新增或覆盖可重复 # -s / --secret token=<new> | ssh_key=@./new.pem | credentials:content@./new.pem(新增或覆盖,可重复)
# --remove-secret old_password | deprecated_key删除 secret 字段,可重复) # --remove-secret old_password | deprecated_key | credentials:content(删除 secret 字段,可重复)
# 更新单个 metadata 字段 # 更新单个 metadata 字段
secrets update -n refining --kind server --name i-uf63f2uookgs5uxmrdyc \ secrets update -n refining --kind server --name i-example0abcd1234efgh \
-m ip=10.0.0.1 -m ip=10.0.0.1
# 轮换 token # 轮换 token
@@ -227,33 +352,220 @@ secrets update -n refining --kind service --name gitea \
secrets update -n refining --kind service --name mqtt \ secrets update -n refining --kind service --name mqtt \
--remove-meta old_port --remove-secret old_password --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 # 移除 tag
secrets update -n refining --kind service --name gitea --remove-tag staging secrets update -n refining --kind service --name gitea --remove-tag staging
``` ```
--- ---
### delete — 删除记录 ### delete — 删除记录(支持单条精确删除与批量删除)
删除时会自动将 entry 与所有关联 secret 字段快照到历史表,并写入审计日志,可通过 `rollback` 命令恢复。
```bash ```bash
# 参数说明(带典型值) # 参数说明(带典型值)
# -n / --namespace refining | ricnsmart # -n / --namespace refining | ricnsmart(必填)
# --kind server | service # --kind server | service(指定 --name 时必填;批量时可选)
# --name gitea | i-uf63f2uookgs5uxmrdyc必须精确匹配 # --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 refining --kind service --name legacy-mqtt
# 删除服务器记录
secrets delete -n ricnsmart --kind server --name i-old-server-id 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
``` ```
--- ---
### config — 配置管理 ### history — 查看变更历史
```bash ```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 set-db "postgres://postgres:<password>@<host>:<port>/secrets"
# 查看当前配置(密码脱敏) # 查看当前配置(密码脱敏)
@@ -288,16 +600,25 @@ secrets --db-url "postgres://..." search -n refining
- 新增 `kind` 类型时:只需在 `add` 调用时传入,无需改代码 - 新增 `kind` 类型时:只需在 `add` 调用时传入,无需改代码
- 字段命名CLI 短标志 `-n`=namespace`-m`=meta`-s`=secret`-q`=query`-v`=verbose`-f`=field`-o`=output - 字段命名CLI 短标志 `-n`=namespace`-m`=meta`-s`=secret`-q`=query`-v`=verbose`-f`=field`-o`=output
- 日志:用户可见输出用 `println!`;调试/运维信息用 `tracing::debug!`/`info!`/`warn!`/`error!` - 日志:用户可见输出用 `println!`;调试/运维信息用 `tracing::debug!`/`info!`/`warn!`/`error!`
- 审计:`add`/`update`/`delete` 成功后调用 `audit::log()`,写入 `audit_log` 表;失败只 warn 不中断 - 审计:`add`/`update`/`delete` 成功后调用 `audit::log_tx`,写入 `audit_log` 表;失败只 warn 不中断
- 输出:读命令通过 `OutputMode` 支持 text/json/json-compact/env写命令 `add` 同样支持 `-o json` - 加密:`encrypted` 列存储 AES-256-GCM 密文;`add`/`update`/`search`/`delete` 需主密钥(`secrets init` 后从 OS 钥匙串加载)
- 输出:读命令通过 `OutputMode` 支持 text/json/json-compact默认始终 `json`pretty`-o text` 供人类阅读;写命令 `add` 同样支持 `-o json`
## 提交前检查(必须全部通过) ## 提交前检查(必须全部通过)
每次提交代码前,请在本地依次执行以下检查,**全部通过后再 push** 每次提交代码前,请在本地依次执行以下检查,**全部通过后再 push**
优先使用:
```bash
./scripts/release-check.sh
```
它等价于先检查版本号 / tag再执行下面的格式、Lint、测试。
### 1. 版本号(按需) ### 1. 版本号(按需)
若本次改动需要发版,请先确认 `Cargo.toml` 中的 `version` 已提升,避免 CI 打出的 Tag 与已有版本重复。可通过 git tag 判断: 若本次改动需要发版,请先确认 `Cargo.toml` 中的 `version` 已提升,避免 CI 打出的 Tag 与已有版本重复。**升级版本后需同时更新 `Cargo.lock`**(运行 `cargo build` 即可自动同步),否则 CI 中 `cargo clippy --locked` 会因 lock 与 manifest 不一致而失败。可通过 git tag 判断:
```bash ```bash
# 查看当前 Cargo.toml 版本 # 查看当前 Cargo.toml 版本
@@ -307,7 +628,7 @@ grep '^version' Cargo.toml
git tag -l 'secrets-*' git tag -l 'secrets-*'
``` ```
若当前版本已被 tag例如已有 `secrets-0.3.0``Cargo.toml` 仍为 `0.3.0`),则应在 `Cargo.toml` 中 bump 版本号后再提交,以便 CI 自动打新 Tag 并发布 Release。 若当前版本已被 tag例如已有 `secrets-0.3.0``Cargo.toml` 仍为 `0.3.0`),则应在 `Cargo.toml` 中 bump 版本号,再执行 `cargo build` 同步 `Cargo.lock`,最后一并提交,以便 CI 自动打新 Tag 并发布 Release。
### 2. 格式、Lint、测试 ### 2. 格式、Lint、测试
@@ -325,12 +646,14 @@ cargo fmt -- --check && cargo clippy -- -D warnings && cargo test
## CI/CD ## CI/CD
- Gitea Actionsrunner: debian - Gitea Actionsrunners: debian / darwin-arm64 / windows
- 触发:`src/**``Cargo.toml``Cargo.lock` 变更推送到 main - 触发:`src/**``Cargo.toml``Cargo.lock` 变更推送到 main
- 构建目标:`x86_64-unknown-linux-musl`(静态链接,无 glibc 依赖) - 构建目标:`x86_64-unknown-linux-musl``aarch64-apple-darwin``x86_64-apple-darwin`(由 ARM mac runner 交叉编译)、`x86_64-pc-windows-msvc`
- 新版本自动打 Tag格式 `secrets-<version>`)并上传二进制到 Gitea Release - 新版本自动打 Tag格式 `secrets-<version>`)并上传二进制与对应 `.sha256` 摘要到 Gitea Release
- Release 仅在 Linux/macOS/Windows 构建全部成功后才会从 draft 发布
- 通知:飞书 Webhook`vars.WEBHOOK_URL` - 通知:飞书 Webhook`vars.WEBHOOK_URL`
- 所需 secrets/vars`RELEASE_TOKEN`Release 上传Gitea PAT`vars.WEBHOOK_URL`(通知,可选) - 所需 secrets/vars`RELEASE_TOKEN`Release 上传Gitea PAT`vars.WEBHOOK_URL`(通知,可选)
- **注意**Gitea Actions 的 Secret/Variable 创建时,`data`/`value` 字段需传入**原始值**,不要使用 base64 编码
## 环境变量 ## 环境变量
@@ -338,5 +661,6 @@ cargo fmt -- --check && cargo clippy -- -D warnings && cargo test
|------|------| |------|------|
| `RUST_LOG` | 日志级别,如 `secrets=debug``secrets=trace`(默认 warn | | `RUST_LOG` | 日志级别,如 `secrets=debug``secrets=trace`(默认 warn |
| `USER` | 审计日志 actor 字段来源Shell 自动设置,通常无需手动配置 | | `USER` | 审计日志 actor 字段来源Shell 自动设置,通常无需手动配置 |
| `SECRETS_UPGRADE_URL` | upgrade 的 Release API 地址。构建时cargo build或运行时.env/export |
数据库连接通过 `secrets config set-db` 持久化到 `~/.config/secrets/config.toml`,不支持环境变量。 数据库连接通过 `secrets config set-db` 持久化到 `~/.config/secrets/config.toml`,不支持环境变量。

984
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,18 +1,33 @@
[package] [package]
name = "secrets" name = "secrets"
version = "0.4.0" version = "0.9.6"
edition = "2024" edition = "2024"
[dependencies] [dependencies]
anyhow = "1.0.102" aes-gcm = "^0.10.3"
chrono = { version = "0.4.44", features = ["serde"] } anyhow = "^1.0.102"
clap = { version = "4.6.0", features = ["derive", "env"] } argon2 = { version = "^0.5.3", features = ["std"] }
dirs = "6.0.0" chrono = { version = "^0.4.44", features = ["serde"] }
serde = { version = "1.0.228", features = ["derive"] } clap = { version = "^4.6.0", features = ["derive"] }
serde_json = "1.0.149" dirs = "^6.0.0"
sqlx = { version = "0.8.6", features = ["runtime-tokio", "tls-rustls", "postgres", "uuid", "json", "chrono"] } dotenvy = "^0.15"
tokio = { version = "1.50.0", features = ["full"] } flate2 = "^1.1.9"
toml = "1.0.7" keyring = { version = "^3.6.3", features = ["apple-native", "windows-native", "linux-native"] }
tracing = "0.1" rand = "^0.10.0"
tracing-subscriber = { version = "0.3", features = ["env-filter"] } reqwest = { version = "^0.12", default-features = false, features = ["rustls-tls", "json"] }
uuid = { version = "1.22.0", features = ["serde", "v4"] } rpassword = "^7.4.0"
self-replace = "^1.5.0"
semver = "^1.0.27"
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"
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"] }

254
README.md
View File

@@ -2,7 +2,7 @@
跨设备密钥与配置管理 CLI基于 Rust + PostgreSQL 18。 跨设备密钥与配置管理 CLI基于 Rust + PostgreSQL 18。
将服务器信息、服务凭据统一存入数据库,供本地工具和 AI 读取上下文。 将服务器信息、服务凭据统一存入数据库,供本地工具和 AI 读取上下文。每个敏感字段单独行存储(`secrets` 子表),字段名、类型、长度以明文保存便于 AI 理解,仅值本身使用 AES-256-GCM 加密;主密钥由 Argon2id 从主密码派生并存入系统钥匙串。
## 安装 ## 安装
@@ -11,12 +11,24 @@ cargo build --release
# 或从 Release 页面下载预编译二进制 # 或从 Release 页面下载预编译二进制
``` ```
配置数据库连接(首次使用需执行一次,之后在该设备上持久生效): 已有旧版本时,可执行 `secrets upgrade` 自动下载最新版并替换。该命令会校验 Release 附带的 `.sha256` 摘要后再安装。
## 首次使用(每台设备各执行一次)
```bash ```bash
# 1. 配置数据库连接(会先验证连接可用再写入)
secrets config set-db "postgres://postgres:<password>@<host>:<port>/secrets" secrets config set-db "postgres://postgres:<password>@<host>:<port>/secrets"
# 2. 初始化主密钥(提示输入至少 8 位的主密码,派生后存入 OS 钥匙串)
secrets init
``` ```
主密码不会存储,仅用于派生主密钥,且至少需 8 位。同一主密码在所有设备上会得到相同主密钥salt 存于数据库,首台设备生成后共享)。
**主密钥存储**macOS → KeychainWindows → Credential ManagerLinux → keyutils会话级重启后需再次 `secrets init`)。
**从旧版(明文存储)升级**:升级后首次运行需执行 `secrets init` 即可(明文记录需手动重新 add 或通过 update 更新)。
## AI Agent 快速指南 ## AI Agent 快速指南
这个 CLI 以 AI 使用优先设计。核心路径只有一条:**读取用 `search`,写入用 `add` / `update`**。 这个 CLI 以 AI 使用优先设计。核心路径只有一条:**读取用 `search`,写入用 `add` / `update`**。
@@ -42,37 +54,45 @@ secrets search --sort updated --limit 10 --summary
# 精确定位namespace + kind + name 三元组) # 精确定位namespace + kind + name 三元组)
secrets search -n refining --kind service --name gitea secrets search -n refining --kind service --name gitea
# 获取完整记录含 secretsJSON 格式AI 最易解析 # 获取完整记录含 secrets 字段名,无需 master_key
secrets search -n refining --kind service --name gitea -o json --show-secrets secrets search -n refining --kind service --name gitea -o json
# 直接提取单个字段值(最短路径) # 直接提取单个 metadata 字段值(最短路径)
secrets search -n refining --kind service --name gitea -f secret.token
secrets search -n refining --kind service --name gitea -f metadata.url secrets search -n refining --kind service --name gitea -f metadata.url
# 同时提取多个字段 # 同时提取多个 metadata 字段
secrets search -n refining --kind service --name gitea \ secrets search -n refining --kind service --name gitea \
-f metadata.url -f metadata.default_org -f secret.token -f metadata.url -f metadata.default_org
# 需要 secrets 时,改用 run只注入 token 字段到子进程)
secrets run -n refining --kind service --name gitea -s token -- ./deploy.sh
# 预览 run 会注入哪些变量(不执行命令)
secrets run -n refining --kind service --name gitea --dry-run
``` ```
`-f secret.*` 会自动解锁 secrets无需额外加 `--show-secrets` `search` 展示 metadata 与 secrets 的字段名,不展示 secret 值本身;需要 secret 值时用 `run`(仅注入加密字段到子进程,不含 metadata。用 `-s` 指定只注入特定字段,最小化注入范围
### 输出格式 ### 输出格式
| 场景 | 推荐命令 | | 场景 | 推荐命令 |
|------|----------| |------|----------|
| AI 解析 / 管道处理 | `-o json``-o json-compact` | | AI 解析 / 管道处理(默认) | jsonpretty-printed |
| 写入 `.env` 文件 | `-o env --show-secrets` | | 管道紧凑格式 | `-o json-compact` |
| 人类查看 | 默认 `text`TTY 下自动启用) | | 注入 secrets 到子进程环境 | `run` |
| 非 TTY管道/重定向) | 自动 `json-compact` | | 人类查看 | `-o text` |
默认始终输出 JSON无论是 TTY 还是管道。`text` 输出中时间按本地时区显示;`json/json-compact` 使用 UTCRFC3339
```bash ```bash
# 管道直接 jq 解析(非 TTY 自动 json-compact # 默认 JSON 输出,直接 jq 解析
secrets search -n refining --kind service | jq '.[].name' secrets search -n refining --kind service | jq '.[].name'
secrets search -n refining --kind service --name gitea --show-secrets | jq '.secrets.token'
# 导出为可 source 的 env 文件(单条记录 # 需要 secrets 时,使用 run-s 指定只注入特定字段
secrets search -n refining --kind service --name gitea -o env --show-secrets \ secrets run -n refining --kind service --name gitea -s token -- ./deploy.sh
> ~/.config/gitea/config.env
# 预览 run 会注入哪些变量(不执行命令)
secrets run -n refining --kind service --name gitea --dry-run
``` ```
## 完整命令参考 ## 完整命令参考
@@ -80,11 +100,15 @@ secrets search -n refining --kind service --name gitea -o env --show-secrets \
```bash ```bash
# 查看帮助(包含各子命令 EXAMPLES # 查看帮助(包含各子命令 EXAMPLES
secrets --help secrets --help
secrets init --help # 主密钥初始化
secrets search --help secrets search --help
secrets add --help secrets add --help
secrets update --help secrets update --help
secrets delete --help secrets delete --help
secrets config --help secrets config --help
secrets upgrade --help # 检查并更新 CLI 版本
secrets export --help # 批量导出JSON/TOML/YAML
secrets import --help # 批量导入JSON/TOML/YAML
# ── search ────────────────────────────────────────────────────────────────── # ── search ──────────────────────────────────────────────────────────────────
secrets search --summary --limit 20 # 发现概览 secrets search --summary --limit 20 # 发现概览
@@ -92,35 +116,81 @@ secrets search -n refining --kind service # 按 namespace + kin
secrets search -n refining --kind service --name gitea # 精确查找 secrets search -n refining --kind service --name gitea # 精确查找
secrets search -q mqtt # 关键词模糊搜索 secrets search -q mqtt # 关键词模糊搜索
secrets search --tag hongkong # 按 tag 过滤 secrets search --tag hongkong # 按 tag 过滤
secrets search -n refining --kind service --name gitea -f secret.token # 提取字段 secrets search -n refining --kind service --name gitea -f metadata.url # 提取 metadata 字段
secrets search -n refining --kind service --name gitea -o json --show-secrets # 完整 JSON secrets search -n refining --kind service --name gitea -o json # 完整记录(含 secrets schema
secrets search --sort updated --limit 10 --summary # 最近改动 secrets search --sort updated --limit 10 --summary # 最近改动
secrets search -n refining --summary --limit 10 --offset 10 # 翻页 secrets search -n refining --summary --limit 10 --offset 10 # 翻页
# ── add ────────────────────────────────────────────────────────────────────── # ── add ──────────────────────────────────────────────────────────────────────
secrets add -n refining --kind server --name my-server \ secrets add -n refining --kind server --name my-server \
--tag aliyun --tag shanghai \ --tag aliyun --tag shanghai \
-m ip=47.117.131.22 -m desc="Aliyun Shanghai ECS" \ -m ip=10.0.0.1 -m desc="Example ECS" \
-s username=root -s ssh_key=@./keys/server.pem -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 \ secrets add -n refining --kind service --name gitea \
--tag gitea \ --tag gitea \
-m url=https://gitea.refining.dev -m default_org=refining \ -m url=https://code.example.com -m default_org=myorg \
-s token=<token> -s token=<token>
# ── update ─────────────────────────────────────────────────────────────────── # ── update ───────────────────────────────────────────────────────────────────
secrets update -n refining --kind server --name my-server -m ip=10.0.0.1 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 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 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 ─────────────────────────────────────────────────────────────────── # ── delete ───────────────────────────────────────────────────────────────────
secrets delete -n refining --kind service --name legacy-mqtt 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 ─────────────────────────────────────────────────────────────────── # ── config ───────────────────────────────────────────────────────────────────
secrets config set-db "postgres://postgres:<password>@<host>:<port>/secrets" secrets config set-db "postgres://postgres:<password>@<host>:<port>/secrets" # 先验证再写入
secrets config show # 密码脱敏展示 secrets config show # 密码脱敏展示
secrets config path # 打印配置文件路径 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 secrets --verbose search -q mqtt
RUST_LOG=secrets=trace secrets search RUST_LOG=secrets=trace secrets search
@@ -128,18 +198,104 @@ RUST_LOG=secrets=trace secrets search
## 数据模型 ## 数据模型
单张 `secrets` 表,首次连接自动建表;同时自动创建 `audit_log` 表,记录所有写操作 主表 `entries`namespace、kind、name、tags、metadata+ 子表 `secrets`(每个加密字段一行,含 field_name、encrypted首次连接自动建表;同时创建 `audit_log``entries_history``secrets_history` 等表
| 字段 | 说明 | | 位置 | 字段 | 说明 |
|------|------| |------|------|------|
| `namespace` | 一级隔离,如 `refining``ricnsmart` | | entries | namespace | 一级隔离,如 `refining``ricnsmart` |
| `kind` | 记录类型,如 `server``service`(可自由扩展) | | entries | kind | 记录类型,如 `server``service``key`(可自由扩展) |
| `name` | 人类可读唯一标识 | | entries | name | 人类可读唯一标识 |
| `tags` | 多维标签,如 `["aliyun","hongkong"]` | | entries | tags | 多维标签,如 `["aliyun","hongkong"]` |
| `metadata` | 明文描述信息ip、desc、domains 等) | | entries | metadata | 明文描述ip、desc、domains、key_ref 等) |
| `encrypted` | 敏感凭据ssh_key、password、token 等MVP 阶段明文存储,预留加密字段 | | secrets | field_name | 明文search 可见AI 可推断 run 会注入哪些变量 |
| secrets | encrypted | 仅加密值本身AES-256-GCM |
`-m` / `--meta` 写入 `metadata``-s` / `--secret` 写入 `encrypted``value=@file` 从文件读取内容 `-m` / `--meta` 写入 `metadata``-s` / `--secret` 写入 `secrets` 表的独立行。支持 `key=value``key=@file``key:=<json>`,也支持 `credentials:content@./key.pem` 这类嵌套字段文件写入;删除时支持 `--remove-secret credentials:content`。加解密使用主密钥(由 `secrets init` 设置)
**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
```
## 审计日志 ## 审计日志
@@ -160,22 +316,29 @@ src/
main.rs # CLI 入口clap含各子命令 after_help 示例 main.rs # CLI 入口clap含各子命令 after_help 示例
output.rs # OutputMode 枚举 + TTY 检测 output.rs # OutputMode 枚举 + TTY 检测
config.rs # 配置读写(~/.config/secrets/config.toml config.rs # 配置读写(~/.config/secrets/config.toml
db.rs # 连接池 + auto-migratesecrets + audit_log db.rs # 连接池 + auto-migrateentries + secrets + entries_history + secrets_history + audit_log + kv_config
models.rs # Secret 结构体 crypto.rs # AES-256-GCM 加解密、Argon2id 派生、OS 钥匙串
models.rs # Entry + SecretField 结构体
audit.rs # 审计日志写入audit_log 表) audit.rs # 审计日志写入audit_log 表)
commands/ commands/
add.rs # upsert支持 -o json init.rs # 主密钥初始化(首次/新设备)
add.rs # upsert entries + secrets 行,支持 -o json
config.rs # config set-db/show/path config.rs # config set-db/show/path
search.rs # 多条件查询,支持 -f/-o/--summary/--limit/--offset/--sort search.rs # 多条件查询,展示 secrets schema-f/-o/--summary/--limit/--offset/--sort
delete.rs # 删除 delete.rs # 删除CASCADE 删除 secrets
update.rs # 增量更新(合并 tags/metadata/encrypted 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重新加密写入
scripts/ scripts/
seed-data.sh # 导入 refining / ricnsmart 全量数据 setup-gitea-actions.sh # 配置 Gitea Actions 变量与 Secrets
``` ```
## CI/CDGitea Actions ## CI/CDGitea Actions
推送 `main` 分支时自动fmt/clippy 检查 → musl 构建 → 创建 Release 并上传二进制 推送 `main` 分支时自动fmt/clippy/test 检查 → Linux/macOS/Windows 构建 → 上传二进制与 `.sha256` 摘要 → 所有平台成功后发布 Release。
**首次使用需配置 Actions 变量和 Secrets** **首次使用需配置 Actions 变量和 Secrets**
@@ -186,5 +349,12 @@ scripts/
- `RELEASE_TOKEN`SecretGitea PAT用于创建 Release 上传二进制 - `RELEASE_TOKEN`SecretGitea PAT用于创建 Release 上传二进制
- `WEBHOOK_URL`Variable飞书通知可选 - `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)。

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

@@ -0,0 +1,23 @@
#!/usr/bin/env bash
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}"
echo "==> 当前版本: ${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
fi
echo "==> 未发现重复 tag开始执行检查"
cargo fmt -- --check
cargo clippy --locked -- -D warnings
cargo test --locked

View File

@@ -7,7 +7,9 @@
# - secrets.RELEASE_TOKEN (必选) Release 上传用,值为 Gitea PAT # - secrets.RELEASE_TOKEN (必选) Release 上传用,值为 Gitea PAT
# - vars.WEBHOOK_URL (可选) 飞书通知 # - vars.WEBHOOK_URL (可选) 飞书通知
# #
# 注意: Gitea 不允许 secret/variable 名以 GITEA_ 或 GITHUB_ 开头,故使用 RELEASE_TOKEN # 注意:
# - Gitea 不允许 secret/variable 名以 GITEA_ 或 GITHUB_ 开头,故使用 RELEASE_TOKEN
# - Secret/Variable 的 data/value 字段需传入原始值,不要使用 base64 编码
# #
# 用法: # 用法:
# 1. 从 ~/.config/gitea/config.env 读取 GITEA_URL, GITEA_TOKEN, GITEA_WEBHOOK_URL # 1. 从 ~/.config/gitea/config.env 读取 GITEA_URL, GITEA_TOKEN, GITEA_WEBHOOK_URL
@@ -108,11 +110,13 @@ echo "━━━━━━━━━━━━━━━━━━━━━━━━
echo "" echo ""
# 1. 创建 Secret: RELEASE_TOKEN # 1. 创建 Secret: RELEASE_TOKEN
# 注意: Gitea Actions API 的 data 字段需传入原始值,不要使用 base64 编码
echo "1. 创建 Secret: RELEASE_TOKEN" echo "1. 创建 Secret: RELEASE_TOKEN"
secret_payload=$(jq -n --arg t "$GITEA_TOKEN" '{data: $t}')
resp=$(curl -s -w "\n%{http_code}" -X PUT \ resp=$(curl -s -w "\n%{http_code}" -X PUT \
-H "Authorization: token $GITEA_TOKEN" \ -H "Authorization: token $GITEA_TOKEN" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d "{\"data\":\"${GITEA_TOKEN}\"}" \ -d "$secret_payload" \
"${API_BASE}/repos/${OWNER}/${REPO}/actions/secrets/RELEASE_TOKEN") "${API_BASE}/repos/${OWNER}/${REPO}/actions/secrets/RELEASE_TOKEN")
http_code=$(echo "$resp" | tail -n1) http_code=$(echo "$resp" | tail -n1)
body=$(echo "$resp" | sed '$d') body=$(echo "$resp" | sed '$d')
@@ -126,14 +130,16 @@ else
fi fi
# 2. 创建/更新 Variable: WEBHOOK_URL可选 # 2. 创建/更新 Variable: WEBHOOK_URL可选
# 注意: Secret 和 Variable 均使用原始值,不要 base64 编码
WEBHOOK_VALUE="${WEBHOOK_URL:-$GITEA_WEBHOOK_URL}" WEBHOOK_VALUE="${WEBHOOK_URL:-$GITEA_WEBHOOK_URL}"
if [[ -n "$WEBHOOK_VALUE" ]]; then if [[ -n "$WEBHOOK_VALUE" ]]; then
echo "" echo ""
echo "2. 创建/更新 Variable: WEBHOOK_URL" echo "2. 创建/更新 Variable: WEBHOOK_URL"
var_payload=$(jq -n --arg v "$WEBHOOK_VALUE" '{value: $v}')
resp=$(curl -s -w "\n%{http_code}" -X POST \ resp=$(curl -s -w "\n%{http_code}" -X POST \
-H "Authorization: token $GITEA_TOKEN" \ -H "Authorization: token $GITEA_TOKEN" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d "{\"value\":\"${WEBHOOK_VALUE}\"}" \ -d "$var_payload" \
"${API_BASE}/repos/${OWNER}/${REPO}/actions/variables/WEBHOOK_URL") "${API_BASE}/repos/${OWNER}/${REPO}/actions/variables/WEBHOOK_URL")
http_code=$(echo "$resp" | tail -n1) http_code=$(echo "$resp" | tail -n1)
body=$(echo "$resp" | sed '$d') body=$(echo "$resp" | sed '$d')
@@ -145,7 +151,7 @@ if [[ -n "$WEBHOOK_VALUE" ]]; then
resp=$(curl -s -w "\n%{http_code}" -X PUT \ resp=$(curl -s -w "\n%{http_code}" -X PUT \
-H "Authorization: token $GITEA_TOKEN" \ -H "Authorization: token $GITEA_TOKEN" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d "{\"value\":\"${WEBHOOK_VALUE}\"}" \ -d "$var_payload" \
"${API_BASE}/repos/${OWNER}/${REPO}/actions/variables/WEBHOOK_URL") "${API_BASE}/repos/${OWNER}/${REPO}/actions/variables/WEBHOOK_URL")
http_code=$(echo "$resp" | tail -n1) http_code=$(echo "$resp" | tail -n1)
if [[ "$http_code" == "200" || "$http_code" == "204" ]]; then if [[ "$http_code" == "200" || "$http_code" == "204" ]]; then

View File

@@ -1,18 +1,21 @@
use anyhow::Result;
use serde_json::Value; use serde_json::Value;
use sqlx::PgPool; use sqlx::{Postgres, Transaction};
/// Write an audit entry for a write operation. Failures are logged as warnings /// Return the current OS user as the audit actor (falls back to empty string).
/// and do not interrupt the main flow. pub fn current_actor() -> String {
pub async fn log( std::env::var("USER").unwrap_or_default()
pool: &PgPool, }
/// Write an audit entry within an existing transaction.
pub async fn log_tx(
tx: &mut Transaction<'_, Postgres>,
action: &str, action: &str,
namespace: &str, namespace: &str,
kind: &str, kind: &str,
name: &str, name: &str,
detail: Value, detail: Value,
) { ) {
let actor = std::env::var("USER").unwrap_or_default(); let actor = current_actor();
let result: Result<_, sqlx::Error> = sqlx::query( let result: Result<_, sqlx::Error> = sqlx::query(
"INSERT INTO audit_log (action, namespace, kind, name, detail, actor) \ "INSERT INTO audit_log (action, namespace, kind, name, detail, actor) \
VALUES ($1, $2, $3, $4, $5, $6)", VALUES ($1, $2, $3, $4, $5, $6)",
@@ -23,7 +26,7 @@ pub async fn log(
.bind(name) .bind(name)
.bind(&detail) .bind(&detail)
.bind(&actor) .bind(&actor)
.execute(pool) .execute(&mut **tx)
.await; .await;
if let Err(e) = result { if let Err(e) = result {

View File

@@ -3,36 +3,188 @@ use serde_json::{Map, Value, json};
use sqlx::PgPool; use sqlx::PgPool;
use std::fs; use std::fs;
use crate::output::OutputMode; use crate::crypto;
use crate::db;
use crate::models::EntryRow;
use crate::output::{OutputMode, print_json};
/// Parse "key=value" entries. Value starting with '@' reads from file. // ── Key/value parsing helpers (shared with update.rs) ───────────────────────
pub(crate) fn parse_kv(entry: &str) -> Result<(String, String)> {
let (key, raw_val) = entry.split_once('=').ok_or_else(|| {
anyhow::anyhow!(
"Invalid format '{}'. Expected: key=value or key=@file",
entry
)
})?;
let value = if let Some(path) = raw_val.strip_prefix('@') { /// Parse secret / metadata entries into a nested key path and JSON value.
fs::read_to_string(path) /// - `key=value` → stores the literal string `value`
.map_err(|e| anyhow::anyhow!("Failed to read file '{}': {}", path, e))? /// - `key:=<json>` → parses `<json>` as a typed JSON value
} else { /// - `key=@file` → reads the file content as a string
raw_val.to_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));
}
Ok((key.to_string(), value)) // 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
)
} }
fn build_json(entries: &[String]) -> Result<Value> { pub(crate) fn build_json(entries: &[String]) -> Result<Value> {
let mut map = Map::new(); let mut map = Map::new();
for entry in entries { for entry in entries {
let (key, value) = parse_kv(entry)?; let (path, value) = parse_kv(entry)?;
map.insert(key, Value::String(value)); insert_path(&mut map, &path, value)?;
} }
Ok(Value::Object(map)) 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 struct AddArgs<'a> {
pub namespace: &'a str, pub namespace: &'a str,
pub kind: &'a str, pub kind: &'a str,
@@ -43,22 +195,59 @@ pub struct AddArgs<'a> {
pub output: OutputMode, pub output: OutputMode,
} }
pub async fn run(pool: &PgPool, args: AddArgs<'_>) -> Result<()> { pub async fn run(pool: &PgPool, args: AddArgs<'_>, master_key: &[u8; 32]) -> Result<()> {
let metadata = build_json(args.meta_entries)?; let metadata = build_json(args.meta_entries)?;
let encrypted = build_json(args.secret_entries)?; let secret_json = build_json(args.secret_entries)?;
tracing::debug!(args.namespace, args.kind, args.name, "upserting record"); tracing::debug!(args.namespace, args.kind, args.name, "upserting entry");
sqlx::query( 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#" r#"
INSERT INTO secrets (namespace, kind, name, tags, metadata, encrypted, updated_at) INSERT INTO entries (namespace, kind, name, tags, metadata, version, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, NOW()) VALUES ($1, $2, $3, $4, $5, 1, NOW())
ON CONFLICT (namespace, kind, name) ON CONFLICT (namespace, kind, name)
DO UPDATE SET DO UPDATE SET
tags = EXCLUDED.tags, tags = EXCLUDED.tags,
metadata = EXCLUDED.metadata, metadata = EXCLUDED.metadata,
encrypted = EXCLUDED.encrypted, version = entries.version + 1,
updated_at = NOW() updated_at = NOW()
RETURNING id
"#, "#,
) )
.bind(args.namespace) .bind(args.namespace)
@@ -66,23 +255,73 @@ pub async fn run(pool: &PgPool, args: AddArgs<'_>) -> Result<()> {
.bind(args.name) .bind(args.name)
.bind(args.tags) .bind(args.tags)
.bind(&metadata) .bind(&metadata)
.bind(&encrypted) .fetch_one(&mut *tx)
.execute(pool)
.await?; .await?;
let meta_keys: Vec<&str> = args let new_entry_version: i64 = sqlx::query_scalar("SELECT version FROM entries WHERE id = $1")
.meta_entries .bind(entry_id)
.iter() .fetch_one(&mut *tx)
.filter_map(|s| s.split_once('=').map(|(k, _)| k)) .await?;
.collect();
let secret_keys: Vec<&str> = args
.secret_entries
.iter()
.filter_map(|s| s.split_once('=').map(|(k, _)| k))
.collect();
crate::audit::log( // Snapshot existing secret fields before replacing.
pool, 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", "add",
args.namespace, args.namespace,
args.kind, args.kind,
@@ -95,6 +334,8 @@ pub async fn run(pool: &PgPool, args: AddArgs<'_>) -> Result<()> {
) )
.await; .await;
tx.commit().await?;
let result_json = json!({ let result_json = json!({
"action": "added", "action": "added",
"namespace": args.namespace, "namespace": args.namespace,
@@ -106,11 +347,8 @@ pub async fn run(pool: &PgPool, args: AddArgs<'_>) -> Result<()> {
}); });
match args.output { match args.output {
OutputMode::Json => { OutputMode::Json | OutputMode::JsonCompact => {
println!("{}", serde_json::to_string_pretty(&result_json)?); print_json(&result_json, &args.output)?;
}
OutputMode::JsonCompact => {
println!("{}", serde_json::to_string(&result_json)?);
} }
_ => { _ => {
println!("Added: [{}/{}] {}", args.namespace, args.kind, args.name); println!("Added: [{}/{}] {}", args.namespace, args.kind, args.name);
@@ -128,3 +366,94 @@ pub async fn run(pool: &PgPool, args: AddArgs<'_>) -> Result<()> {
Ok(()) 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,43 +1,44 @@
use crate::config::{self, Config, config_path}; use crate::config::{self, Config, config_path};
use anyhow::Result; use anyhow::Result;
pub enum ConfigAction { pub async fn run(action: crate::ConfigAction) -> Result<()> {
SetDb { url: String },
Show,
Path,
}
pub async fn run(action: ConfigAction) -> Result<()> {
match action { match action {
ConfigAction::SetDb { url } => { 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 { let cfg = Config {
database_url: Some(url.clone()), database_url: Some(url.clone()),
}; };
config::save_config(&cfg)?; config::save_config(&cfg)?;
println!("✓ 数据库连接串已保存到: {}", config_path().display()); println!("Database URL saved to: {}", config_path()?.display());
println!(" {}", mask_password(&url)); println!(" {}", mask_password(&url));
} }
ConfigAction::Show => { crate::ConfigAction::Show => {
let cfg = config::load_config()?; let cfg = config::load_config()?;
match cfg.database_url { match cfg.database_url {
Some(url) => { Some(url) => {
println!("database_url = {}", mask_password(&url)); println!("database_url = {}", mask_password(&url));
println!("配置文件: {}", config_path().display()); println!("config file: {}", config_path()?.display());
} }
None => { None => {
println!("未配置数据库连接串。"); println!("Database URL not configured.");
println!("请运行:secrets config set-db <DATABASE_URL>"); println!("Run: secrets config set-db <DATABASE_URL>");
} }
} }
} }
ConfigAction::Path => { crate::ConfigAction::Path => {
println!("{}", config_path().display()); println!("{}", config_path()?.display());
} }
} }
Ok(()) Ok(())
} }
/// postgres://user:password@host/db 中的密码替换为 *** /// Mask the password in a postgres://user:password@host/db URL.
fn mask_password(url: &str) -> String { fn mask_password(url: &str) -> String {
if let Some(at_pos) = url.rfind('@') if let Some(at_pos) = url.rfind('@')
&& let Some(scheme_end) = url.find("://") && let Some(scheme_end) = url.find("://")

View File

@@ -1,24 +1,291 @@
use anyhow::Result; use anyhow::Result;
use serde_json::json; use serde_json::json;
use sqlx::PgPool; use sqlx::PgPool;
use uuid::Uuid;
pub async fn run(pool: &PgPool, namespace: &str, kind: &str, name: &str) -> Result<()> { use crate::db;
tracing::debug!(namespace, kind, name, "deleting record"); use crate::models::{EntryRow, SecretFieldRow};
use crate::output::{OutputMode, print_json};
let result = pub struct DeleteArgs<'a> {
sqlx::query("DELETE FROM secrets WHERE namespace = $1 AND kind = $2 AND name = $3") pub namespace: &'a str,
.bind(namespace) /// Kind filter. Required when --name is given; optional for bulk deletes.
.bind(kind) pub kind: Option<&'a str>,
.bind(name) /// Exact record name. When None, bulk-delete all matching records.
.execute(pool) pub name: Option<&'a str>,
.await?; /// Preview without writing to the database (bulk mode only).
pub dry_run: bool,
pub output: OutputMode,
}
if result.rows_affected() == 0 { // ── Internal row type used for bulk queries ────────────────────────────────
tracing::warn!(namespace, kind, name, "record not found for deletion");
println!("Not found: [{}/{}] {}", namespace, kind, name); #[derive(Debug, sqlx::FromRow)]
} else { struct FullEntryRow {
crate::audit::log(pool, "delete", namespace, kind, name, json!({})).await; pub id: Uuid,
println!("Deleted: [{}/{}] {}", namespace, kind, name); 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(()) 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(())
}

109
src/commands/export_cmd.rs Normal file
View File

@@ -0,0 +1,109 @@
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(())
}

78
src/commands/history.rs Normal file
View File

@@ -0,0 +1,78 @@
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(())
}

217
src/commands/import_cmd.rs Normal file
View File

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

70
src/commands/init.rs Normal file
View File

@@ -0,0 +1,70 @@
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,5 +1,12 @@
pub mod add; pub mod add;
pub mod config; pub mod config;
pub mod delete; 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 search;
pub mod update; pub mod update;
pub mod upgrade;

256
src/commands/rollback.rs Normal file
View File

@@ -0,0 +1,256 @@
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(())
}

248
src/commands/run.rs Normal file
View File

@@ -0,0 +1,248 @@
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,17 +1,18 @@
use anyhow::Result; use anyhow::Result;
use serde_json::{Value, json}; use serde_json::{Value, json};
use sqlx::PgPool; use sqlx::PgPool;
use std::collections::HashMap;
use crate::models::Secret; use crate::crypto;
use crate::output::OutputMode; use crate::models::{Entry, SecretField};
use crate::output::{OutputMode, format_local_time};
pub struct SearchArgs<'a> { pub struct SearchArgs<'a> {
pub namespace: Option<&'a str>, pub namespace: Option<&'a str>,
pub kind: Option<&'a str>, pub kind: Option<&'a str>,
pub name: Option<&'a str>, pub name: Option<&'a str>,
pub tag: Option<&'a str>, pub tags: &'a [String],
pub query: Option<&'a str>, pub query: Option<&'a str>,
pub show_secrets: bool,
pub fields: &'a [String], pub fields: &'a [String],
pub summary: bool, pub summary: bool,
pub limit: u32, pub limit: u32,
@@ -21,85 +22,41 @@ pub struct SearchArgs<'a> {
} }
pub async fn run(pool: &PgPool, args: SearchArgs<'_>) -> Result<()> { pub async fn run(pool: &PgPool, args: SearchArgs<'_>) -> Result<()> {
let mut conditions: Vec<String> = Vec::new(); validate_safe_search_args(args.fields)?;
let mut idx: i32 = 1;
if args.namespace.is_some() { let rows = fetch_entries_paged(
conditions.push(format!("namespace = ${}", idx)); pool,
idx += 1; PagedFetchArgs {
} namespace: args.namespace,
if args.kind.is_some() { kind: args.kind,
conditions.push(format!("kind = ${}", idx)); name: args.name,
idx += 1; tags: args.tags,
} query: args.query,
if args.name.is_some() { sort: args.sort,
conditions.push(format!("name = ${}", idx)); limit: args.limit,
idx += 1; offset: args.offset,
} },
if args.tag.is_some() { )
conditions.push(format!("tags @> ARRAY[${}]", idx)); .await?;
idx += 1;
}
if args.query.is_some() {
conditions.push(format!(
"(name ILIKE ${i} OR namespace ILIKE ${i} OR kind ILIKE ${i} OR metadata::text ILIKE ${i} OR EXISTS (SELECT 1 FROM unnest(tags) t WHERE t ILIKE ${i}))",
i = idx
));
idx += 1;
}
let where_clause = if conditions.is_empty() { // -f/--field: extract specific metadata field values directly
String::new()
} else {
format!("WHERE {}", conditions.join(" AND "))
};
let order = match args.sort {
"updated" => "updated_at DESC",
"created" => "created_at DESC",
_ => "namespace, kind, name",
};
let sql = format!(
"SELECT * FROM secrets {} ORDER BY {} LIMIT ${} OFFSET ${}",
where_clause,
order,
idx,
idx + 1
);
tracing::debug!(sql, "executing search query");
let mut q = sqlx::query_as::<_, Secret>(&sql);
if let Some(v) = args.namespace {
q = q.bind(v);
}
if let Some(v) = args.kind {
q = q.bind(v);
}
if let Some(v) = args.name {
q = q.bind(v);
}
if let Some(v) = args.tag {
q = q.bind(v);
}
if let Some(v) = args.query {
q = q.bind(format!("%{}%", v));
}
q = q.bind(args.limit as i64).bind(args.offset as i64);
let rows = q.fetch_all(pool).await?;
// -f/--field: extract specific field values directly
if !args.fields.is_empty() { if !args.fields.is_empty() {
return print_fields(&rows, args.fields); 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 { match args.output {
OutputMode::Json | OutputMode::JsonCompact => { OutputMode::Json | OutputMode::JsonCompact => {
let arr: Vec<Value> = rows let arr: Vec<Value> = rows
.iter() .iter()
.map(|r| to_json(r, args.show_secrets, args.summary)) .map(|r| to_json(r, args.summary, schema_map.get(&r.id).map(Vec::as_slice)))
.collect(); .collect();
let out = if args.output == OutputMode::Json { let out = if args.output == OutputMode::Json {
serde_json::to_string_pretty(&arr)? serde_json::to_string_pretty(&arr)?
@@ -108,26 +65,17 @@ pub async fn run(pool: &PgPool, args: SearchArgs<'_>) -> Result<()> {
}; };
println!("{}", out); println!("{}", out);
} }
OutputMode::Env => {
if rows.len() > 1 {
anyhow::bail!(
"env output requires exactly one record; got {}. Add more filters.",
rows.len()
);
}
if let Some(row) = rows.first() {
print_env(row, args.show_secrets)?;
} else {
eprintln!("No records found.");
}
}
OutputMode::Text => { OutputMode::Text => {
if rows.is_empty() { if rows.is_empty() {
println!("No records found."); println!("No records found.");
return Ok(()); return Ok(());
} }
for row in &rows { for row in &rows {
print_text(row, args.show_secrets, args.summary)?; print_text(
row,
args.summary,
schema_map.get(&row.id).map(Vec::as_slice),
)?;
} }
println!("{} record(s) found.", rows.len()); println!("{} record(s) found.", rows.len());
if rows.len() == args.limit as usize { if rows.len() == args.limit as usize {
@@ -143,123 +91,384 @@ pub async fn run(pool: &PgPool, args: SearchArgs<'_>) -> Result<()> {
Ok(()) Ok(())
} }
fn to_json(row: &Secret, show_secrets: bool, summary: bool) -> Value { 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 { if summary {
let desc = row let desc = entry
.metadata .metadata
.get("desc") .get("desc")
.or_else(|| row.metadata.get("url")) .or_else(|| entry.metadata.get("url"))
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
.unwrap_or("") .unwrap_or("")
.to_string(); .to_string();
return json!({ return json!({
"namespace": row.namespace, "namespace": entry.namespace,
"kind": row.kind, "kind": entry.kind,
"name": row.name, "name": entry.name,
"tags": row.tags, "tags": entry.tags,
"desc": desc, "desc": desc,
"updated_at": row.updated_at.format("%Y-%m-%dT%H:%M:%SZ").to_string(), "updated_at": entry.updated_at.format("%Y-%m-%dT%H:%M:%SZ").to_string(),
}); });
} }
let secrets_val = if show_secrets { let secrets_val: Value = match schema {
row.encrypted.clone() Some(fields) if !fields.is_empty() => {
} else { let schema_arr: Vec<Value> = fields
let keys: Vec<&str> = row .iter()
.encrypted .map(|f| {
.as_object() json!({
.map(|m| m.keys().map(|k| k.as_str()).collect()) "field_name": f.field_name,
.unwrap_or_default(); })
json!({"_hidden_keys": keys}) })
.collect();
Value::Array(schema_arr)
}
_ => Value::Array(vec![]),
}; };
json!({ json!({
"id": row.id, "id": entry.id,
"namespace": row.namespace, "namespace": entry.namespace,
"kind": row.kind, "kind": entry.kind,
"name": row.name, "name": entry.name,
"tags": row.tags, "tags": entry.tags,
"metadata": row.metadata, "metadata": entry.metadata,
"secrets": secrets_val, "secrets": secrets_val,
"created_at": row.created_at.format("%Y-%m-%dT%H:%M:%SZ").to_string(), "version": entry.version,
"updated_at": row.updated_at.format("%Y-%m-%dT%H:%M:%SZ").to_string(), "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(row: &Secret, show_secrets: bool, summary: bool) -> Result<()> { fn print_text(entry: &Entry, summary: bool, schema: Option<&[SecretField]>) -> Result<()> {
println!("[{}/{}] {}", row.namespace, row.kind, row.name); println!("[{}/{}] {}", entry.namespace, entry.kind, entry.name);
if summary { if summary {
let desc = row let desc = entry
.metadata .metadata
.get("desc") .get("desc")
.or_else(|| row.metadata.get("url")) .or_else(|| entry.metadata.get("url"))
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
.unwrap_or("-"); .unwrap_or("-");
if !row.tags.is_empty() { if !entry.tags.is_empty() {
println!(" tags: [{}]", row.tags.join(", ")); println!(" tags: [{}]", entry.tags.join(", "));
} }
println!(" desc: {}", desc); println!(" desc: {}", desc);
println!( println!(" updated: {}", format_local_time(entry.updated_at));
" updated: {}",
row.updated_at.format("%Y-%m-%d %H:%M:%S UTC")
);
} else { } else {
println!(" id: {}", row.id); println!(" id: {}", entry.id);
if !row.tags.is_empty() { if !entry.tags.is_empty() {
println!(" tags: [{}]", row.tags.join(", ")); println!(" tags: [{}]", entry.tags.join(", "));
} }
if row.metadata.as_object().is_some_and(|m| !m.is_empty()) { if entry.metadata.as_object().is_some_and(|m| !m.is_empty()) {
println!( println!(
" metadata: {}", " metadata: {}",
serde_json::to_string_pretty(&row.metadata)? serde_json::to_string_pretty(&entry.metadata)?
); );
} }
if show_secrets { match schema {
println!( Some(fields) if !fields.is_empty() => {
" secrets: {}", let schema_str: Vec<String> = fields.iter().map(|f| f.field_name.clone()).collect();
serde_json::to_string_pretty(&row.encrypted)? println!(" secrets: {}", schema_str.join(", "));
); println!(" (use `secrets run` to get values)");
} else {
let keys: Vec<String> = row
.encrypted
.as_object()
.map(|m| m.keys().cloned().collect())
.unwrap_or_default();
if !keys.is_empty() {
println!(
" secrets: [{}] (--show-secrets to reveal)",
keys.join(", ")
);
} }
_ => {}
} }
println!( println!(" version: {}", entry.version);
" created: {}", println!(" created: {}", format_local_time(entry.created_at));
row.created_at.format("%Y-%m-%d %H:%M:%S UTC")
);
} }
println!(); println!();
Ok(()) Ok(())
} }
fn print_env(row: &Secret, show_secrets: bool) -> Result<()> { /// Extract one or more metadata field paths like `metadata.url`.
let prefix = row.name.to_uppercase().replace(['-', '.'], "_"); fn print_fields(rows: &[Entry], fields: &[String]) -> Result<()> {
if let Some(meta) = row.metadata.as_object() {
for (k, v) in meta {
let key = format!("{}_{}", prefix, k.to_uppercase().replace('-', "_"));
println!("{}={}", key, v.as_str().unwrap_or(&v.to_string()));
}
}
if show_secrets && let Some(enc) = row.encrypted.as_object() {
for (k, v) in enc {
let key = format!("{}_{}", prefix, k.to_uppercase().replace('-', "_"));
println!("{}={}", key, v.as_str().unwrap_or(&v.to_string()));
}
}
Ok(())
}
/// Extract one or more field paths like `metadata.url` or `secret.token`.
fn print_fields(rows: &[Secret], fields: &[String]) -> Result<()> {
for row in rows { for row in rows {
for field in fields { for field in fields {
let val = extract_field(row, field)?; let val = extract_field(row, field)?;
@@ -269,21 +478,14 @@ fn print_fields(rows: &[Secret], fields: &[String]) -> Result<()> {
Ok(()) Ok(())
} }
fn extract_field(row: &Secret, field: &str) -> Result<String> { fn extract_field(entry: &Entry, field: &str) -> Result<String> {
let (section, key) = field.split_once('.').ok_or_else(|| { let (section, key) = field
anyhow::anyhow!( .split_once('.')
"Invalid field path '{}'. Use metadata.<key> or secret.<key>", .ok_or_else(|| anyhow::anyhow!("Invalid field path '{}'. Use metadata.<key>.", field))?;
field
)
})?;
let obj = match section { let obj = match section {
"metadata" | "meta" => &row.metadata, "metadata" | "meta" => &entry.metadata,
"secret" | "secrets" | "encrypted" => &row.encrypted, other => anyhow::bail!("Unknown field section '{}'. Use 'metadata'.", other),
other => anyhow::bail!(
"Unknown field section '{}'. Use 'metadata' or 'secret'",
other
),
}; };
obj.get(key) obj.get(key)
@@ -296,9 +498,71 @@ fn extract_field(row: &Secret, field: &str) -> Result<String> {
anyhow::anyhow!( anyhow::anyhow!(
"Field '{}' not found in record [{}/{}/{}]", "Field '{}' not found in record [{}/{}/{}]",
field, field,
row.namespace, entry.namespace,
row.kind, entry.kind,
row.name 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,17 +1,16 @@
use anyhow::Result; use anyhow::Result;
use serde_json::{Map, Value, json}; use serde_json::{Map, Value, json};
use sqlx::{FromRow, PgPool}; use sqlx::PgPool;
use uuid::Uuid; use uuid::Uuid;
use super::add::parse_kv; use super::add::{
collect_field_paths, collect_key_paths, flatten_json_fields, insert_path, parse_key_path,
#[derive(FromRow)] parse_kv, remove_path,
struct UpdateRow { };
id: Uuid, use crate::crypto;
tags: Vec<String>, use crate::db;
metadata: Value, use crate::models::EntryRow;
encrypted: Value, use crate::output::{OutputMode, print_json};
}
pub struct UpdateArgs<'a> { pub struct UpdateArgs<'a> {
pub namespace: &'a str, pub namespace: &'a str,
@@ -23,20 +22,22 @@ pub struct UpdateArgs<'a> {
pub remove_meta: &'a [String], pub remove_meta: &'a [String],
pub secret_entries: &'a [String], pub secret_entries: &'a [String],
pub remove_secrets: &'a [String], pub remove_secrets: &'a [String],
pub output: OutputMode,
} }
pub async fn run(pool: &PgPool, args: UpdateArgs<'_>) -> Result<()> { pub async fn run(pool: &PgPool, args: UpdateArgs<'_>, master_key: &[u8; 32]) -> Result<()> {
let row: Option<UpdateRow> = sqlx::query_as( let mut tx = pool.begin().await?;
r#"
SELECT id, tags, metadata, encrypted let row: Option<EntryRow> = sqlx::query_as(
FROM secrets "SELECT id, version, tags, metadata \
WHERE namespace = $1 AND kind = $2 AND name = $3 FROM entries \
"#, WHERE namespace = $1 AND kind = $2 AND name = $3 \
FOR UPDATE",
) )
.bind(args.namespace) .bind(args.namespace)
.bind(args.kind) .bind(args.kind)
.bind(args.name) .bind(args.name)
.fetch_optional(pool) .fetch_optional(&mut *tx)
.await?; .await?;
let row = row.ok_or_else(|| { let row = row.ok_or_else(|| {
@@ -48,7 +49,26 @@ pub async fn run(pool: &PgPool, args: UpdateArgs<'_>) -> Result<()> {
) )
})?; })?;
// Merge tags // 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; let mut tags: Vec<String> = row.tags;
for t in args.add_tags { for t in args.add_tags {
if !tags.contains(t) { if !tags.contains(t) {
@@ -57,68 +77,161 @@ pub async fn run(pool: &PgPool, args: UpdateArgs<'_>) -> Result<()> {
} }
tags.retain(|t| !args.remove_tags.contains(t)); tags.retain(|t| !args.remove_tags.contains(t));
// Merge metadata // ── Merge metadata ────────────────────────────────────────────────────────
let mut meta_map: Map<String, Value> = match row.metadata { let mut meta_map: Map<String, Value> = match row.metadata {
Value::Object(m) => m, Value::Object(m) => m,
_ => Map::new(), _ => Map::new(),
}; };
for entry in args.meta_entries { for entry in args.meta_entries {
let (key, value) = parse_kv(entry)?; let (path, value) = parse_kv(entry)?;
meta_map.insert(key, Value::String(value)); insert_path(&mut meta_map, &path, value)?;
} }
for key in args.remove_meta { for key in args.remove_meta {
meta_map.remove(key); let path = parse_key_path(key)?;
remove_path(&mut meta_map, &path)?;
} }
let metadata = Value::Object(meta_map); let metadata = Value::Object(meta_map);
// Merge encrypted // CAS update of the entry row.
let mut enc_map: Map<String, Value> = match row.encrypted { let result = sqlx::query(
Value::Object(m) => m, "UPDATE entries \
_ => Map::new(), SET tags = $1, metadata = $2, version = version + 1, updated_at = NOW() \
}; WHERE id = $3 AND version = $4",
for entry in args.secret_entries {
let (key, value) = parse_kv(entry)?;
enc_map.insert(key, Value::String(value));
}
for key in args.remove_secrets {
enc_map.remove(key);
}
let encrypted = Value::Object(enc_map);
tracing::debug!(
namespace = args.namespace,
kind = args.kind,
name = args.name,
"updating record"
);
sqlx::query(
r#"
UPDATE secrets
SET tags = $1, metadata = $2, encrypted = $3, updated_at = NOW()
WHERE id = $4
"#,
) )
.bind(&tags) .bind(&tags)
.bind(metadata) .bind(&metadata)
.bind(encrypted)
.bind(row.id) .bind(row.id)
.execute(pool) .bind(row.version)
.execute(&mut *tx)
.await?; .await?;
let meta_keys: Vec<&str> = args if result.rows_affected() == 0 {
.meta_entries tx.rollback().await?;
.iter() anyhow::bail!(
.filter_map(|s| s.split_once('=').map(|(k, _)| k)) "Concurrent modification detected for [{}/{}] {}. Please retry.",
.collect(); args.namespace,
let secret_keys: Vec<&str> = args args.kind,
.secret_entries args.name
.iter() );
.filter_map(|s| s.split_once('=').map(|(k, _)| k)) }
.collect();
crate::audit::log( let new_version = row.version + 1;
pool,
// ── 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", "update",
args.namespace, args.namespace,
args.kind, args.kind,
@@ -127,42 +240,53 @@ pub async fn run(pool: &PgPool, args: UpdateArgs<'_>) -> Result<()> {
"add_tags": args.add_tags, "add_tags": args.add_tags,
"remove_tags": args.remove_tags, "remove_tags": args.remove_tags,
"meta_keys": meta_keys, "meta_keys": meta_keys,
"remove_meta": args.remove_meta, "remove_meta": remove_meta_keys,
"secret_keys": secret_keys, "secret_keys": secret_keys,
"remove_secrets": args.remove_secrets, "remove_secrets": remove_secret_keys,
}), }),
) )
.await; .await;
println!("Updated: [{}/{}] {}", args.namespace, args.kind, args.name); tx.commit().await?;
if !args.add_tags.is_empty() { let result_json = json!({
println!(" +tags: {}", args.add_tags.join(", ")); "action": "updated",
} "namespace": args.namespace,
if !args.remove_tags.is_empty() { "kind": args.kind,
println!(" -tags: {}", args.remove_tags.join(", ")); "name": args.name,
} "add_tags": args.add_tags,
if !args.meta_entries.is_empty() { "remove_tags": args.remove_tags,
let keys: Vec<&str> = args "meta_keys": meta_keys,
.meta_entries "remove_meta": remove_meta_keys,
.iter() "secret_keys": secret_keys,
.filter_map(|s| s.split_once('=').map(|(k, _)| k)) "remove_secrets": remove_secret_keys,
.collect(); });
println!(" +metadata: {}", keys.join(", "));
} match args.output {
if !args.remove_meta.is_empty() { OutputMode::Json | OutputMode::JsonCompact => {
println!(" -metadata: {}", args.remove_meta.join(", ")); print_json(&result_json, &args.output)?;
} }
if !args.secret_entries.is_empty() { _ => {
let keys: Vec<&str> = args println!("Updated: [{}/{}] {}", args.namespace, args.kind, args.name);
.secret_entries if !args.add_tags.is_empty() {
.iter() println!(" +tags: {}", args.add_tags.join(", "));
.filter_map(|s| s.split_once('=').map(|(k, _)| k)) }
.collect(); if !args.remove_tags.is_empty() {
println!(" +secrets: {}", keys.join(", ")); println!(" -tags: {}", args.remove_tags.join(", "));
} }
if !args.remove_secrets.is_empty() { if !args.meta_entries.is_empty() {
println!(" -secrets: {}", args.remove_secrets.join(", ")); 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(()) Ok(())

411
src/commands/upgrade.rs Normal file
View File

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

@@ -8,52 +8,59 @@ pub struct Config {
pub database_url: Option<String>, pub database_url: Option<String>,
} }
pub fn config_dir() -> PathBuf { pub fn config_dir() -> Result<PathBuf> {
dirs::config_dir() let dir = dirs::config_dir()
.unwrap_or_else(|| PathBuf::from("~/.config")) .or_else(|| dirs::home_dir().map(|h| h.join(".config")))
.join("secrets") .context(
"Cannot determine config directory: \
neither XDG_CONFIG_HOME nor HOME is set",
)?
.join("secrets");
Ok(dir)
} }
pub fn config_path() -> PathBuf { pub fn config_path() -> Result<PathBuf> {
config_dir().join("config.toml") Ok(config_dir()?.join("config.toml"))
} }
pub fn load_config() -> Result<Config> { pub fn load_config() -> Result<Config> {
let path = config_path(); let path = config_path()?;
if !path.exists() { if !path.exists() {
return Ok(Config::default()); return Ok(Config::default());
} }
let content = fs::read_to_string(&path) let content = fs::read_to_string(&path)
.with_context(|| format!("读取配置文件失败: {}", path.display()))?; .with_context(|| format!("failed to read config file: {}", path.display()))?;
let config: Config = toml::from_str(&content) let config: Config = toml::from_str(&content)
.with_context(|| format!("解析配置文件失败: {}", path.display()))?; .with_context(|| format!("failed to parse config file: {}", path.display()))?;
Ok(config) Ok(config)
} }
pub fn save_config(config: &Config) -> Result<()> { pub fn save_config(config: &Config) -> Result<()> {
let dir = config_dir(); let dir = config_dir()?;
fs::create_dir_all(&dir).with_context(|| format!("创建配置目录失败: {}", dir.display()))?; fs::create_dir_all(&dir)
.with_context(|| format!("failed to create config dir: {}", dir.display()))?;
let path = config_path(); let path = dir.join("config.toml");
let content = toml::to_string_pretty(config).context("序列化配置失败")?; let content = toml::to_string_pretty(config).context("failed to serialize config")?;
fs::write(&path, &content).with_context(|| format!("写入配置文件失败: {}", path.display()))?; fs::write(&path, &content)
.with_context(|| format!("failed to write config file: {}", path.display()))?;
// 设置文件权限为 0600仅所有者读写 // Set file permissions to 0600 (owner read/write only)
#[cfg(unix)] #[cfg(unix)]
{ {
use std::os::unix::fs::PermissionsExt; use std::os::unix::fs::PermissionsExt;
let perms = fs::Permissions::from_mode(0o600); let perms = fs::Permissions::from_mode(0o600);
fs::set_permissions(&path, perms) fs::set_permissions(&path, perms)
.with_context(|| format!("设置文件权限失败: {}", path.display()))?; .with_context(|| format!("failed to set file permissions: {}", path.display()))?;
} }
Ok(()) Ok(())
} }
/// 按优先级解析数据库连接串: /// Resolve database URL by priority:
/// 1. --db-url CLI 参数(非空时使用) /// 1. --db-url CLI flag (if non-empty)
/// 2. ~/.config/secrets/config.toml 中的 database_url /// 2. database_url in ~/.config/secrets/config.toml
/// 3. 报错并提示用户配置 /// 3. Error with setup instructions
pub fn resolve_db_url(cli_db_url: &str) -> Result<String> { pub fn resolve_db_url(cli_db_url: &str) -> Result<String> {
if !cli_db_url.is_empty() { if !cli_db_url.is_empty() {
return Ok(cli_db_url.to_string()); return Ok(cli_db_url.to_string());
@@ -66,5 +73,5 @@ pub fn resolve_db_url(cli_db_url: &str) -> Result<String> {
return Ok(url); return Ok(url);
} }
anyhow::bail!("数据库未配置。请先运行:\n\n secrets config set-db <DATABASE_URL>\n") anyhow::bail!("Database not configured. Run:\n\n secrets config set-db <DATABASE_URL>\n")
} }

195
src/crypto.rs Normal file
View File

@@ -0,0 +1,195 @@
use aes_gcm::{
Aes256Gcm, Key, Nonce,
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.
/// Returns `nonce (12 B) || ciphertext+tag`.
pub fn encrypt(master_key: &[u8; 32], plaintext: &[u8]) -> Result<Vec<u8>> {
let key = Key::<Aes256Gcm>::from_slice(master_key);
let cipher = Aes256Gcm::new(key);
let nonce = Aes256Gcm::generate_nonce(&mut OsRng);
let ciphertext = cipher
.encrypt(&nonce, plaintext)
.map_err(|e| anyhow::anyhow!("AES-256-GCM encryption failed: {}", e))?;
let mut out = Vec::with_capacity(NONCE_LEN + ciphertext.len());
out.extend_from_slice(&nonce);
out.extend_from_slice(&ciphertext);
Ok(out)
}
/// Decrypt `nonce (12 B) || ciphertext+tag` with AES-256-GCM.
pub fn decrypt(master_key: &[u8; 32], data: &[u8]) -> Result<Vec<u8>> {
if data.len() < NONCE_LEN {
bail!(
"encrypted data too short ({}B); possibly corrupted",
data.len()
);
}
let (nonce_bytes, ciphertext) = data.split_at(NONCE_LEN);
let key = Key::<Aes256Gcm>::from_slice(master_key);
let cipher = Aes256Gcm::new(key);
let nonce = Nonce::from_slice(nonce_bytes);
cipher
.decrypt(nonce, ciphertext)
.map_err(|_| anyhow::anyhow!("decryption failed — wrong master key or corrupted data"))
}
// ─── JSON helpers ─────────────────────────────────────────────────────────────
/// Serialize a JSON Value and encrypt it. Returns the encrypted blob.
pub fn encrypt_json(master_key: &[u8; 32], value: &Value) -> Result<Vec<u8>> {
let bytes = serde_json::to_vec(value).context("serialize JSON for encryption")?;
encrypt(master_key, &bytes)
}
/// Decrypt an encrypted blob and deserialize it as a JSON Value.
pub fn decrypt_json(master_key: &[u8; 32], data: &[u8]) -> Result<Value> {
let bytes = decrypt(master_key, data)?;
serde_json::from_slice(&bytes).context("deserialize decrypted JSON")
}
// ─── OS Keychain ──────────────────────────────────────────────────────────────
/// 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)?;
if bytes.len() != 32 {
bail!(
"stored master key has unexpected length {}; re-run `secrets init`",
bytes.len()
);
}
let mut key = [0u8; 32];
key.copy_from_slice(&bytes);
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(())
}
// ─── Minimal hex helpers (avoid extra dep) ────────────────────────────────────
mod hex {
use anyhow::{Result, bail};
pub fn encode_hex(bytes: &[u8]) -> String {
bytes.iter().map(|b| format!("{:02x}", b)).collect()
}
pub fn decode_hex(s: &str) -> Result<Vec<u8>> {
if !s.len().is_multiple_of(2) {
bail!("hex string has odd length");
}
(0..s.len())
.step_by(2)
.map(|i| u8::from_str_radix(&s[i..i + 2], 16).map_err(|e| anyhow::anyhow!("{}", e)))
.collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn roundtrip_encrypt_decrypt() {
let key = [0x42u8; 32];
let plaintext = b"hello world";
let enc = encrypt(&key, plaintext).unwrap();
let dec = decrypt(&key, &enc).unwrap();
assert_eq!(dec, plaintext);
}
#[test]
fn encrypt_produces_different_ciphertexts() {
let key = [0x42u8; 32];
let plaintext = b"hello world";
let enc1 = encrypt(&key, plaintext).unwrap();
let enc2 = encrypt(&key, plaintext).unwrap();
// Different nonces → different ciphertexts
assert_ne!(enc1, enc2);
}
#[test]
fn wrong_key_fails_decryption() {
let key1 = [0x42u8; 32];
let key2 = [0x43u8; 32];
let enc = encrypt(&key1, b"secret").unwrap();
assert!(decrypt(&key2, &enc).is_err());
}
#[test]
fn json_roundtrip() {
let key = [0x42u8; 32];
let value = serde_json::json!({"token": "abc123", "password": "hunter2"});
let enc = encrypt_json(&key, &value).unwrap();
let dec = decrypt_json(&key, &enc).unwrap();
assert_eq!(dec, value);
}
#[test]
fn 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);
}
}

179
src/db.rs
View File

@@ -1,11 +1,15 @@
use anyhow::Result; use anyhow::Result;
use serde_json::Value;
use sqlx::PgPool; use sqlx::PgPool;
use sqlx::postgres::PgPoolOptions; use sqlx::postgres::PgPoolOptions;
use crate::audit::current_actor;
pub async fn create_pool(database_url: &str) -> Result<PgPool> { pub async fn create_pool(database_url: &str) -> Result<PgPool> {
tracing::debug!("connecting to database"); tracing::debug!("connecting to database");
let pool = PgPoolOptions::new() let pool = PgPoolOptions::new()
.max_connections(5) .max_connections(5)
.acquire_timeout(std::time::Duration::from_secs(5))
.connect(database_url) .connect(database_url)
.await?; .await?;
tracing::debug!("database connection established"); tracing::debug!("database connection established");
@@ -16,30 +20,46 @@ pub async fn migrate(pool: &PgPool) -> Result<()> {
tracing::debug!("running migrations"); tracing::debug!("running migrations");
sqlx::raw_sql( sqlx::raw_sql(
r#" r#"
CREATE TABLE IF NOT EXISTS secrets ( -- ── entries: top-level entities (server, service, key, …) ──────────────
CREATE TABLE IF NOT EXISTS entries (
id UUID PRIMARY KEY DEFAULT uuidv7(), id UUID PRIMARY KEY DEFAULT uuidv7(),
namespace VARCHAR(64) NOT NULL, namespace VARCHAR(64) NOT NULL,
kind VARCHAR(64) NOT NULL, kind VARCHAR(64) NOT NULL,
name VARCHAR(256) NOT NULL, name VARCHAR(256) NOT NULL,
tags TEXT[] NOT NULL DEFAULT '{}', tags TEXT[] NOT NULL DEFAULT '{}',
metadata JSONB NOT NULL DEFAULT '{}', metadata JSONB NOT NULL DEFAULT '{}',
encrypted JSONB NOT NULL DEFAULT '{}', version BIGINT NOT NULL DEFAULT 1,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(namespace, kind, name) UNIQUE(namespace, kind, name)
); );
-- idempotent column add for existing tables CREATE INDEX IF NOT EXISTS idx_entries_namespace ON entries(namespace);
DO $$ BEGIN CREATE INDEX IF NOT EXISTS idx_entries_kind ON entries(kind);
ALTER TABLE secrets ADD COLUMN IF NOT EXISTS metadata JSONB NOT NULL DEFAULT '{}'; CREATE INDEX IF NOT EXISTS idx_entries_tags ON entries USING GIN(tags);
EXCEPTION WHEN OTHERS THEN NULL; CREATE INDEX IF NOT EXISTS idx_entries_metadata ON entries USING GIN(metadata jsonb_path_ops);
END $$;
CREATE INDEX IF NOT EXISTS idx_secrets_namespace ON secrets(namespace); -- ── secrets: one row per encrypted field, plaintext schema metadata ────
CREATE INDEX IF NOT EXISTS idx_secrets_kind ON secrets(kind); CREATE TABLE IF NOT EXISTS secrets (
CREATE INDEX IF NOT EXISTS idx_secrets_tags ON secrets USING GIN(tags); id UUID PRIMARY KEY DEFAULT uuidv7(),
CREATE INDEX IF NOT EXISTS idx_secrets_metadata ON secrets USING GIN(metadata jsonb_path_ops); 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 ( CREATE TABLE IF NOT EXISTS audit_log (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
action VARCHAR(32) NOT NULL, action VARCHAR(32) NOT NULL,
@@ -51,8 +71,46 @@ pub async fn migrate(pool: &PgPool) -> Result<()> {
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() 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_created ON audit_log(created_at DESC);
CREATE INDEX IF NOT EXISTS idx_audit_log_ns_kind ON audit_log(namespace, kind); CREATE INDEX IF NOT EXISTS idx_audit_log_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) .execute(pool)
@@ -60,3 +118,98 @@ pub async fn migrate(pool: &PgPool) -> Result<()> {
tracing::debug!("migrations complete"); tracing::debug!("migrations complete");
Ok(()) 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,11 +1,17 @@
mod audit; mod audit;
mod commands; mod commands;
mod config; mod config;
mod crypto;
mod db; mod db;
mod models; mod models;
mod output; mod output;
use anyhow::Result; 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 clap::{Parser, Subcommand};
use tracing_subscriber::EnvFilter; use tracing_subscriber::EnvFilter;
@@ -16,18 +22,27 @@ use output::resolve_output_mode;
name = "secrets", name = "secrets",
version, version,
about = "Secrets & config manager backed by PostgreSQL — optimised for AI agents", about = "Secrets & config manager backed by PostgreSQL — optimised for AI agents",
after_help = "QUICK START (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 # Discover what namespaces / kinds exist
secrets search --summary --limit 20 secrets search --summary --limit 20
# Precise lookup (JSON output for easy parsing) # Precise lookup (JSON output for easy parsing)
secrets search -n refining --kind service --name gitea -o json --show-secrets secrets search -n refining --kind service --name gitea -o json
# Extract a single field value directly # Extract a single metadata field directly
secrets search -n refining --kind service --name gitea -f secret.token secrets search -n refining --kind service --name gitea -f metadata.url
# Pipe-friendly (non-TTY defaults to json-compact automatically) # Pipe-friendly (non-TTY defaults to json-compact automatically)
secrets search -n refining --kind service | jq '.[].name'" 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 { struct Cli {
/// Database URL, overrides saved config (one-time override) /// Database URL, overrides saved config (one-time override)
@@ -44,24 +59,65 @@ struct Cli {
#[derive(Subcommand)] #[derive(Subcommand)]
enum Commands { 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. /// Add or update a record (upsert). Use -m for plaintext metadata, -s for secrets.
#[command(after_help = "EXAMPLES: #[command(after_help = "EXAMPLES:
# Add a server # Add a server
secrets add -n refining --kind server --name my-server \\ secrets add -n refining --kind server --name my-server \\
--tag aliyun --tag shanghai \\ --tag aliyun --tag shanghai \\
-m ip=47.117.131.22 -m desc=\"Aliyun Shanghai ECS\" \\ -m ip=10.0.0.1 -m desc=\"Example ECS\" \\
-s username=root -s ssh_key=@./keys/server.pem -s username=root -s ssh_key=@./keys/server.pem
# Add a service credential # Add a service credential
secrets add -n refining --kind service --name gitea \\ secrets add -n refining --kind service --name gitea \\
--tag gitea \\ --tag gitea \\
-m url=https://gitea.refining.dev -m default_org=refining \\ -m url=https://code.example.com -m default_org=myorg \\
-s token=<token> -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 # Add with token read from a file
secrets add -n ricnsmart --kind service --name mqtt \\ secrets add -n ricnsmart --kind service --name mqtt \\
-m host=mqtt.ricnsmart.com -m port=1883 \\ -m host=mqtt.example.com -m port=1883 \\
-s password=@./mqtt_password.txt")] -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 { Add {
/// Namespace, e.g. refining, ricnsmart /// Namespace, e.g. refining, ricnsmart
#[arg(short, long)] #[arg(short, long)]
@@ -69,19 +125,20 @@ enum Commands {
/// Kind of record: server, service, key, ... /// Kind of record: server, service, key, ...
#[arg(long)] #[arg(long)]
kind: String, kind: String,
/// Human-readable unique name, e.g. gitea, i-uf63f2uookgs5uxmrdyc /// Human-readable unique name, e.g. gitea, i-example0abcd1234efgh
#[arg(long)] #[arg(long)]
name: String, name: String,
/// Tag for categorization (repeatable), e.g. --tag aliyun --tag hongkong /// Tag for categorization (repeatable), e.g. --tag aliyun --tag hongkong
#[arg(long = "tag")] #[arg(long = "tag")]
tags: Vec<String>, tags: Vec<String>,
/// Plaintext metadata: key=value (repeatable; value=@file reads from file) /// 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')] #[arg(long = "meta", short = 'm')]
meta: Vec<String>, meta: Vec<String>,
/// Secret entry: key=value (repeatable; value=@file reads from file) /// Secret entry: key=value, key:=<json>, key=@file, or nested:path@file
#[arg(long = "secret", short = 's')] #[arg(long = "secret", short = 's')]
secrets: Vec<String>, secrets: Vec<String>,
/// Output format: text (default on TTY), json, json-compact, env /// Output format: text (default on TTY), json, json-compact
#[arg(short, long = "output")] #[arg(short, long = "output")]
output: Option<String>, output: Option<String>,
}, },
@@ -90,7 +147,7 @@ enum Commands {
/// ///
/// Supports fuzzy search (-q), exact lookup (--name), field extraction (-f), /// Supports fuzzy search (-q), exact lookup (--name), field extraction (-f),
/// summary view (--summary), pagination (--limit / --offset), and structured /// summary view (--summary), pagination (--limit / --offset), and structured
/// output (-o json / json-compact / env). When stdout is not a TTY, output /// output (-o json / json-compact). When stdout is not a TTY, output
/// defaults to json-compact automatically. /// defaults to json-compact automatically.
#[command(after_help = "EXAMPLES: #[command(after_help = "EXAMPLES:
# Discover all records (summary, safe default limit) # Discover all records (summary, safe default limit)
@@ -105,19 +162,15 @@ enum Commands {
# Fuzzy keyword search (matches name, namespace, kind, tags, metadata) # Fuzzy keyword search (matches name, namespace, kind, tags, metadata)
secrets search -q mqtt secrets search -q mqtt
# Extract a single field value (implies --show-secrets for secret.*) # Extract a single metadata field value
secrets search -n refining --kind service --name gitea -f secret.token
secrets search -n refining --kind service --name gitea -f metadata.url secrets search -n refining --kind service --name gitea -f metadata.url
# Multiple fields at once # Multiple fields at once
secrets search -n refining --kind service --name gitea \\ secrets search -n refining --kind service --name gitea \\
-f metadata.url -f metadata.default_org -f secret.token -f metadata.url -f metadata.default_org
# Full JSON output with secrets revealed (ideal for AI parsing) # Run a command with decrypted secrets only when needed
secrets search -n refining --kind service --name gitea -o json --show-secrets secrets run -n refining --kind service --name gitea -- printenv
# Export as env vars (source-able; single record only)
secrets search -n refining --kind service --name gitea -o env --show-secrets
# Paginate large result sets # Paginate large result sets
secrets search -n refining --summary --limit 10 --offset 0 secrets search -n refining --summary --limit 10 --offset 0
@@ -127,8 +180,7 @@ enum Commands {
secrets search --sort updated --limit 5 --summary secrets search --sort updated --limit 5 --summary
# Non-TTY / pipe: output is json-compact by default # Non-TTY / pipe: output is json-compact by default
secrets search -n refining --kind service | jq '.[].name' secrets search -n refining --kind service | jq '.[].name'")]
secrets search -n refining --kind service --name gitea --show-secrets | jq '.secrets.token'")]
Search { Search {
/// Filter by namespace, e.g. refining, ricnsmart /// Filter by namespace, e.g. refining, ricnsmart
#[arg(short, long)] #[arg(short, long)]
@@ -136,19 +188,16 @@ enum Commands {
/// Filter by kind, e.g. server, service /// Filter by kind, e.g. server, service
#[arg(long)] #[arg(long)]
kind: Option<String>, kind: Option<String>,
/// Exact name filter, e.g. gitea, i-uf63f2uookgs5uxmrdyc /// Exact name filter, e.g. gitea, i-example0abcd1234efgh
#[arg(long)] #[arg(long)]
name: Option<String>, name: Option<String>,
/// Filter by tag, e.g. --tag aliyun /// Filter by tag, e.g. --tag aliyun (repeatable for AND intersection)
#[arg(long)] #[arg(long)]
tag: Option<String>, tag: Vec<String>,
/// Fuzzy keyword (matches name, namespace, kind, tags, metadata text) /// Fuzzy keyword (matches name, namespace, kind, tags, metadata text)
#[arg(short, long)] #[arg(short, long)]
query: Option<String>, query: Option<String>,
/// Reveal encrypted secret values in output /// Extract metadata field value(s) directly: metadata.<key> (repeatable)
#[arg(long)]
show_secrets: bool,
/// Extract field value(s) directly: metadata.<key> or secret.<key> (repeatable)
#[arg(short = 'f', long = "field")] #[arg(short = 'f', long = "field")]
fields: Vec<String>, fields: Vec<String>,
/// Return lightweight summary only (namespace, kind, name, tags, desc, updated_at) /// Return lightweight summary only (namespace, kind, name, tags, desc, updated_at)
@@ -163,28 +212,47 @@ enum Commands {
/// Sort order: name (default), updated, created /// Sort order: name (default), updated, created
#[arg(long, default_value = "name")] #[arg(long, default_value = "name")]
sort: String, sort: String,
/// Output format: text (default on TTY), json, json-compact, env /// Output format: text (default on TTY), json, json-compact
#[arg(short, long = "output")] #[arg(short, long = "output")]
output: Option<String>, output: Option<String>,
}, },
/// Delete a record permanently. Requires exact namespace + kind + name. /// 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: #[command(after_help = "EXAMPLES:
# Delete a service credential # Delete a single record (exact match)
secrets delete -n refining --kind service --name legacy-mqtt secrets delete -n refining --kind service --name legacy-mqtt
# Delete a server record # Preview what a bulk delete would remove (no writes)
secrets delete -n ricnsmart --kind server --name i-old-server-id")] 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 { Delete {
/// Namespace, e.g. refining /// Namespace, e.g. refining
#[arg(short, long)] #[arg(short, long)]
namespace: String, namespace: String,
/// Kind, e.g. server, service /// Kind filter, e.g. server, service (required with --name; optional for bulk)
#[arg(long)] #[arg(long)]
kind: String, kind: Option<String>,
/// Exact name of the record to delete /// Exact name of the record to delete (omit for bulk delete)
#[arg(long)] #[arg(long)]
name: String, 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). /// Incrementally update an existing record (merge semantics; record must exist).
@@ -198,6 +266,11 @@ enum Commands {
# Rotate a secret token # Rotate a secret token
secrets update -n refining --kind service --name gitea -s token=<new-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 # Add a tag and rotate password at the same time
secrets update -n refining --kind service --name gitea \\ secrets update -n refining --kind service --name gitea \\
--add-tag production -s token=<new-token> --add-tag production -s token=<new-token>
@@ -206,8 +279,25 @@ enum Commands {
secrets update -n refining --kind service --name mqtt \\ secrets update -n refining --kind service --name mqtt \\
--remove-meta old_port --remove-secret old_password --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 # Remove a tag
secrets update -n refining --kind service --name gitea --remove-tag staging")] 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 { Update {
/// Namespace, e.g. refining, ricnsmart /// Namespace, e.g. refining, ricnsmart
#[arg(short, long)] #[arg(short, long)]
@@ -224,18 +314,22 @@ enum Commands {
/// Remove a tag (repeatable) /// Remove a tag (repeatable)
#[arg(long = "remove-tag")] #[arg(long = "remove-tag")]
remove_tags: Vec<String>, remove_tags: Vec<String>,
/// Set or overwrite a metadata field: key=value (repeatable, @file supported) /// 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')] #[arg(long = "meta", short = 'm')]
meta: Vec<String>, meta: Vec<String>,
/// Delete a metadata field by key (repeatable) /// Delete a metadata field by key or nested path, e.g. old_port or credentials:content
#[arg(long = "remove-meta")] #[arg(long = "remove-meta")]
remove_meta: Vec<String>, remove_meta: Vec<String>,
/// Set or overwrite a secret field: key=value (repeatable, @file supported) /// Set or overwrite a secret field: key=value, key:=<json>, key=@file, or nested:path@file
#[arg(long = "secret", short = 's')] #[arg(long = "secret", short = 's')]
secrets: Vec<String>, secrets: Vec<String>,
/// Delete a secret field by key (repeatable) /// Delete a secret field by key or nested path, e.g. old_password or credentials:content
#[arg(long = "remove-secret")] #[arg(long = "remove-secret")]
remove_secrets: Vec<String>, 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.) /// Manage CLI configuration (database connection, etc.)
@@ -252,6 +346,197 @@ enum Commands {
#[command(subcommand)] #[command(subcommand)]
action: ConfigAction, 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)] #[derive(Subcommand)]
@@ -269,6 +554,7 @@ enum ConfigAction {
#[tokio::main] #[tokio::main]
async fn main() -> Result<()> { async fn main() -> Result<()> {
load_dotenv();
let cli = Cli::parse(); let cli = Cli::parse();
let filter = if cli.verbose { let filter = if cli.verbose {
@@ -281,23 +567,31 @@ async fn main() -> Result<()> {
.with_target(false) .with_target(false)
.init(); .init();
// config 子命令不需要数据库连接,提前处理 // config subcommand needs no database or master key
if let Commands::Config { action } = &cli.command { if let Commands::Config { action } = cli.command {
let cmd_action = match action { return commands::config::run(action).await;
ConfigAction::SetDb { url } => { }
commands::config::ConfigAction::SetDb { url: url.clone() }
} // upgrade needs no database or master key either
ConfigAction::Show => commands::config::ConfigAction::Show, if let Commands::Upgrade { check } = cli.command {
ConfigAction::Path => commands::config::ConfigAction::Path, return commands::upgrade::run(check).await;
};
return commands::config::run(cmd_action).await;
} }
let db_url = config::resolve_db_url(&cli.db_url)?; let db_url = config::resolve_db_url(&cli.db_url)?;
let pool = db::create_pool(&db_url).await?; let pool = db::create_pool(&db_url).await?;
db::migrate(&pool).await?; db::migrate(&pool).await?;
match &cli.command { // 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 { Commands::Add {
namespace, namespace,
kind, kind,
@@ -307,30 +601,32 @@ async fn main() -> Result<()> {
secrets, secrets,
output, output,
} => { } => {
let master_key = crypto::load_master_key()?;
let _span = let _span =
tracing::info_span!("cmd", command = "add", %namespace, %kind, %name).entered(); tracing::info_span!("cmd", command = "add", %namespace, %kind, %name).entered();
let out = resolve_output_mode(output.as_deref())?; let out = resolve_output_mode(output.as_deref())?;
commands::add::run( commands::add::run(
&pool, &pool,
commands::add::AddArgs { commands::add::AddArgs {
namespace, namespace: &namespace,
kind, kind: &kind,
name, name: &name,
tags, tags: &tags,
meta_entries: meta, meta_entries: &meta,
secret_entries: secrets, secret_entries: &secrets,
output: out, output: out,
}, },
&master_key,
) )
.await?; .await?;
} }
Commands::Search { Commands::Search {
namespace, namespace,
kind, kind,
name, name,
tag, tag,
query, query,
show_secrets,
fields, fields,
summary, summary,
limit, limit,
@@ -339,8 +635,6 @@ async fn main() -> Result<()> {
output, output,
} => { } => {
let _span = tracing::info_span!("cmd", command = "search").entered(); let _span = tracing::info_span!("cmd", command = "search").entered();
// -f implies --show-secrets when any field path starts with "secret"
let show = *show_secrets || fields.iter().any(|f| f.starts_with("secret"));
let out = resolve_output_mode(output.as_deref())?; let out = resolve_output_mode(output.as_deref())?;
commands::search::run( commands::search::run(
&pool, &pool,
@@ -348,28 +642,42 @@ async fn main() -> Result<()> {
namespace: namespace.as_deref(), namespace: namespace.as_deref(),
kind: kind.as_deref(), kind: kind.as_deref(),
name: name.as_deref(), name: name.as_deref(),
tag: tag.as_deref(), tags: &tag,
query: query.as_deref(), query: query.as_deref(),
show_secrets: show, fields: &fields,
fields, summary,
summary: *summary, limit,
limit: *limit, offset,
offset: *offset, sort: &sort,
sort,
output: out, output: out,
}, },
) )
.await?; .await?;
} }
Commands::Delete { Commands::Delete {
namespace, namespace,
kind, kind,
name, name,
dry_run,
output,
} => { } => {
let _span = let _span =
tracing::info_span!("cmd", command = "delete", %namespace, %kind, %name).entered(); tracing::info_span!("cmd", command = "delete", %namespace, ?kind, ?name).entered();
commands::delete::run(&pool, namespace, kind, name).await?; 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 { Commands::Update {
namespace, namespace,
kind, kind,
@@ -380,26 +688,165 @@ async fn main() -> Result<()> {
remove_meta, remove_meta,
secrets, secrets,
remove_secrets, remove_secrets,
output,
} => { } => {
let master_key = crypto::load_master_key()?;
let _span = let _span =
tracing::info_span!("cmd", command = "update", %namespace, %kind, %name).entered(); tracing::info_span!("cmd", command = "update", %namespace, %kind, %name).entered();
let out = resolve_output_mode(output.as_deref())?;
commands::update::run( commands::update::run(
&pool, &pool,
commands::update::UpdateArgs { commands::update::UpdateArgs {
namespace, namespace: &namespace,
kind, kind: &kind,
name, name: &name,
add_tags, add_tags: &add_tags,
remove_tags, remove_tags: &remove_tags,
meta_entries: meta, meta_entries: &meta,
remove_meta, remove_meta: &remove_meta,
secret_entries: secrets, secret_entries: &secrets,
remove_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?; .await?;
} }
Commands::Config { .. } => unreachable!(),
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(()) Ok(())

View File

@@ -1,17 +1,211 @@
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::Value; use serde_json::Value;
use std::collections::BTreeMap;
use uuid::Uuid; use uuid::Uuid;
/// A top-level entry (server, service, key, …).
/// Sensitive fields are stored separately in `secrets`.
#[derive(Debug, Serialize, Deserialize, sqlx::FromRow)] #[derive(Debug, Serialize, Deserialize, sqlx::FromRow)]
pub struct Secret { pub struct Entry {
pub id: Uuid, pub id: Uuid,
pub namespace: String, pub namespace: String,
pub kind: String, pub kind: String,
pub name: String, pub name: String,
pub tags: Vec<String>, pub tags: Vec<String>,
pub metadata: Value, pub metadata: Value,
pub encrypted: Value, pub version: i64,
pub created_at: DateTime<Utc>, pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>, pub updated_at: DateTime<Utc>,
} }
/// A single encrypted field belonging to an Entry.
#[derive(Debug, Serialize, Deserialize, sqlx::FromRow)]
pub struct SecretField {
pub id: Uuid,
pub entry_id: Uuid,
pub field_name: String,
/// AES-256-GCM ciphertext: nonce(12B) || ciphertext+tag
pub encrypted: Vec<u8>,
pub version: i64,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
// ── Internal query row types (shared across commands) ─────────────────────────
/// Minimal entry row fetched for write operations (add / update / delete / rollback).
#[derive(Debug, sqlx::FromRow)]
pub struct EntryRow {
pub id: Uuid,
pub version: i64,
pub tags: Vec<String>,
pub metadata: Value,
}
/// Minimal secret field row fetched before snapshots or cascade deletes.
#[derive(Debug, sqlx::FromRow)]
pub struct SecretFieldRow {
pub id: Uuid,
pub field_name: String,
pub encrypted: Vec<u8>,
}
// ── Export / Import types ──────────────────────────────────────────────────────
/// Supported file formats for export/import.
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum ExportFormat {
Json,
Toml,
Yaml,
}
impl 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
)
})
}
/// Parse from --format CLI value.
pub fn from_str(s: &str) -> anyhow::Result<Self> {
match s.to_lowercase().as_str() {
"json" => Ok(Self::Json),
"toml" => Ok(Self::Toml),
"yaml" | "yml" => Ok(Self::Yaml),
other => anyhow::bail!("Unknown format '{}'. Expected: json, toml, or yaml", other),
}
}
/// Serialize ExportData to a string in this format.
pub fn serialize(&self, data: &ExportData) -> anyhow::Result<String> {
match self {
Self::Json => Ok(serde_json::to_string_pretty(data)?),
Self::Toml => {
let toml_val = json_to_toml_value(&serde_json::to_value(data)?)?;
toml::to_string_pretty(&toml_val)
.map_err(|e| anyhow::anyhow!("TOML serialization failed: {}", e))
}
Self::Yaml => serde_yaml::to_string(data)
.map_err(|e| anyhow::anyhow!("YAML serialization failed: {}", e)),
}
}
/// Deserialize ExportData from a string in this format.
pub fn deserialize(&self, content: &str) -> anyhow::Result<ExportData> {
match self {
Self::Json => Ok(serde_json::from_str(content)?),
Self::Toml => {
let toml_val: toml::Value = toml::from_str(content)
.map_err(|e| anyhow::anyhow!("TOML parse error: {}", e))?;
let json_val = toml_to_json_value(&toml_val);
Ok(serde_json::from_value(json_val)?)
}
Self::Yaml => serde_yaml::from_str(content)
.map_err(|e| anyhow::anyhow!("YAML parse error: {}", e)),
}
}
}
/// Top-level structure for export/import files.
#[derive(Debug, Serialize, Deserialize)]
pub struct ExportData {
pub version: u32,
pub exported_at: String,
pub entries: Vec<ExportEntry>,
}
/// A single entry with decrypted secrets for export/import.
#[derive(Debug, Serialize, Deserialize)]
pub struct ExportEntry {
pub namespace: String,
pub kind: String,
pub name: String,
#[serde(default)]
pub tags: Vec<String>,
#[serde(default)]
pub metadata: Value,
/// Decrypted secret fields. None means no secrets in this export (--no-secrets).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub secrets: Option<BTreeMap<String, Value>>,
}
// ── TOML ↔ JSON value conversion ──────────────────────────────────────────────
/// Convert a serde_json Value to a toml Value.
/// `null` values are filtered out (TOML does not support null).
/// Mixed-type arrays are serialised as JSON strings.
pub fn json_to_toml_value(v: &Value) -> anyhow::Result<toml::Value> {
match v {
Value::Null => anyhow::bail!("TOML does not support null values"),
Value::Bool(b) => Ok(toml::Value::Boolean(*b)),
Value::Number(n) => {
if let Some(i) = n.as_i64() {
Ok(toml::Value::Integer(i))
} else if let Some(f) = n.as_f64() {
Ok(toml::Value::Float(f))
} else {
anyhow::bail!("unsupported number: {}", n)
}
}
Value::String(s) => Ok(toml::Value::String(s.clone())),
Value::Array(arr) => {
let items: anyhow::Result<Vec<toml::Value>> =
arr.iter().map(json_to_toml_value).collect();
match items {
Ok(vals) => Ok(toml::Value::Array(vals)),
Err(e) => {
tracing::debug!(error = %e, "mixed-type array; falling back to JSON string");
Ok(toml::Value::String(serde_json::to_string(v)?))
}
}
}
Value::Object(map) => {
let mut toml_map = toml::map::Map::new();
for (k, val) in map {
if val.is_null() {
// Skip null entries
continue;
}
match json_to_toml_value(val) {
Ok(tv) => {
toml_map.insert(k.clone(), tv);
}
Err(e) => {
tracing::debug!(key = %k, error = %e, "field not representable in TOML; falling back to JSON string");
toml_map
.insert(k.clone(), toml::Value::String(serde_json::to_string(val)?));
}
}
}
Ok(toml::Value::Table(toml_map))
}
}
}
/// Convert a toml Value back to a serde_json Value.
pub fn toml_to_json_value(v: &toml::Value) -> Value {
match v {
toml::Value::Boolean(b) => Value::Bool(*b),
toml::Value::Integer(i) => Value::Number((*i).into()),
toml::Value::Float(f) => serde_json::Number::from_f64(*f)
.map(Value::Number)
.unwrap_or(Value::Null),
toml::Value::String(s) => Value::String(s.clone()),
toml::Value::Datetime(dt) => Value::String(dt.to_string()),
toml::Value::Array(arr) => Value::Array(arr.iter().map(toml_to_json_value).collect()),
toml::Value::Table(map) => {
let obj: serde_json::Map<String, Value> = map
.iter()
.map(|(k, v)| (k.clone(), toml_to_json_value(v)))
.collect();
Value::Object(obj)
}
}
}

View File

@@ -1,4 +1,4 @@
use std::io::IsTerminal; use chrono::{DateTime, Local, Utc};
use std::str::FromStr; use std::str::FromStr;
/// Output format for all commands. /// Output format for all commands.
@@ -11,8 +11,6 @@ pub enum OutputMode {
Json, Json,
/// Single-line JSON (default when stdout is NOT a TTY, e.g. piped to jq) /// Single-line JSON (default when stdout is NOT a TTY, e.g. piped to jq)
JsonCompact, JsonCompact,
/// KEY=VALUE pairs suitable for `source` or `.env` files
Env,
} }
impl FromStr for OutputMode { impl FromStr for OutputMode {
@@ -23,9 +21,8 @@ impl FromStr for OutputMode {
"text" => Ok(Self::Text), "text" => Ok(Self::Text),
"json" => Ok(Self::Json), "json" => Ok(Self::Json),
"json-compact" => Ok(Self::JsonCompact), "json-compact" => Ok(Self::JsonCompact),
"env" => Ok(Self::Env),
other => Err(anyhow::anyhow!( other => Err(anyhow::anyhow!(
"Unknown output format '{}'. Valid: text, json, json-compact, env", "Unknown output format '{}'. Valid: text, json, json-compact",
other other
)), )),
} }
@@ -34,14 +31,30 @@ impl FromStr for OutputMode {
/// Resolve the effective output mode. /// Resolve the effective output mode.
/// - Explicit value from `--output` takes priority. /// - Explicit value from `--output` takes priority.
/// - TTY → text; non-TTY (piped/redirected) → json-compact. /// - Default is always `Json` (AI-first); use `-o text` for human-readable output.
pub fn resolve_output_mode(explicit: Option<&str>) -> anyhow::Result<OutputMode> { pub fn resolve_output_mode(explicit: Option<&str>) -> anyhow::Result<OutputMode> {
if let Some(s) = explicit { if let Some(s) = explicit {
return s.parse(); return s.parse();
} }
if std::io::stdout().is_terminal() { Ok(OutputMode::Json)
Ok(OutputMode::Text) }
} else {
Ok(OutputMode::JsonCompact) /// 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

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