Compare commits

..

7 Commits

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

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

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

Bump version to 0.9.4

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

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

Made-with: Cursor
2026-03-19 16:08:27 +08:00
18 changed files with 672 additions and 470 deletions

View File

@@ -17,6 +17,7 @@ permissions:
env:
BINARY_NAME: secrets
SECRETS_UPGRADE_URL: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}/releases/latest
CARGO_INCREMENTAL: 0
CARGO_NET_RETRY: 10
CARGO_TERM_COLOR: always
@@ -406,8 +407,6 @@ jobs:
runs-on: debian
timeout-minutes: 5
steps:
- uses: actions/checkout@v4
- name: 发布草稿
env:
RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }}
@@ -447,7 +446,8 @@ jobs:
tag="${{ needs.version.outputs.tag }}"
tag_exists="${{ needs.version.outputs.tag_exists }}"
commit=$(git log -1 --pretty=format:"%s" 2>/dev/null || echo "N/A")
commit="${{ github.event.head_commit.message }}"
[ -z "$commit" ] && commit="${{ github.sha }}"
url="${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_number }}"
linux_r="${{ needs.build-linux.result }}"

8
.vscode/tasks.json vendored
View File

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

162
AGENTS.md
View File

@@ -15,7 +15,7 @@
secrets/
src/
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
db.rs # PgPool 创建 + 建表/索引DROP+CREATE含所有表
crypto.rs # AES-256-GCM 加解密、Argon2id 派生、OS 钥匙串
@@ -30,7 +30,7 @@ secrets/
update.rs # update 命令增量更新secrets 行级 UPSERT/DELETECAS 并发保护
rollback.rs # rollback 命令:按 entry_version 恢复 entry + secrets
history.rs # history 命令:查看 entry 变更历史列表
run.rs # inject / run 命令:逐字段解密 + key_ref 引用解析
run.rs # run 命令:仅 secrets 逐字段解密 + key_ref 引用解析(不含 metadata
upgrade.rs # upgrade 命令:检查、校验摘要并下载最新版本,自动替换二进制
export_cmd.rs # export 命令:批量导出记录,支持 JSON/TOML/YAML含解密明文
import_cmd.rs # import 命令批量导入记录冲突检测dry-run重新加密写入
@@ -71,8 +71,6 @@ 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"
field_type VARCHAR(32) NOT NULL DEFAULT 'string', -- 明文类型: "string"|"number"|"boolean"|"json"
value_len INT NOT NULL DEFAULT 0, -- 明文原始值字符数PEM≈4096token≈40
encrypted BYTEA NOT NULL DEFAULT '\x', -- 仅加密值本身nonce(12B)||ciphertext+tag
version BIGINT NOT NULL DEFAULT 1,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
@@ -130,8 +128,6 @@ secrets_history (
secret_id UUID NOT NULL, -- 对应 secrets.id
entry_version BIGINT NOT NULL, -- 关联 entries_history 的版本号
field_name VARCHAR(256) NOT NULL,
field_type VARCHAR(32) NOT NULL DEFAULT 'string',
value_len INT NOT NULL DEFAULT 0,
encrypted BYTEA NOT NULL DEFAULT '\x',
action VARCHAR(16) NOT NULL, -- 'add' | 'update' | 'delete' | 'rollback'
actor VARCHAR(128) NOT NULL DEFAULT '',
@@ -145,12 +141,10 @@ secrets_history (
|------|--------|------|
| `namespace` | 项目/团队隔离 | `refining`, `ricnsmart` |
| `kind` | 记录类型 | `server`, `service`, `key` |
| `name` | 唯一标识名 | `i-uf63f2uookgs5uxmrdyc`, `gitea` |
| `name` | 唯一标识名 | `i-example0abcd1234efgh`, `gitea` |
| `tags` | 多维分类标签 | `["aliyun","hongkong","ricn"]` |
| `metadata` | 明文非敏感信息 | `{"ip":"47.243.154.187","desc":"Grafana","key_ref":"ricn-hk-260127"}` |
| `metadata` | 明文非敏感信息 | `{"ip":"192.0.2.1","desc":"Grafana","key_ref":"my-shared-key"}` |
| `secrets.field_name` | 加密字段名(明文) | `"username"`, `"token"`, `"ssh_key"` |
| `secrets.field_type` | 值类型(明文) | `"string"`, `"number"`, `"boolean"`, `"json"` |
| `secrets.value_len` | 原始值字符数(明文) | `4`root`40`token`4096`PEM |
| `secrets.encrypted` | 仅加密值本身 | AES-256-GCM 密文 |
### PEM 共享机制key_ref
@@ -159,17 +153,17 @@ secrets_history (
```bash
# 1. 存共享 PEM
secrets add -n refining --kind key --name ricn-hk-260127 \
secrets add -n refining --kind key --name my-shared-key \
--tag aliyun --tag hongkong \
-s content=@./keys/ricn-hk-260127.pem
-s content=@./keys/my-shared-key.pem
# 2. 服务器通过 metadata.key_ref 引用(inject/run 时自动合并 key 的 secrets
secrets add -n refining --kind server --name i-j6c39dmtkr26vztii0ox \
-m ip=47.243.154.187 -m key_ref=ricn-hk-260127 \
# 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 ricn-hk-260127 \
secrets update -n refining --kind key --name my-shared-key \
-s content=@./keys/new-key.pem
```
@@ -209,9 +203,9 @@ secrets init # 提示输入主密码Argon2id 派生主密钥后存入 OS
**读取一律用 `search`,写入用 `add` / `update`,避免反复查帮助。**
输出格式规则:
- TTY终端直接运行→ 默认 `text`
- 非 TTY管道/重定向/AI 调用)→ 自动 `json-compact`
- 显式 `-o json`美化 JSON
- 默认始终输出 `json`pretty-printed无论 TTY 还是管道
- 显式 `-o json-compact` → 单行 JSON管道处理时更紧凑
- 显式 `-o text`人类可读文本格式
---
@@ -231,7 +225,7 @@ secrets init
# 参数说明(带典型值)
# -n / --namespace refining | ricnsmart
# --kind server | service
# --name gitea | i-uf63f2uookgs5uxmrdyc | mqtt
# --name gitea | i-example0abcd1234efgh | mqtt
# --tag aliyun | hongkong | production
# -q / --query mqtt | grafana | gitea (模糊匹配 name/namespace/kind/tags/metadata
# secrets schema search 默认展示 secrets 字段名、类型与长度(无需 master_key
@@ -249,7 +243,7 @@ secrets search --sort updated --limit 10 --summary
# 精确定位单条记录
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 search -n refining --kind service --name gitea -o json
@@ -259,14 +253,13 @@ secrets search -n refining --kind service --name gitea -f metadata.url
secrets search -n refining --kind service --name gitea \
-f metadata.url -f metadata.default_org
# 需要 secrets 时,改用 inject / run
secrets inject -n refining --kind service --name gitea
# 需要 secrets 时,改用 run
secrets run -n refining --kind service --name gitea -- printenv
# 模糊关键词搜索
secrets search -q mqtt
secrets search -q grafana
secrets search -q 47.117
secrets search -q 192.0.2
# 按条件过滤
secrets search -n refining --kind service
@@ -278,7 +271,7 @@ secrets search --tag aliyun --summary
secrets search -n refining --summary --limit 10 --offset 0
secrets search -n refining --summary --limit 10 --offset 10
# 管道 / AI 调用(非 TTY 自动 json-compact
# 管道 / AI 调用(默认 json直接可解析
secrets search -n refining --kind service | jq '.[].name'
```
@@ -290,31 +283,31 @@ secrets search -n refining --kind service | jq '.[].name'
# 参数说明(带典型值)
# -n / --namespace refining | ricnsmart
# --kind server | service
# --name gitea | i-uf63f2uookgs5uxmrdyc
# --name gitea | i-example0abcd1234efgh
# --tag aliyun | hongkong可重复
# -m / --meta ip=47.117.131.22 | desc="Aliyun ECS" | url=https://... | tls:cert@./cert.pem可重复
# -m / --meta ip=10.0.0.1 | desc="ECS" | url=https://... | tls:cert@./cert.pem可重复
# -s / --secret token=<value> | ssh_key=@./key.pem | password=secret123 | credentials:content@./key.pem可重复
# 添加服务器
secrets add -n refining --kind server --name i-uf63f2uookgs5uxmrdyc \
secrets add -n refining --kind server --name i-example0abcd1234efgh \
--tag aliyun --tag shanghai \
-m ip=47.117.131.22 -m desc="Aliyun Shanghai ECS" \
-s username=root -s ssh_key=@./keys/voson_shanghai_e.pem
-m ip=10.0.0.1 -m desc="Aliyun Shanghai ECS" \
-s username=root -s ssh_key=@./keys/deploy-key.pem
# 添加服务凭据
secrets add -n refining --kind service --name gitea \
--tag gitea \
-m url=https://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>
# 从文件读取 token
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
# 多行文件直接写入嵌套 secret 字段
secrets add -n refining --kind server --name i-uf63f2uookgs5uxmrdyc \
-s credentials:content@./keys/voson_shanghai_e.pem
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 \
@@ -334,7 +327,7 @@ secrets add -n refining --kind service --name prometheus \
# 参数说明(带典型值)
# -n / --namespace refining | ricnsmart
# --kind server | service
# --name gitea | i-uf63f2uookgs5uxmrdyc
# --name gitea | i-example0abcd1234efgh
# --add-tag production | backup不影响已有 tag可重复
# --remove-tag staging | deprecated可重复
# -m / --meta ip=10.0.0.1 | desc="新描述" | credentials:username=root新增或覆盖可重复
@@ -343,7 +336,7 @@ secrets add -n refining --kind service --name prometheus \
# --remove-secret old_password | deprecated_key | credentials:content删除 secret 字段,可重复)
# 更新单个 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
# 轮换 token
@@ -360,11 +353,11 @@ secrets update -n refining --kind service --name mqtt \
--remove-meta old_port --remove-secret old_password
# 从文件更新嵌套 secret 字段
secrets update -n refining --kind server --name i-uf63f2uookgs5uxmrdyc \
-s credentials:content@./keys/voson_shanghai_e.pem
secrets update -n refining --kind server --name i-example0abcd1234efgh \
-s credentials:content@./keys/deploy-key.pem
# 删除嵌套字段
secrets update -n refining --kind server --name i-uf63f2uookgs5uxmrdyc \
secrets update -n refining --kind server --name i-example0abcd1234efgh \
--remove-secret credentials:content
# 移除 tag
@@ -373,19 +366,34 @@ secrets update -n refining --kind service --name gitea --remove-tag staging
---
### delete — 删除记录
### delete — 删除记录(支持单条精确删除与批量删除)
删除时会自动将 entry 与所有关联 secret 字段快照到历史表,并写入审计日志,可通过 `rollback` 命令恢复。
```bash
# 参数说明(带典型值)
# -n / --namespace refining | ricnsmart
# --kind server | service
# --name gitea | i-uf63f2uookgs5uxmrdyc必须精确匹配
# -n / --namespace refining | ricnsmart(必填)
# --kind server | service(指定 --name 时必填;批量时可选)
# --name gitea | i-example0abcd1234efgh精确匹配省略则批量删除
# --dry-run 预览将删除的记录,不实际写入(仅批量模式有效)
# -o / --output text | json | json-compact
# 删除服务凭据
# 精确删除单条记录(--kind 必填)
secrets delete -n refining --kind service --name legacy-mqtt
# 删除服务器记录
secrets delete -n ricnsmart --kind server --name i-old-server-id
# 预览批量删除(不写入数据库)
secrets delete -n refining --dry-run
secrets delete -n ricnsmart --kind server --dry-run
# 批量删除整个 namespace 的所有记录
secrets delete -n ricnsmart
# 批量删除 namespace 下指定 kind 的所有记录
secrets delete -n ricnsmart --kind server
# JSON 输出
secrets delete -n refining --kind service -o json
```
---
@@ -429,37 +437,11 @@ secrets rollback -n refining --kind service --name gitea --to-version 3
---
### inject — 输出临时环境变量
敏感值仅打印到 stdout不持久化、不写入当前 shell。
```bash
# 参数说明
# -n / --namespace refining | ricnsmart
# --kind server | service
# --name 记录名
# --tag 按 tag 过滤(可重复)
# --prefix 变量名前缀(留空则以记录 name 作前缀)
# -o / --output text默认 KEY=VALUE| json | json-compact
# 打印单条记录的所有变量KEY=VALUE 格式)
secrets inject -n refining --kind service --name gitea
# 自定义前缀
secrets inject -n refining --kind service --name gitea --prefix GITEA
# JSON 格式(适合管道或脚本解析)
secrets inject -n refining --kind service --name gitea -o json
# eval 注入当前 shell谨慎使用
eval $(secrets inject -n refining --kind service --name gitea)
```
---
### run — 向子进程注入 secrets 并执行命令
secrets 仅作用于子进程环境,不修改当前 shell进程退出码透传。
仅注入 secrets 表中的加密字段(解密后),不含 metadata。secrets 仅作用于子进程环境,不修改当前 shell进程退出码透传。
使用 `-s/--secret` 指定只注入哪些字段(最小权限原则);使用 `--dry-run` 预览将注入哪些变量名及来源,不执行命令。
```bash
# 参数说明
@@ -467,24 +449,39 @@ secrets 仅作用于子进程环境,不修改当前 shell进程退出码透
# --kind server | service
# --name 记录名
# --tag 按 tag 过滤(可重复)
# -s / --secret 只注入指定字段名(可重复;省略则注入全部)
# --prefix 变量名前缀
# -- <command> 执行命令及参数
# --dry-run 预览变量映射,不执行命令
# -o / --output json默认| json-compact | text
# -- <command> 执行的命令及参数(--dry-run 时可省略)
# 向脚本注入单条记录的 secrets
# 注入全部 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
# 验证注入哪些变量
secrets run -n refining --kind service --name gitea -- printenv
# 预览将注入哪些变量(不执行命令,默认 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 二进制
Gitea Release 下载最新版本,校验对应 `.sha256` 摘要后替换当前二进制,无需数据库连接或主密钥。
从 Release 服务器下载最新版本,校验对应 `.sha256` 摘要后替换当前二进制,无需数据库连接或主密钥。
**配置方式**`SECRETS_UPGRADE_URL` 必填。优先用**构建时**`SECRETS_UPGRADE_URL=https://... cargo build`CI 已自动注入。或**运行时**:写在 `.env``export` 后执行。
```bash
# 检查是否有新版本(不下载)
@@ -504,7 +501,7 @@ secrets upgrade
# 参数说明
# -n / --namespace refining | ricnsmart
# --kind server | service
# --name gitea | i-uf63f2uookgs5uxmrdyc
# --name gitea | i-example0abcd1234efgh
# --tag aliyun | production可重复
# -q / --query 模糊关键词
# --file <path> 输出文件路径,格式由扩展名推断(.json / .toml / .yaml / .yml
@@ -605,7 +602,7 @@ secrets --db-url "postgres://..." search -n refining
- 日志:用户可见输出用 `println!`;调试/运维信息用 `tracing::debug!`/`info!`/`warn!`/`error!`
- 审计:`add`/`update`/`delete` 成功后调用 `audit::log_tx`,写入 `audit_log` 表;失败只 warn 不中断
- 加密:`encrypted` 列存储 AES-256-GCM 密文;`add`/`update`/`search`/`delete` 需主密钥(`secrets init` 后从 OS 钥匙串加载)
- 输出:读命令通过 `OutputMode` 支持 text/json/json-compact/env;写命令 `add` 同样支持 `-o json`
- 输出:读命令通过 `OutputMode` 支持 text/json/json-compact;默认始终 `json`pretty`-o text` 供人类阅读;写命令 `add` 同样支持 `-o json`
## 提交前检查(必须全部通过)
@@ -664,5 +661,6 @@ cargo fmt -- --check && cargo clippy -- -D warnings && cargo test
|------|------|
| `RUST_LOG` | 日志级别,如 `secrets=debug``secrets=trace`(默认 warn |
| `USER` | 审计日志 actor 字段来源Shell 自动设置,通常无需手动配置 |
| `SECRETS_UPGRADE_URL` | upgrade 的 Release API 地址。构建时cargo build或运行时.env/export |
数据库连接通过 `secrets config set-db` 持久化到 `~/.config/secrets/config.toml`,不支持环境变量。

3
Cargo.lock generated
View File

@@ -1836,7 +1836,7 @@ checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "secrets"
version = "0.9.1"
version = "0.9.6"
dependencies = [
"aes-gcm",
"anyhow",
@@ -1844,6 +1844,7 @@ dependencies = [
"chrono",
"clap",
"dirs",
"dotenvy",
"flate2",
"keyring",
"rand 0.10.0",

View File

@@ -1,6 +1,6 @@
[package]
name = "secrets"
version = "0.9.1"
version = "0.9.6"
edition = "2024"
[dependencies]
@@ -10,6 +10,7 @@ argon2 = { version = "^0.5.3", features = ["std"] }
chrono = { version = "^0.4.44", features = ["serde"] }
clap = { version = "^4.6.0", features = ["derive"] }
dirs = "^6.0.0"
dotenvy = "^0.15"
flate2 = "^1.1.9"
keyring = { version = "^3.6.3", features = ["apple-native", "windows-native", "linux-native"] }
rand = "^0.10.0"

View File

@@ -54,7 +54,7 @@ secrets search --sort updated --limit 10 --summary
# 精确定位namespace + kind + name 三元组)
secrets search -n refining --kind service --name gitea
# 获取完整记录(含 secrets 字段 schemafield_name、field_type、value_len,无需 master_key
# 获取完整记录(含 secrets 字段,无需 master_key
secrets search -n refining --kind service --name gitea -o json
# 直接提取单个 metadata 字段值(最短路径)
@@ -64,31 +64,35 @@ secrets search -n refining --kind service --name gitea -f metadata.url
secrets search -n refining --kind service --name gitea \
-f metadata.url -f metadata.default_org
# 需要 secrets 时,改用 inject / run
secrets inject -n refining --kind service --name gitea
secrets run -n refining --kind service --name gitea -- printenv
# 需要 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
```
`search` 展示 metadata 与 secrets 的字段 schema字段名、类型、长度不展示 secret 值本身;需要值时用 `inject` / `run`
`search` 展示 metadata 与 secrets 的字段名,不展示 secret 值本身;需要 secret 值时用 `run`(仅注入加密字段到子进程,不含 metadata。用 `-s` 指定只注入特定字段,最小化注入范围
### 输出格式
| 场景 | 推荐命令 |
|------|----------|
| AI 解析 / 管道处理 | `-o json``-o json-compact` |
| 注入 secrets 到环境变量 | `inject` / `run` |
| 人类查看 | 默认 `text`TTY 下自动启用) |
| 非 TTY管道/重定向) | 自动 `json-compact` |
| AI 解析 / 管道处理(默认) | jsonpretty-printed |
| 管道紧凑格式 | `-o json-compact` |
| 注入 secrets 到子进程环境 | `run` |
| 人类查看 | `-o text` |
说明:`text` 输出中时间会按当前机器本地时区显示;`json/json-compact` 继续使用 UTCRFC3339 风格)以便脚本和 AI 稳定解析
默认始终输出 JSON无论是 TTY 还是管道。`text` 输出中时间本地时区显示;`json/json-compact` 使用 UTCRFC3339
```bash
# 管道直接 jq 解析(非 TTY 自动 json-compact
# 默认 JSON 输出,直接 jq 解析
secrets search -n refining --kind service | jq '.[].name'
# 需要 secrets 时,使用 inject / run
secrets inject -n refining --kind service --name gitea > ~/.config/gitea/secrets.env
secrets run -n refining --kind service --name gitea -- ./deploy.sh
# 需要 secrets 时,使用 run-s 指定只注入特定字段)
secrets run -n refining --kind service --name gitea -s token -- ./deploy.sh
# 预览 run 会注入哪些变量(不执行命令)
secrets run -n refining --kind service --name gitea --dry-run
```
## 完整命令参考
@@ -120,7 +124,7 @@ secrets search -n refining --summary --limit 10 --offset 10 # 翻页
# ── add ──────────────────────────────────────────────────────────────────────
secrets add -n refining --kind server --name my-server \
--tag aliyun --tag shanghai \
-m ip=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
# 多行文件直接写入嵌套 secret 字段
@@ -136,7 +140,7 @@ secrets add -n refining --kind service --name deploy-bot \
secrets add -n refining --kind service --name 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>
# ── update ───────────────────────────────────────────────────────────────────
@@ -146,7 +150,10 @@ secrets update -n refining --kind service --name mqtt --remove-meta old_port --r
secrets update -n refining --kind server --name my-server --remove-secret credentials:content
# ── 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 位,派生后存钥匙串)
@@ -158,7 +165,7 @@ secrets config path # 打印配置文件路径
# ── upgrade ──────────────────────────────────────────────────────────────────
secrets upgrade --check # 仅检查是否有新版本
secrets upgrade # 下载、校验 SHA-256 并安装最新版(从 Gitea Release
secrets upgrade # 下载、校验 SHA-256 并安装最新版(可通过 SECRETS_UPGRADE_URL 自托管
# ── export ────────────────────────────────────────────────────────────────────
secrets export --file backup.json # 全量导出到 JSON
@@ -174,6 +181,16 @@ secrets import backup.json # 导入(冲突时报
secrets import --force refining.toml # 冲突时覆盖已有记录
secrets import --dry-run backup.yaml # 预览将要执行的操作(不写入)
# ── run ───────────────────────────────────────────────────────────────────────
secrets run -n refining --kind service --name gitea -- ./deploy.sh # 注入全部 secrets
secrets run -n refining --kind service --name gitea -s token -- ./deploy.sh # 只注入 token 字段
secrets run -n refining --kind service --name aliyun \
-s access_key_id -s access_key_secret -- aliyun ecs DescribeInstances # 只注入指定字段
secrets run --tag production -- env # 按 tag 批量注入
secrets run -n refining --kind service --name gitea --dry-run # 预览变量映射
secrets run -n refining --kind service --name gitea -s token --dry-run # 过滤后预览
secrets run -n refining --kind service --name gitea --dry-run -o text # 人类可读预览
# ── 调试 ──────────────────────────────────────────────────────────────────────
secrets --verbose search -q mqtt
RUST_LOG=secrets=trace secrets search
@@ -181,7 +198,7 @@ RUST_LOG=secrets=trace secrets search
## 数据模型
主表 `entries`namespace、kind、name、tags、metadata+ 子表 `secrets`(每个加密字段一行,含 field_name、field_type、value_len、encrypted。首次连接自动建表同时创建 `audit_log``entries_history``secrets_history` 等表。
主表 `entries`namespace、kind、name、tags、metadata+ 子表 `secrets`(每个加密字段一行,含 field_name、encrypted。首次连接自动建表同时创建 `audit_log``entries_history``secrets_history` 等表。
| 位置 | 字段 | 说明 |
|------|------|------|
@@ -190,7 +207,7 @@ RUST_LOG=secrets=trace secrets search
| entries | name | 人类可读唯一标识 |
| entries | tags | 多维标签,如 `["aliyun","hongkong"]` |
| entries | metadata | 明文描述ip、desc、domains、key_ref 等) |
| secrets | field_name / field_type / value_len | 明文search 可见AI 可推断 inject 会生成什么变量 |
| secrets | field_name | 明文search 可见AI 可推断 run 会注入哪些变量 |
| secrets | encrypted | 仅加密值本身AES-256-GCM |
`-m` / `--meta` 写入 `metadata``-s` / `--secret` 写入 `secrets` 表的独立行。支持 `key=value``key=@file``key:=<json>`,也支持 `credentials:content@./key.pem` 这类嵌套字段文件写入;删除时支持 `--remove-secret credentials:content`。加解密使用主密钥(由 `secrets init` 设置)。
@@ -203,12 +220,12 @@ RUST_LOG=secrets=trace secrets search
| 目标值 | 写法示例 | 实际存入 |
|------|------|------|
| 普通字符串 | `-m url=https://gitea.refining.dev` | `"https://gitea.refining.dev"` |
| 普通字符串 | `-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:='["gitea.refining.dev","git.refining.dev"]'` | `["gitea.refining.dev","git.refining.dev"]` |
| 数组 | `-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}}}` |
@@ -223,10 +240,10 @@ RUST_LOG=secrets=trace secrets search
```bash
secrets add -n refining --kind service --name gitea \
-m url=https://gitea.refining.dev \
-m url=https://code.example.com \
-m port:=3000 \
-m enabled:=true \
-m domains:='["gitea.refining.dev","git.refining.dev"]' \
-m domains:='["code.example.com","git.example.com"]' \
-m tls:='{"enabled":true,"redirect_http":true}'
```
@@ -311,7 +328,7 @@ src/
delete.rs # 删除CASCADE 删除 secrets
update.rs # 增量更新tags/metadata + secrets 行级 UPSERT/DELETE
rollback.rs # rollback / history按 entry_version 恢复
run.rs # inject / run逐字段解密 + key_ref 引用解析
run.rs # run仅 secrets 逐字段解密 + key_ref 引用解析(不含 metadata
upgrade.rs # 从 Gitea Release 自更新
export_cmd.rs # export批量导出支持 JSON/TOML/YAML含解密明文
import_cmd.rs # import批量导入冲突检测dry-run重新加密写入

View File

@@ -161,28 +161,6 @@ pub(crate) fn remove_path(map: &mut Map<String, Value>, path: &[String]) -> Resu
Ok(removed)
}
// ── field_type inference and value_len ──────────────────────────────────────
/// Infer the field type string from a JSON value.
pub(crate) fn infer_field_type(v: &Value) -> &'static str {
match v {
Value::String(_) => "string",
Value::Number(_) => "number",
Value::Bool(_) => "boolean",
Value::Null => "string",
Value::Array(_) | Value::Object(_) => "json",
}
}
/// Compute the plaintext length of a JSON value (chars for string, serialized length otherwise).
pub(crate) fn compute_value_len(v: &Value) -> i32 {
match v {
Value::String(s) => s.chars().count() as i32,
Value::Null => 0,
other => other.to_string().chars().count() as i32,
}
}
/// Flatten a (potentially nested) JSON object into dot-separated field entries.
/// e.g. `{"credentials": {"type": "ssh", "content": "..."}}` →
/// `[("credentials.type", "ssh"), ("credentials.content", "...")]`
@@ -291,12 +269,10 @@ pub async fn run(pool: &PgPool, args: AddArgs<'_>, master_key: &[u8; 32]) -> Res
struct ExistingField {
id: uuid::Uuid,
field_name: String,
field_type: String,
value_len: i32,
encrypted: Vec<u8>,
}
let existing_fields: Vec<ExistingField> = sqlx::query_as(
"SELECT id, field_name, field_type, value_len, encrypted \
"SELECT id, field_name, encrypted \
FROM secrets WHERE entry_id = $1",
)
.bind(entry_id)
@@ -311,8 +287,6 @@ pub async fn run(pool: &PgPool, args: AddArgs<'_>, master_key: &[u8; 32]) -> Res
secret_id: f.id,
entry_version: new_entry_version - 1,
field_name: &f.field_name,
field_type: &f.field_type,
value_len: f.value_len,
encrypted: &f.encrypted,
action: "add",
},
@@ -333,18 +307,14 @@ pub async fn run(pool: &PgPool, args: AddArgs<'_>, master_key: &[u8; 32]) -> Res
// Insert new secret fields.
let flat_fields = flatten_json_fields("", &secret_json);
for (field_name, field_value) in &flat_fields {
let field_type = infer_field_type(field_value);
let value_len = compute_value_len(field_value);
let encrypted = crypto::encrypt_json(master_key, field_value)?;
sqlx::query(
"INSERT INTO secrets (entry_id, field_name, field_type, value_len, encrypted) \
VALUES ($1, $2, $3, $4, $5)",
"INSERT INTO secrets (entry_id, field_name, encrypted) \
VALUES ($1, $2, $3)",
)
.bind(entry_id)
.bind(field_name)
.bind(field_type)
.bind(value_len)
.bind(&encrypted)
.execute(&mut *tx)
.await?;
@@ -399,10 +369,7 @@ pub async fn run(pool: &PgPool, args: AddArgs<'_>, master_key: &[u8; 32]) -> Res
#[cfg(test)]
mod tests {
use super::{
build_json, compute_value_len, flatten_json_fields, infer_field_type, key_path_to_string,
parse_kv, remove_path,
};
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;
@@ -489,19 +456,4 @@ mod tests {
assert_eq!(fields[1].0, "credentials.type");
assert_eq!(fields[2].0, "username");
}
#[test]
fn infer_field_types() {
assert_eq!(infer_field_type(&Value::String("x".into())), "string");
assert_eq!(infer_field_type(&serde_json::json!(42)), "number");
assert_eq!(infer_field_type(&Value::Bool(true)), "boolean");
assert_eq!(infer_field_type(&serde_json::json!(["a"])), "json");
}
#[test]
fn compute_value_len_string() {
assert_eq!(compute_value_len(&Value::String("root".into())), 4);
assert_eq!(compute_value_len(&Value::Null), 0);
assert_eq!(compute_value_len(&serde_json::json!(1234)), 4);
}
}

View File

@@ -1,6 +1,7 @@
use anyhow::Result;
use serde_json::json;
use sqlx::PgPool;
use uuid::Uuid;
use crate::db;
use crate::models::{EntryRow, SecretFieldRow};
@@ -8,13 +9,50 @@ use crate::output::{OutputMode, print_json};
pub struct DeleteArgs<'a> {
pub namespace: &'a str,
pub kind: &'a str,
pub name: &'a str,
/// Kind filter. Required when --name is given; optional for bulk deletes.
pub kind: Option<&'a str>,
/// Exact record name. When None, bulk-delete all matching records.
pub name: Option<&'a str>,
/// Preview without writing to the database (bulk mode only).
pub dry_run: bool,
pub output: OutputMode,
}
// ── Internal row type used for bulk queries ────────────────────────────────
#[derive(Debug, sqlx::FromRow)]
struct FullEntryRow {
pub id: Uuid,
pub version: i64,
pub kind: String,
pub name: String,
pub metadata: serde_json::Value,
pub tags: Vec<String>,
}
// ── Entry point ────────────────────────────────────────────────────────────
pub async fn run(pool: &PgPool, args: DeleteArgs<'_>) -> Result<()> {
let (namespace, kind, name) = (args.namespace, args.kind, args.name);
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?;
@@ -34,16 +72,174 @@ pub async fn run(pool: &PgPool, args: DeleteArgs<'_>) -> Result<()> {
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 args.output {
match output {
OutputMode::Text => println!("Not found: [{}/{}] {}", namespace, kind, name),
ref mode => print_json(&v, mode)?,
}
return Ok(());
};
// Snapshot entry history before deleting.
snapshot_and_delete(&mut tx, namespace, kind, name, &row).await?;
crate::audit::log_tx(&mut tx, "delete", namespace, kind, name, json!({})).await;
tx.commit().await?;
let v = json!({"action":"deleted","namespace":namespace,"kind":kind,"name":name});
match output {
OutputMode::Text => println!("Deleted: [{}/{}] {}", namespace, kind, name),
ref mode => print_json(&v, mode)?,
}
Ok(())
}
// ── Bulk delete by namespace (+ optional kind filter) ─────────────────────
async fn delete_bulk(
pool: &PgPool,
namespace: &str,
kind: Option<&str>,
dry_run: bool,
output: OutputMode,
) -> Result<()> {
tracing::debug!(namespace, ?kind, dry_run, "bulk-deleting entries");
let rows: Vec<FullEntryRow> = if let Some(k) = kind {
sqlx::query_as(
"SELECT id, version, kind, name, metadata, tags FROM entries \
WHERE namespace = $1 AND kind = $2 \
ORDER BY name",
)
.bind(namespace)
.bind(k)
.fetch_all(pool)
.await?
} else {
sqlx::query_as(
"SELECT id, version, kind, name, metadata, tags FROM entries \
WHERE namespace = $1 \
ORDER BY kind, name",
)
.bind(namespace)
.fetch_all(pool)
.await?
};
if rows.is_empty() {
let v = json!({
"action": "noop",
"namespace": namespace,
"kind": kind,
"deleted": 0,
"dry_run": dry_run
});
match output {
OutputMode::Text => println!(
"No records found in namespace \"{}\"{}.",
namespace,
kind.map(|k| format!(" with kind \"{}\"", k))
.unwrap_or_default()
),
ref mode => print_json(&v, mode)?,
}
return Ok(());
}
if dry_run {
let count = rows.len();
match output {
OutputMode::Text => {
println!(
"dry-run: would delete {} record(s) in namespace \"{}\":",
count, namespace
);
for r in &rows {
println!(" [{}/{}] {}", namespace, r.kind, r.name);
}
}
ref mode => {
let items: Vec<_> = rows
.iter()
.map(|r| json!({"namespace": namespace, "kind": r.kind, "name": r.name}))
.collect();
print_json(
&json!({
"action": "dry_run",
"namespace": namespace,
"kind": kind,
"would_delete": count,
"entries": items
}),
mode,
)?;
}
}
return Ok(());
}
let mut deleted = Vec::with_capacity(rows.len());
for row in &rows {
let entry_row = EntryRow {
id: row.id,
version: row.version,
tags: row.tags.clone(),
metadata: row.metadata.clone(),
};
let mut tx = pool.begin().await?;
snapshot_and_delete(&mut tx, namespace, &row.kind, &row.name, &entry_row).await?;
crate::audit::log_tx(
&mut tx,
"delete",
namespace,
&row.kind,
&row.name,
json!({"bulk": true}),
)
.await;
tx.commit().await?;
deleted.push(json!({"namespace": namespace, "kind": row.kind, "name": row.name}));
tracing::info!(namespace, kind = %row.kind, name = %row.name, "bulk deleted");
}
let count = deleted.len();
match output {
OutputMode::Text => {
for item in &deleted {
println!(
"Deleted: [{}/{}] {}",
item["namespace"].as_str().unwrap_or(""),
item["kind"].as_str().unwrap_or(""),
item["name"].as_str().unwrap_or("")
);
}
println!("Total: {} record(s) deleted.", count);
}
ref mode => print_json(
&json!({
"action": "deleted",
"namespace": namespace,
"kind": kind,
"deleted": count,
"entries": deleted
}),
mode,
)?,
}
Ok(())
}
// ── Shared helper: snapshot history then DELETE ────────────────────────────
async fn snapshot_and_delete(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
namespace: &str,
kind: &str,
name: &str,
row: &EntryRow,
) -> Result<()> {
if let Err(e) = db::snapshot_entry_history(
&mut tx,
tx,
db::EntrySnapshotParams {
entry_id: row.id,
namespace,
@@ -60,50 +256,36 @@ pub async fn run(pool: &PgPool, args: DeleteArgs<'_>) -> Result<()> {
tracing::warn!(error = %e, "failed to snapshot entry history before delete");
}
// Snapshot all secret fields before cascade delete.
let fields: Vec<SecretFieldRow> = sqlx::query_as(
"SELECT id, field_name, field_type, value_len, encrypted \
"SELECT id, field_name, encrypted \
FROM secrets WHERE entry_id = $1",
)
.bind(row.id)
.fetch_all(&mut *tx)
.fetch_all(&mut **tx)
.await?;
for f in &fields {
if let Err(e) = db::snapshot_secret_history(
&mut tx,
tx,
db::SecretSnapshotParams {
entry_id: row.id,
secret_id: f.id,
entry_version: row.version,
field_name: &f.field_name,
field_type: &f.field_type,
value_len: f.value_len,
encrypted: &f.encrypted,
action: "delete",
},
)
.await
{
tracing::warn!(error = %e, "failed to snapshot secret field history before delete");
tracing::warn!(error = %e, "failed to snapshot secret history before delete");
}
}
// Delete the entry — secrets rows are removed via ON DELETE CASCADE.
sqlx::query("DELETE FROM entries WHERE id = $1")
.bind(row.id)
.execute(&mut *tx)
.execute(&mut **tx)
.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 args.output {
OutputMode::Text => println!("Deleted: [{}/{}] {}", namespace, kind, name),
ref mode => print_json(&v, mode)?,
}
Ok(())
}

View File

@@ -71,14 +71,12 @@ pub async fn run(pool: &PgPool, args: RollbackArgs<'_>, master_key: &[u8; 32]) -
struct SecretHistoryRow {
secret_id: Uuid,
field_name: String,
field_type: String,
value_len: i32,
encrypted: Vec<u8>,
action: String,
}
let field_snaps: Vec<SecretHistoryRow> = sqlx::query_as(
"SELECT secret_id, field_name, field_type, value_len, encrypted, action \
"SELECT secret_id, field_name, encrypted, action \
FROM secrets_history \
WHERE entry_id = $1 AND entry_version = $2 \
ORDER BY field_name",
@@ -145,12 +143,10 @@ pub async fn run(pool: &PgPool, args: RollbackArgs<'_>, master_key: &[u8; 32]) -
struct LiveField {
id: Uuid,
field_name: String,
field_type: String,
value_len: i32,
encrypted: Vec<u8>,
}
let live_fields: Vec<LiveField> = sqlx::query_as(
"SELECT id, field_name, field_type, value_len, encrypted \
"SELECT id, field_name, encrypted \
FROM secrets WHERE entry_id = $1",
)
.bind(lr.id)
@@ -165,8 +161,6 @@ pub async fn run(pool: &PgPool, args: RollbackArgs<'_>, master_key: &[u8; 32]) -
secret_id: f.id,
entry_version: lr.version,
field_name: &f.field_name,
field_type: &f.field_type,
value_len: f.value_len,
encrypted: &f.encrypted,
action: "rollback",
},
@@ -212,11 +206,9 @@ pub async fn run(pool: &PgPool, args: RollbackArgs<'_>, master_key: &[u8; 32]) -
continue;
}
sqlx::query(
"INSERT INTO secrets (id, entry_id, field_name, field_type, value_len, encrypted) \
VALUES ($1, $2, $3, $4, $5, $6) \
"INSERT INTO secrets (id, entry_id, field_name, encrypted) \
VALUES ($1, $2, $3, $4) \
ON CONFLICT (entry_id, field_name) DO UPDATE SET \
field_type = EXCLUDED.field_type, \
value_len = EXCLUDED.value_len, \
encrypted = EXCLUDED.encrypted, \
version = secrets.version + 1, \
updated_at = NOW()",
@@ -224,8 +216,6 @@ pub async fn run(pool: &PgPool, args: RollbackArgs<'_>, master_key: &[u8; 32]) -
.bind(f.secret_id)
.bind(snap.entry_id)
.bind(&f.field_name)
.bind(&f.field_type)
.bind(f.value_len)
.bind(&f.encrypted)
.execute(&mut *tx)
.await?;

View File

@@ -1,45 +1,57 @@
use anyhow::Result;
use serde_json::Value;
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 InjectArgs<'a> {
pub namespace: Option<&'a str>,
pub kind: Option<&'a str>,
pub name: Option<&'a str>,
pub tags: &'a [String],
pub prefix: &'a str,
pub 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],
}
/// Fetch entries matching the filter and build a flat env map (metadata + decrypted secrets).
pub async fn collect_env_map(
/// 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,
namespace: Option<&str>,
kind: Option<&str>,
name: Option<&str>,
tags: &[String],
prefix: &str,
args: &CollectArgs<'_>,
master_key: &[u8; 32],
) -> Result<HashMap<String, String>> {
if namespace.is_none() && kind.is_none() && name.is_none() && tags.is_empty() {
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 inject/run"
"At least one filter (--namespace, --kind, --name, or --tag) is required for run"
);
}
let entries = fetch_entries(pool, namespace, kind, name, tags, None).await?;
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.");
}
@@ -50,8 +62,17 @@ pub async fn collect_env_map(
let mut map = HashMap::new();
for entry in &entries {
let empty = vec![];
let fields = fields_map.get(&entry.id).unwrap_or(&empty);
let row_map = build_injected_env_map(pool, entry, prefix, master_key, fields).await?;
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);
}
@@ -59,64 +80,152 @@ pub async fn collect_env_map(
Ok(map)
}
/// `inject` command: print env vars to stdout.
pub async fn run_inject(pool: &PgPool, args: InjectArgs<'_>, master_key: &[u8; 32]) -> Result<()> {
let env_map = collect_env_map(
pool,
args.namespace,
args.kind,
args.name,
args.tags,
args.prefix,
master_key,
)
.await?;
match args.output {
OutputMode::Json => {
let obj: serde_json::Map<String, Value> = env_map
.into_iter()
.map(|(k, v)| (k, Value::String(v)))
.collect();
println!("{}", serde_json::to_string_pretty(&Value::Object(obj))?);
}
OutputMode::JsonCompact => {
let obj: serde_json::Map<String, Value> = env_map
.into_iter()
.map(|(k, v)| (k, Value::String(v)))
.collect();
println!("{}", serde_json::to_string(&Value::Object(obj))?);
}
_ => {
let mut pairs: Vec<(String, String)> = env_map.into_iter().collect();
pairs.sort_by(|a, b| a.0.cmp(&b.0));
for (k, v) in pairs {
println!("{}={}", k, shell_quote(&v));
}
}
/// 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.");
}
Ok(())
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.command.is_empty() {
if !args.dry_run && args.command.is_empty() {
anyhow::bail!(
"No command specified. Usage: secrets run [filter flags] -- <command> [args]"
);
}
let env_map = collect_env_map(
pool,
args.namespace,
args.kind,
args.name,
args.tags,
args.prefix,
master_key,
)
.await?;
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(),
@@ -137,7 +246,3 @@ pub async fn run_exec(pool: &PgPool, args: RunArgs<'_>, master_key: &[u8; 32]) -
Ok(())
}
fn shell_quote(s: &str) -> String {
format!("'{}'", s.replace('\'', "'\\''"))
}

View File

@@ -94,7 +94,7 @@ pub async fn run(pool: &PgPool, args: SearchArgs<'_>) -> Result<()> {
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 inject` or `secrets run` for secrets.",
"Field '{}' is sensitive. `search -f` only supports metadata.* fields; use `secrets run` for secrets.",
field
);
}
@@ -121,11 +121,11 @@ struct PagedFetchArgs<'a> {
offset: u32,
}
/// A very large limit used when callers need all matching records (export, inject, run).
/// 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, inject, run).
/// 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,
@@ -250,8 +250,8 @@ async fn fetch_entries_paged(pool: &PgPool, a: PagedFetchArgs<'_>) -> Result<Vec
// ── Secret schema fetching (no master key) ───────────────────────────────────
/// Fetch secret field schemas (field_name, field_type, value_len) for a set of entry ids.
/// Returns a map from entry_id to list of SecretField (encrypted field not used here).
/// 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],
@@ -312,35 +312,17 @@ fn env_prefix(entry: &Entry, prefix: &str) -> String {
}
}
/// Build a flat KEY=VALUE map from metadata only (no master key required).
pub fn build_metadata_env_map(entry: &Entry, prefix: &str) -> HashMap<String, String> {
let effective_prefix = env_prefix(entry, prefix);
let mut map = HashMap::new();
if let Some(meta) = entry.metadata.as_object() {
for (k, v) in meta {
let key = format!(
"{}_{}",
effective_prefix,
k.to_uppercase().replace(['-', '.'], "_")
);
map.insert(key, json_value_to_env_string(v));
}
}
map
}
/// Build a flat KEY=VALUE map from metadata + decrypted secret fields.
/// 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],
fields: &[&SecretField],
) -> Result<HashMap<String, String>> {
let effective_prefix = env_prefix(entry, prefix);
let mut map = build_metadata_env_map(entry, prefix);
let mut map = HashMap::new();
// Decrypt each secret field and add to env map.
for f in fields {
@@ -423,8 +405,6 @@ fn to_json(entry: &Entry, summary: bool, schema: Option<&[SecretField]>) -> Valu
.map(|f| {
json!({
"field_name": f.field_name,
"field_type": f.field_type,
"value_len": f.value_len,
})
})
.collect();
@@ -474,12 +454,9 @@ fn print_text(entry: &Entry, summary: bool, schema: Option<&[SecretField]>) -> R
}
match schema {
Some(fields) if !fields.is_empty() => {
let schema_str: Vec<String> = fields
.iter()
.map(|f| format!("{}: {}({})", f.field_name, f.field_type, f.value_len))
.collect();
let schema_str: Vec<String> = fields.iter().map(|f| f.field_name.clone()).collect();
println!(" secrets: {}", schema_str.join(", "));
println!(" (use `secrets inject` or `secrets run` to get values)");
println!(" (use `secrets run` to get values)");
}
_ => {}
}
@@ -542,7 +519,7 @@ mod tests {
kind: "service".to_string(),
name: "gitea.main".to_string(),
tags: vec!["prod".to_string()],
metadata: json!({"url": "https://gitea.refining.dev", "enabled": true}),
metadata: json!({"url": "https://code.example.com", "enabled": true}),
version: 1,
created_at: Utc::now(),
updated_at: Utc::now(),
@@ -556,8 +533,6 @@ mod tests {
id: Uuid::nil(),
entry_id: Uuid::nil(),
field_name: "token".to_string(),
field_type: "string".to_string(),
value_len: 6,
encrypted: enc,
version: 1,
created_at: Utc::now(),
@@ -572,22 +547,6 @@ mod tests {
assert!(err.to_string().contains("sensitive"));
}
#[test]
fn metadata_env_map_excludes_secret_values() {
let entry = sample_entry();
let map = build_metadata_env_map(&entry, "");
assert_eq!(
map.get("GITEA_MAIN_URL").map(String::as_str),
Some("https://gitea.refining.dev")
);
assert_eq!(
map.get("GITEA_MAIN_ENABLED").map(String::as_str),
Some("true")
);
assert!(!map.contains_key("GITEA_MAIN_TOKEN"));
}
#[test]
fn to_json_full_includes_secrets_schema() {
let entry = sample_entry();
@@ -597,8 +556,6 @@ mod tests {
let secrets = v.get("secrets").unwrap().as_array().unwrap();
assert_eq!(secrets.len(), 1);
assert_eq!(secrets[0]["field_name"], "token");
assert_eq!(secrets[0]["field_type"], "string");
assert_eq!(secrets[0]["value_len"], 6);
}
#[test]

View File

@@ -4,8 +4,8 @@ use sqlx::PgPool;
use uuid::Uuid;
use super::add::{
collect_field_paths, collect_key_paths, compute_value_len, flatten_json_fields,
infer_field_type, insert_path, parse_key_path, parse_kv, remove_path,
collect_field_paths, collect_key_paths, flatten_json_fields, insert_path, parse_key_path,
parse_kv, remove_path,
};
use crate::crypto;
use crate::db;
@@ -130,20 +130,16 @@ pub async fn run(pool: &PgPool, args: UpdateArgs<'_>, master_key: &[u8; 32]) ->
});
for (field_name, fv) in &flat {
let field_type = infer_field_type(fv);
let value_len = compute_value_len(fv);
let encrypted = crypto::encrypt_json(master_key, fv)?;
// Snapshot existing field before replacing.
#[derive(sqlx::FromRow)]
struct ExistingField {
id: Uuid,
field_type: String,
value_len: i32,
encrypted: Vec<u8>,
}
let existing_field: Option<ExistingField> = sqlx::query_as(
"SELECT id, field_type, value_len, encrypted \
"SELECT id, encrypted \
FROM secrets WHERE entry_id = $1 AND field_name = $2",
)
.bind(row.id)
@@ -159,8 +155,6 @@ pub async fn run(pool: &PgPool, args: UpdateArgs<'_>, master_key: &[u8; 32]) ->
secret_id: ef.id,
entry_version: row.version,
field_name,
field_type: &ef.field_type,
value_len: ef.value_len,
encrypted: &ef.encrypted,
action: "update",
},
@@ -171,19 +165,15 @@ pub async fn run(pool: &PgPool, args: UpdateArgs<'_>, master_key: &[u8; 32]) ->
}
sqlx::query(
"INSERT INTO secrets (entry_id, field_name, field_type, value_len, encrypted) \
VALUES ($1, $2, $3, $4, $5) \
"INSERT INTO secrets (entry_id, field_name, encrypted) \
VALUES ($1, $2, $3) \
ON CONFLICT (entry_id, field_name) DO UPDATE SET \
field_type = EXCLUDED.field_type, \
value_len = EXCLUDED.value_len, \
encrypted = EXCLUDED.encrypted, \
version = secrets.version + 1, \
updated_at = NOW()",
)
.bind(row.id)
.bind(field_name)
.bind(field_type)
.bind(value_len)
.bind(&encrypted)
.execute(&mut *tx)
.await?;
@@ -200,12 +190,10 @@ pub async fn run(pool: &PgPool, args: UpdateArgs<'_>, master_key: &[u8; 32]) ->
#[derive(sqlx::FromRow)]
struct FieldToDelete {
id: Uuid,
field_type: String,
value_len: i32,
encrypted: Vec<u8>,
}
let field: Option<FieldToDelete> = sqlx::query_as(
"SELECT id, field_type, value_len, encrypted \
"SELECT id, encrypted \
FROM secrets WHERE entry_id = $1 AND field_name = $2",
)
.bind(row.id)
@@ -221,8 +209,6 @@ pub async fn run(pool: &PgPool, args: UpdateArgs<'_>, master_key: &[u8; 32]) ->
secret_id: f.id,
entry_version: new_version,
field_name: &field_name,
field_type: &f.field_type,
value_len: f.value_len,
encrypted: &f.encrypted,
action: "delete",
},

View File

@@ -5,10 +5,26 @@ use sha2::{Digest, Sha256};
use std::io::{Cursor, Read, Write};
use std::time::Duration;
const GITEA_API: &str = "https://gitea.refining.dev/api/v1/repos/refining/secrets/releases/latest";
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,
@@ -186,13 +202,14 @@ pub async fn run(check_only: bool) -> Result<()> {
.build()
.context("failed to build HTTP client")?;
let api_url = upgrade_api_url()?;
let release: Release = client
.get(GITEA_API)
.get(&api_url)
.send()
.await
.context("failed to fetch release info from Gitea")?
.context("failed to fetch release info")?
.error_for_status()
.context("Gitea API returned an error")?
.context("release API returned an error")?
.json()
.await
.context("failed to parse release JSON")?;

View File

@@ -44,8 +44,6 @@ pub async fn migrate(pool: &PgPool) -> Result<()> {
id UUID PRIMARY KEY DEFAULT uuidv7(),
entry_id UUID NOT NULL REFERENCES entries(id) ON DELETE CASCADE,
field_name VARCHAR(256) NOT NULL,
field_type VARCHAR(32) NOT NULL DEFAULT 'string',
value_len INT NOT NULL DEFAULT 0,
encrypted BYTEA NOT NULL DEFAULT '\x',
version BIGINT NOT NULL DEFAULT 1,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
@@ -103,8 +101,6 @@ pub async fn migrate(pool: &PgPool) -> Result<()> {
secret_id UUID NOT NULL,
entry_version BIGINT NOT NULL,
field_name VARCHAR(256) NOT NULL,
field_type VARCHAR(32) NOT NULL DEFAULT 'string',
value_len INT NOT NULL DEFAULT 0,
encrypted BYTEA NOT NULL DEFAULT '\x',
action VARCHAR(16) NOT NULL,
actor VARCHAR(128) NOT NULL DEFAULT '',
@@ -168,8 +164,6 @@ pub struct SecretSnapshotParams<'a> {
pub secret_id: uuid::Uuid,
pub entry_version: i64,
pub field_name: &'a str,
pub field_type: &'a str,
pub value_len: i32,
pub encrypted: &'a [u8],
pub action: &'a str,
}
@@ -182,15 +176,13 @@ pub async fn snapshot_secret_history(
let actor = current_actor();
sqlx::query(
"INSERT INTO secrets_history \
(entry_id, secret_id, entry_version, field_name, field_type, value_len, encrypted, action, actor) \
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)",
(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.field_type)
.bind(p.value_len)
.bind(p.encrypted)
.bind(p.action)
.bind(&actor)

View File

@@ -7,6 +7,11 @@ mod models;
mod output;
use anyhow::Result;
/// Load .env from current or parent directories (best-effort, no error if missing).
fn load_dotenv() {
let _ = dotenvy::dotenv();
}
use clap::{Parser, Subcommand};
use tracing_subscriber::EnvFilter;
@@ -36,8 +41,8 @@ use output::resolve_output_mode;
# Pipe-friendly (non-TTY defaults to json-compact automatically)
secrets search -n refining --kind service | jq '.[].name'
# Inject secrets into environment variables when you really need them
secrets inject -n refining --kind service --name gitea"
# Run a command with secrets injected into its child process environment
secrets run -n refining --kind service --name gitea -- printenv"
)]
struct Cli {
/// Database URL, overrides saved config (one-time override)
@@ -76,25 +81,25 @@ EXAMPLES:
# Add a server
secrets add -n refining --kind server --name my-server \\
--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
# Add a service credential
secrets add -n refining --kind service --name 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>
# Add typed JSON metadata
secrets add -n refining --kind service --name gitea \\
-m port:=3000 \\
-m enabled:=true \\
-m domains:='[\"gitea.refining.dev\",\"git.refining.dev\"]' \\
-m domains:='[\"code.example.com\",\"git.example.com\"]' \\
-m tls:='{\"enabled\":true,\"redirect_http\":true}'
# Add with token read from a file
secrets add -n ricnsmart --kind service --name mqtt \\
-m host=mqtt.ricnsmart.com -m port=1883 \\
-m host=mqtt.example.com -m port=1883 \\
-s password=@./mqtt_password.txt
# Add typed JSON secrets
@@ -106,7 +111,13 @@ EXAMPLES:
# Write a multiline file into a nested secret field
secrets add -n refining --kind server --name my-server \\
-s credentials:content@./keys/server.pem")]
-s credentials:content@./keys/server.pem
# Shared PEM (key_ref): store key once, reference from multiple servers
secrets add -n refining --kind key --name my-shared-key \\
--tag aliyun -s content=@./keys/shared.pem
secrets add -n refining --kind server --name i-abc123 \\
-m ip=10.0.0.1 -m key_ref=my-shared-key -s username=ecs-user")]
Add {
/// Namespace, e.g. refining, ricnsmart
#[arg(short, long)]
@@ -114,13 +125,14 @@ EXAMPLES:
/// Kind of record: server, service, key, ...
#[arg(long)]
kind: String,
/// Human-readable unique name, e.g. gitea, i-uf63f2uookgs5uxmrdyc
/// Human-readable unique name, e.g. gitea, i-example0abcd1234efgh
#[arg(long)]
name: String,
/// Tag for categorization (repeatable), e.g. --tag aliyun --tag hongkong
#[arg(long = "tag")]
tags: Vec<String>,
/// Plaintext metadata: key=value, key:=<json>, key=@file, or nested:path@file
/// Plaintext metadata: key=value, key:=<json>, key=@file, or nested:path@file.
/// Use key_ref=<name> to reference a shared key entry (kind=key); run merges its secrets.
#[arg(long = "meta", short = 'm')]
meta: Vec<String>,
/// Secret entry: key=value, key:=<json>, key=@file, or nested:path@file
@@ -157,8 +169,7 @@ EXAMPLES:
secrets search -n refining --kind service --name gitea \\
-f metadata.url -f metadata.default_org
# Inject decrypted secrets only when needed
secrets inject -n refining --kind service --name gitea
# Run a command with decrypted secrets only when needed
secrets run -n refining --kind service --name gitea -- printenv
# Paginate large result sets
@@ -177,7 +188,7 @@ EXAMPLES:
/// Filter by kind, e.g. server, service
#[arg(long)]
kind: Option<String>,
/// Exact name filter, e.g. gitea, i-uf63f2uookgs5uxmrdyc
/// Exact name filter, e.g. gitea, i-example0abcd1234efgh
#[arg(long)]
name: Option<String>,
/// Filter by tag, e.g. --tag aliyun (repeatable for AND intersection)
@@ -206,23 +217,39 @@ EXAMPLES:
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:
# Delete a service credential
# Delete a single record (exact match)
secrets delete -n refining --kind service --name legacy-mqtt
# Delete a server record
secrets delete -n ricnsmart --kind server --name i-old-server-id")]
# Preview what a bulk delete would remove (no writes)
secrets delete -n refining --dry-run
# Bulk-delete all records in a namespace
secrets delete -n ricnsmart
# Bulk-delete only server records in a namespace
secrets delete -n ricnsmart --kind server
# JSON output
secrets delete -n refining --kind service -o json")]
Delete {
/// Namespace, e.g. refining
#[arg(short, long)]
namespace: String,
/// Kind, e.g. server, service
/// Kind filter, e.g. server, service (required with --name; optional for bulk)
#[arg(long)]
kind: String,
/// Exact name of the record to delete
kind: Option<String>,
/// Exact name of the record to delete (omit for bulk delete)
#[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>,
@@ -266,7 +293,11 @@ EXAMPLES:
# 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")]
-s auth:retry:=5
# Rotate shared PEM (all servers with key_ref=my-shared-key get the new key)
secrets update -n refining --kind key --name my-shared-key \\
-s content=@./keys/new-shared.pem")]
Update {
/// Namespace, e.g. refining, ricnsmart
#[arg(short, long)]
@@ -283,7 +314,8 @@ EXAMPLES:
/// Remove a tag (repeatable)
#[arg(long = "remove-tag")]
remove_tags: Vec<String>,
/// Set or overwrite a metadata field: key=value, key:=<json>, key=@file, or nested:path@file
/// Set or overwrite a metadata field: key=value, key:=<json>, key=@file, or nested:path@file.
/// Use key_ref=<name> to reference a shared key entry (kind=key).
#[arg(long = "meta", short = 'm')]
meta: Vec<String>,
/// Delete a metadata field by key or nested path, e.g. old_port or credentials:content
@@ -359,51 +391,34 @@ EXAMPLES:
output: Option<String>,
},
/// Print secrets as environment variables (stdout only, nothing persisted).
///
/// Outputs KEY=VALUE pairs for all matched records. Safe to pipe or eval.
#[command(after_help = "EXAMPLES:
# Print env vars for a single service
secrets inject -n refining --kind service --name gitea
# With a custom prefix
secrets inject -n refining --kind service --name gitea --prefix GITEA
# JSON output (all vars as a JSON object)
secrets inject -n refining --kind service --name gitea -o json
# Eval into current shell (use with caution)
eval $(secrets inject -n refining --kind service --name gitea)")]
Inject {
#[arg(short, long)]
namespace: Option<String>,
#[arg(long)]
kind: Option<String>,
#[arg(long)]
name: Option<String>,
#[arg(long)]
tag: Vec<String>,
/// Prefix to prepend to every variable name (uppercased automatically)
#[arg(long, default_value = "")]
prefix: String,
/// Output format: text/KEY=VALUE (default), 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")]
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>,
@@ -413,18 +428,27 @@ EXAMPLES:
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, required = true)]
#[arg(last = true)]
command: Vec<String>,
},
/// Check for a newer version and update the binary in-place.
///
/// Downloads the latest release from Gitea and replaces the current binary.
/// No database connection or master key required.
/// 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
@@ -530,6 +554,7 @@ enum ConfigAction {
#[tokio::main]
async fn main() -> Result<()> {
load_dotenv();
let cli = Cli::parse();
let filter = if cli.verbose {
@@ -634,17 +659,19 @@ async fn main() -> Result<()> {
namespace,
kind,
name,
dry_run,
output,
} => {
let _span =
tracing::info_span!("cmd", command = "delete", %namespace, %kind, %name).entered();
tracing::info_span!("cmd", command = "delete", %namespace, ?kind, ?name).entered();
let out = resolve_output_mode(output.as_deref())?;
commands::delete::run(
&pool,
commands::delete::DeleteArgs {
namespace: &namespace,
kind: &kind,
name: &name,
kind: kind.as_deref(),
name: name.as_deref(),
dry_run,
output: out,
},
)
@@ -730,40 +757,24 @@ async fn main() -> Result<()> {
.await?;
}
Commands::Inject {
namespace,
kind,
name,
tag,
prefix,
output,
} => {
let master_key = crypto::load_master_key()?;
let out = resolve_output_mode(output.as_deref())?;
commands::run::run_inject(
&pool,
commands::run::InjectArgs {
namespace: namespace.as_deref(),
kind: kind.as_deref(),
name: name.as_deref(),
tags: &tag,
prefix: &prefix,
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 {
@@ -771,7 +782,10 @@ async fn main() -> Result<()> {
kind: kind.as_deref(),
name: name.as_deref(),
tags: &tag,
secret_fields: &secret_fields,
prefix: &prefix,
dry_run,
output: out,
command: &command,
},
&master_key,

View File

@@ -20,17 +20,11 @@ pub struct Entry {
}
/// A single encrypted field belonging to an Entry.
/// field_name, field_type, and value_len are stored in plaintext so that
/// `search` can show the schema without requiring the master key.
#[derive(Debug, Serialize, Deserialize, sqlx::FromRow)]
pub struct SecretField {
pub id: Uuid,
pub entry_id: Uuid,
pub field_name: String,
/// Inferred type: "string", "number", "boolean", "json"
pub field_type: String,
/// Length of the plaintext value in characters (0 for binary-like PEM)
pub value_len: i32,
/// AES-256-GCM ciphertext: nonce(12B) || ciphertext+tag
pub encrypted: Vec<u8>,
pub version: i64,
@@ -54,8 +48,6 @@ pub struct EntryRow {
pub struct SecretFieldRow {
pub id: Uuid,
pub field_name: String,
pub field_type: String,
pub value_len: i32,
pub encrypted: Vec<u8>,
}

View File

@@ -1,5 +1,4 @@
use chrono::{DateTime, Local, Utc};
use std::io::IsTerminal;
use std::str::FromStr;
/// Output format for all commands.
@@ -32,16 +31,12 @@ impl FromStr for OutputMode {
/// Resolve the effective output mode.
/// - 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> {
if let Some(s) = explicit {
return s.parse();
}
if std::io::stdout().is_terminal() {
Ok(OutputMode::Text)
} else {
Ok(OutputMode::JsonCompact)
}
Ok(OutputMode::Json)
}
/// Format a UTC timestamp for local human-readable output.

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