Compare commits
3 Commits
secrets-0.
...
secrets-0.
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a765dcc428 | ||
|
|
31b0ea9bf1 | ||
|
|
dc0534cbc9 |
181
AGENTS.md
181
AGENTS.md
@@ -1,6 +1,6 @@
|
||||
# Secrets CLI — AGENTS.md
|
||||
|
||||
跨设备密钥与配置管理 CLI 工具,将 refining / ricnsmart 两个项目的服务器信息、服务凭据存储到 PostgreSQL 18,供 AI 工具读取上下文。
|
||||
跨设备密钥与配置管理 CLI 工具,将 refining / ricnsmart 两个项目的服务器信息、服务凭据存储到 PostgreSQL 18,供 AI 工具读取上下文。敏感数据(encrypted 字段)使用 AES-256-GCM 加密,主密钥由 Argon2id 从主密码派生并存入平台安全存储(macOS Keychain / Windows Credential Manager / Linux keyutils)。
|
||||
|
||||
## 项目结构
|
||||
|
||||
@@ -10,17 +10,21 @@ secrets/
|
||||
main.rs # CLI 入口,clap 命令定义,auto-migrate,--verbose 全局参数
|
||||
output.rs # OutputMode 枚举 + TTY 检测(TTY→text,非 TTY→json-compact)
|
||||
config.rs # 配置读写:~/.config/secrets/config.toml(database_url)
|
||||
db.rs # PgPool 创建 + 建表/索引(幂等,含 audit_log)
|
||||
models.rs # Secret 结构体(sqlx::FromRow + serde)
|
||||
audit.rs # 审计写入:向 audit_log 表记录所有写操作
|
||||
db.rs # PgPool 创建 + 建表/索引(幂等,含 audit_log + kv_config + secrets_history)
|
||||
crypto.rs # AES-256-GCM 加解密、Argon2id 派生、OS 钥匙串
|
||||
models.rs # Secret 结构体(sqlx::FromRow + serde,含 version 字段)
|
||||
audit.rs # 审计写入:log_tx(事务内)/ log(池,保留备用)
|
||||
commands/
|
||||
add.rs # add 命令:upsert,支持 --meta key=value / --secret key=@file / -o json
|
||||
init.rs # init 命令:主密钥初始化(每台设备一次)
|
||||
add.rs # add 命令:upsert,事务化,含历史快照,支持 key:=json 类型化值
|
||||
config.rs # config 命令:set-db / show / path(持久化 database_url)
|
||||
search.rs # search 命令:多条件查询,-f/-o/--summary/--limit/--offset/--sort
|
||||
delete.rs # delete 命令
|
||||
update.rs # update 命令:增量更新(合并 tags/metadata/encrypted)
|
||||
search.rs # search 命令:多条件查询,公开 fetch_rows / build_env_map
|
||||
delete.rs # delete 命令:事务化,含历史快照
|
||||
update.rs # update 命令:增量更新,CAS 并发保护,含历史快照
|
||||
rollback.rs # rollback / history 命令:版本回滚与历史查看
|
||||
run.rs # inject / run 命令:临时环境变量注入
|
||||
scripts/
|
||||
seed-data.sh # 从 refining/ricnsmart config.toml 导入全量数据
|
||||
setup-gitea-actions.sh # 配置 Gitea Actions 变量与 Secrets
|
||||
.gitea/workflows/
|
||||
secrets.yml # CI:fmt + clippy + musl 构建 + Release 上传 + 飞书通知
|
||||
.vscode/tasks.json # 本地测试任务(build / config / search / add+delete / update / audit 等)
|
||||
@@ -31,7 +35,7 @@ secrets/
|
||||
- **Host**: `<host>:<port>`
|
||||
- **Database**: `secrets`
|
||||
- **连接串**: `postgres://postgres:<password>@<host>:<port>/secrets`
|
||||
- **表**: `secrets`(主表)+ `audit_log`(审计表),首次连接自动建表(auto-migrate)
|
||||
- **表**: `secrets`(主表)+ `audit_log`(审计表)+ `kv_config`(Argon2 salt 等),首次连接自动建表(auto-migrate)
|
||||
|
||||
### 表结构
|
||||
|
||||
@@ -43,13 +47,38 @@ secrets (
|
||||
name VARCHAR(256) NOT NULL, -- 人类可读标识
|
||||
tags TEXT[] NOT NULL DEFAULT '{}', -- 灵活标签: ["aliyun","hongkong"]
|
||||
metadata JSONB NOT NULL DEFAULT '{}', -- 明文描述: ip, desc, domains, location...
|
||||
encrypted JSONB NOT NULL DEFAULT '{}', -- 敏感数据: ssh_key, password, token...
|
||||
encrypted BYTEA NOT NULL DEFAULT '\x', -- AES-256-GCM 密文: 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(namespace, kind, name)
|
||||
)
|
||||
```
|
||||
|
||||
```sql
|
||||
secrets_history (
|
||||
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
secret_id UUID NOT NULL, -- 对应 secrets.id
|
||||
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 '{}',
|
||||
encrypted BYTEA NOT NULL DEFAULT '\x', -- 快照时的加密密文
|
||||
actor VARCHAR(128) NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
)
|
||||
```
|
||||
|
||||
```sql
|
||||
kv_config (
|
||||
key TEXT PRIMARY KEY, -- 如 'argon2_salt'
|
||||
value BYTEA NOT NULL -- Argon2id salt,首台设备 init 时生成
|
||||
)
|
||||
```
|
||||
|
||||
### audit_log 表结构
|
||||
|
||||
```sql
|
||||
@@ -74,7 +103,7 @@ audit_log (
|
||||
| `name` | 唯一标识名 | `i-uf63f2uookgs5uxmrdyc`, `gitea` |
|
||||
| `tags` | 多维分类标签 | `["aliyun","hongkong","ricn"]` |
|
||||
| `metadata` | 明文非敏感信息 | `{"ip":"47.243.154.187","desc":"Grafana","domains":["..."]}` |
|
||||
| `encrypted` | 敏感凭据(MVP 阶段明文存储,后续对 value 加密) | `{"ssh_key":"-----BEGIN...","password":"..."}` |
|
||||
| `encrypted` | 敏感凭据,AES-256-GCM 加密存储 | 二进制密文,解密后为 `{"ssh_key":"...","password":"..."}` |
|
||||
|
||||
## 数据库配置
|
||||
|
||||
@@ -88,6 +117,21 @@ secrets config path # 打印配置文件路径
|
||||
|
||||
配置文件:`~/.config/secrets/config.toml`,权限 0600。`--db-url` 参数可一次性覆盖。
|
||||
|
||||
## 主密钥与加密
|
||||
|
||||
首次使用(每台设备各执行一次):
|
||||
|
||||
```bash
|
||||
secrets config set-db "postgres://postgres:<password>@<host>:<port>/secrets"
|
||||
secrets init # 提示输入主密码,Argon2id 派生主密钥后存入 OS 钥匙串
|
||||
```
|
||||
|
||||
主密码不存储;salt 存于 `kv_config`,首台设备生成后共享,确保同一主密码在所有设备派生出相同主密钥。
|
||||
|
||||
主密钥存储后端:macOS Keychain、Windows Credential Manager、Linux keyutils(会话级,重启后需再次 `secrets init`)。
|
||||
|
||||
**从旧版(明文 JSONB)升级**:升级后执行 `secrets init` 即可(明文记录需手动重新 add 或通过 update 更新)。
|
||||
|
||||
## CLI 命令
|
||||
|
||||
### AI 使用主路径
|
||||
@@ -102,6 +146,16 @@ secrets config path # 打印配置文件路径
|
||||
|
||||
---
|
||||
|
||||
### init — 主密钥初始化(每台设备一次)
|
||||
|
||||
```bash
|
||||
# 首次设备:生成 Argon2id salt 并存库,派生主密钥后存 OS 钥匙串
|
||||
secrets init
|
||||
|
||||
# 后续设备:复用已有 salt,派生主密钥后存钥匙串(主密码需与首台相同)
|
||||
secrets init
|
||||
```
|
||||
|
||||
### search — 发现与读取
|
||||
|
||||
```bash
|
||||
@@ -190,6 +244,13 @@ secrets add -n refining --kind service --name gitea \
|
||||
secrets add -n ricnsmart --kind service --name mqtt \
|
||||
-m host=mqtt.ricnsmart.com -m port=1883 \
|
||||
-s password=@./mqtt_password.txt
|
||||
|
||||
# 使用类型化值(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
|
||||
```
|
||||
|
||||
---
|
||||
@@ -250,7 +311,99 @@ secrets delete -n ricnsmart --kind server --name i-old-server-id
|
||||
|
||||
---
|
||||
|
||||
### config — 配置管理
|
||||
### history — 查看变更历史
|
||||
|
||||
```bash
|
||||
# 参数说明
|
||||
# -n / --namespace refining | ricnsmart
|
||||
# --kind server | service
|
||||
# --name 记录名
|
||||
# --limit 返回条数(默认 20)
|
||||
|
||||
# 查看某条记录的历史版本列表
|
||||
secrets history -n refining --kind service --name gitea
|
||||
|
||||
# 查最近 5 条
|
||||
secrets history -n refining --kind service --name gitea --limit 5
|
||||
|
||||
# JSON 输出
|
||||
secrets history -n refining --kind service --name gitea -o json
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### rollback — 回滚到指定版本
|
||||
|
||||
```bash
|
||||
# 参数说明
|
||||
# -n / --namespace refining | ricnsmart
|
||||
# --kind server | service
|
||||
# --name 记录名
|
||||
# --to-version <N> 目标版本号(省略则恢复最近一次快照)
|
||||
|
||||
# 撤销上次修改(回滚到最近一次快照)
|
||||
secrets rollback -n refining --kind service --name gitea
|
||||
|
||||
# 回滚到版本 3
|
||||
secrets rollback -n refining --kind service --name gitea --to-version 3
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 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,进程退出码透传。
|
||||
|
||||
```bash
|
||||
# 参数说明
|
||||
# -n / --namespace refining | ricnsmart
|
||||
# --kind server | service
|
||||
# --name 记录名
|
||||
# --tag 按 tag 过滤(可重复)
|
||||
# --prefix 变量名前缀
|
||||
# -- <command> 执行的命令及参数
|
||||
|
||||
# 向脚本注入单条记录的 secrets
|
||||
secrets run -n refining --kind service --name gitea -- ./deploy.sh
|
||||
|
||||
# 按 tag 批量注入(多条记录合并)
|
||||
secrets run --tag production -- env | grep -i token
|
||||
|
||||
# 验证注入了哪些变量
|
||||
secrets run -n refining --kind service --name gitea -- printenv
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### config — 配置管理(无需主密钥)
|
||||
|
||||
```bash
|
||||
# 设置数据库连接(每台设备执行一次,之后永久生效)
|
||||
@@ -289,6 +442,7 @@ secrets --db-url "postgres://..." search -n refining
|
||||
- 字段命名:CLI 短标志 `-n`=namespace,`-m`=meta,`-s`=secret,`-q`=query,`-v`=verbose,`-f`=field,`-o`=output
|
||||
- 日志:用户可见输出用 `println!`;调试/运维信息用 `tracing::debug!`/`info!`/`warn!`/`error!`
|
||||
- 审计:`add`/`update`/`delete` 成功后调用 `audit::log()`,写入 `audit_log` 表;失败只 warn 不中断
|
||||
- 加密:`encrypted` 列存储 AES-256-GCM 密文;`add`/`update`/`search`/`delete` 需主密钥(`secrets init` 后从 OS 钥匙串加载)
|
||||
- 输出:读命令通过 `OutputMode` 支持 text/json/json-compact/env;写命令 `add` 同样支持 `-o json`
|
||||
|
||||
## 提交前检查(必须全部通过)
|
||||
@@ -331,6 +485,7 @@ cargo fmt -- --check && cargo clippy -- -D warnings && cargo test
|
||||
- 新版本自动打 Tag(格式 `secrets-<version>`)并上传二进制到 Gitea Release
|
||||
- 通知:飞书 Webhook(`vars.WEBHOOK_URL`)
|
||||
- 所需 secrets/vars:`RELEASE_TOKEN`(Release 上传,Gitea PAT)、`vars.WEBHOOK_URL`(通知,可选)
|
||||
- **注意**:Gitea Actions 的 Secret/Variable 创建时,`data`/`value` 字段需传入**原始值**,不要使用 base64 编码
|
||||
|
||||
## 环境变量
|
||||
|
||||
|
||||
92
Cargo.lock
generated
92
Cargo.lock
generated
@@ -909,9 +909,12 @@ version = "3.6.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eebcc3aff044e5944a8fbaf69eb277d11986064cba30c468730e8b9909fb551c"
|
||||
dependencies = [
|
||||
"byteorder",
|
||||
"linux-keyutils",
|
||||
"log",
|
||||
"security-framework 2.11.1",
|
||||
"security-framework 3.7.0",
|
||||
"windows-sys 0.60.2",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
@@ -964,6 +967,16 @@ dependencies = [
|
||||
"vcpkg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "linux-keyutils"
|
||||
version = "0.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "761e49ec5fd8a5a463f9b84e877c373d888935b71c6be78f3767fe2ae6bed18e"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "litemap"
|
||||
version = "0.8.1"
|
||||
@@ -1460,7 +1473,7 @@ checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
||||
|
||||
[[package]]
|
||||
name = "secrets"
|
||||
version = "0.5.0"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"anyhow",
|
||||
@@ -2216,7 +2229,6 @@ version = "1.22.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a68d3c8f01c0cfa54a75291d83601161799e4a89a39e0929f4b0354d88757a37"
|
||||
dependencies = [
|
||||
"getrandom 0.4.2",
|
||||
"js-sys",
|
||||
"serde_core",
|
||||
"wasm-bindgen",
|
||||
@@ -2463,6 +2475,15 @@ dependencies = [
|
||||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.60.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb"
|
||||
dependencies = [
|
||||
"windows-targets 0.53.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.61.2"
|
||||
@@ -2496,13 +2517,30 @@ dependencies = [
|
||||
"windows_aarch64_gnullvm 0.52.6",
|
||||
"windows_aarch64_msvc 0.52.6",
|
||||
"windows_i686_gnu 0.52.6",
|
||||
"windows_i686_gnullvm",
|
||||
"windows_i686_gnullvm 0.52.6",
|
||||
"windows_i686_msvc 0.52.6",
|
||||
"windows_x86_64_gnu 0.52.6",
|
||||
"windows_x86_64_gnullvm 0.52.6",
|
||||
"windows_x86_64_msvc 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-targets"
|
||||
version = "0.53.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
"windows_aarch64_gnullvm 0.53.1",
|
||||
"windows_aarch64_msvc 0.53.1",
|
||||
"windows_i686_gnu 0.53.1",
|
||||
"windows_i686_gnullvm 0.53.1",
|
||||
"windows_i686_msvc 0.53.1",
|
||||
"windows_x86_64_gnu 0.53.1",
|
||||
"windows_x86_64_gnullvm 0.53.1",
|
||||
"windows_x86_64_msvc 0.53.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_gnullvm"
|
||||
version = "0.48.5"
|
||||
@@ -2515,6 +2553,12 @@ version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_gnullvm"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_msvc"
|
||||
version = "0.48.5"
|
||||
@@ -2527,6 +2571,12 @@ version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_msvc"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnu"
|
||||
version = "0.48.5"
|
||||
@@ -2539,12 +2589,24 @@ version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnu"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnullvm"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnullvm"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_msvc"
|
||||
version = "0.48.5"
|
||||
@@ -2557,6 +2619,12 @@ version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_msvc"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnu"
|
||||
version = "0.48.5"
|
||||
@@ -2569,6 +2637,12 @@ version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnu"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnullvm"
|
||||
version = "0.48.5"
|
||||
@@ -2581,6 +2655,12 @@ version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnullvm"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_msvc"
|
||||
version = "0.48.5"
|
||||
@@ -2593,6 +2673,12 @@ version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_msvc"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
|
||||
|
||||
[[package]]
|
||||
name = "winnow"
|
||||
version = "1.0.0"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "secrets"
|
||||
version = "0.5.0"
|
||||
version = "0.6.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
@@ -8,9 +8,9 @@ aes-gcm = "0.10.3"
|
||||
anyhow = "1.0.102"
|
||||
argon2 = { version = "0.5.3", features = ["std"] }
|
||||
chrono = { version = "0.4.44", features = ["serde"] }
|
||||
clap = { version = "4.6.0", features = ["derive", "env"] }
|
||||
clap = { version = "4.6.0", features = ["derive"] }
|
||||
dirs = "6.0.0"
|
||||
keyring = { version = "3.6.3", features = ["apple-native"] }
|
||||
keyring = { version = "3.6.3", features = ["apple-native", "windows-native", "linux-native"] }
|
||||
rand = "0.10.0"
|
||||
rpassword = "7.4.0"
|
||||
serde = { version = "1.0.228", features = ["derive"] }
|
||||
@@ -20,4 +20,4 @@ tokio = { version = "1.50.0", features = ["full"] }
|
||||
toml = "1.0.7"
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
uuid = { version = "1.22.0", features = ["serde", "v4"] }
|
||||
uuid = { version = "1.22.0", features = ["serde"] }
|
||||
|
||||
29
README.md
29
README.md
@@ -2,7 +2,7 @@
|
||||
|
||||
跨设备密钥与配置管理 CLI,基于 Rust + PostgreSQL 18。
|
||||
|
||||
将服务器信息、服务凭据统一存入数据库,供本地工具和 AI 读取上下文。
|
||||
将服务器信息、服务凭据统一存入数据库,供本地工具和 AI 读取上下文。敏感数据(`encrypted` 字段)使用 AES-256-GCM 加密存储,主密钥由 Argon2id 从主密码派生并存入系统钥匙串。
|
||||
|
||||
## 安装
|
||||
|
||||
@@ -11,12 +11,22 @@ cargo build --release
|
||||
# 或从 Release 页面下载预编译二进制
|
||||
```
|
||||
|
||||
配置数据库连接(首次使用需执行一次,之后在该设备上持久生效):
|
||||
## 首次使用(每台设备各执行一次)
|
||||
|
||||
```bash
|
||||
# 1. 配置数据库连接
|
||||
secrets config set-db "postgres://postgres:<password>@<host>:<port>/secrets"
|
||||
|
||||
# 2. 初始化主密钥(提示输入主密码,派生后存入 OS 钥匙串)
|
||||
secrets init
|
||||
```
|
||||
|
||||
主密码不会存储,仅用于派生主密钥。同一主密码在所有设备上会得到相同主密钥(salt 存于数据库,首台设备生成后共享)。
|
||||
|
||||
**主密钥存储**:macOS → Keychain;Windows → Credential Manager;Linux → keyutils(会话级,重启后需再次 `secrets init`)。
|
||||
|
||||
**从旧版(明文存储)升级**:升级后首次运行需执行 `secrets init` 即可(明文记录需手动重新 add 或通过 update 更新)。
|
||||
|
||||
## AI Agent 快速指南
|
||||
|
||||
这个 CLI 以 AI 使用优先设计。核心路径只有一条:**读取用 `search`,写入用 `add` / `update`**。
|
||||
@@ -80,6 +90,7 @@ secrets search -n refining --kind service --name gitea -o env --show-secrets \
|
||||
```bash
|
||||
# 查看帮助(包含各子命令 EXAMPLES)
|
||||
secrets --help
|
||||
secrets init --help # 主密钥初始化
|
||||
secrets search --help
|
||||
secrets add --help
|
||||
secrets update --help
|
||||
@@ -116,6 +127,9 @@ secrets update -n refining --kind service --name mqtt --remove-meta old_port --r
|
||||
# ── delete ───────────────────────────────────────────────────────────────────
|
||||
secrets delete -n refining --kind service --name legacy-mqtt
|
||||
|
||||
# ── init ─────────────────────────────────────────────────────────────────────
|
||||
secrets init # 主密钥初始化(每台设备一次,主密码派生后存钥匙串)
|
||||
|
||||
# ── config ───────────────────────────────────────────────────────────────────
|
||||
secrets config set-db "postgres://postgres:<password>@<host>:<port>/secrets"
|
||||
secrets config show # 密码脱敏展示
|
||||
@@ -137,9 +151,9 @@ RUST_LOG=secrets=trace secrets search
|
||||
| `name` | 人类可读唯一标识 |
|
||||
| `tags` | 多维标签,如 `["aliyun","hongkong"]` |
|
||||
| `metadata` | 明文描述信息(ip、desc、domains 等) |
|
||||
| `encrypted` | 敏感凭据(ssh_key、password、token 等),MVP 阶段明文存储,预留加密字段 |
|
||||
| `encrypted` | 敏感凭据(ssh_key、password、token 等),AES-256-GCM 加密存储 |
|
||||
|
||||
`-m` / `--meta` 写入 `metadata`,`-s` / `--secret` 写入 `encrypted`,`value=@file` 从文件读取内容。
|
||||
`-m` / `--meta` 写入 `metadata`,`-s` / `--secret` 写入 `encrypted`,`value=@file` 从文件读取内容。加解密使用主密钥(由 `secrets init` 设置)。
|
||||
|
||||
## 审计日志
|
||||
|
||||
@@ -160,17 +174,19 @@ src/
|
||||
main.rs # CLI 入口(clap),含各子命令 after_help 示例
|
||||
output.rs # OutputMode 枚举 + TTY 检测
|
||||
config.rs # 配置读写(~/.config/secrets/config.toml)
|
||||
db.rs # 连接池 + auto-migrate(secrets + audit_log)
|
||||
db.rs # 连接池 + auto-migrate(secrets + audit_log + kv_config)
|
||||
crypto.rs # AES-256-GCM 加解密、Argon2id 派生、OS 钥匙串
|
||||
models.rs # Secret 结构体
|
||||
audit.rs # 审计日志写入(audit_log 表)
|
||||
commands/
|
||||
init.rs # 主密钥初始化(首次/新设备)
|
||||
add.rs # upsert,支持 -o json
|
||||
config.rs # config set-db/show/path
|
||||
search.rs # 多条件查询,支持 -f/-o/--summary/--limit/--offset/--sort
|
||||
delete.rs # 删除
|
||||
update.rs # 增量更新(合并 tags/metadata/encrypted)
|
||||
scripts/
|
||||
seed-data.sh # 导入 refining / ricnsmart 全量数据
|
||||
setup-gitea-actions.sh # 配置 Gitea Actions 变量与 Secrets
|
||||
```
|
||||
|
||||
## CI/CD(Gitea Actions)
|
||||
@@ -186,5 +202,6 @@ scripts/
|
||||
|
||||
- `RELEASE_TOKEN`(Secret):Gitea PAT,用于创建 Release 上传二进制
|
||||
- `WEBHOOK_URL`(Variable):飞书通知,可选
|
||||
- **注意**:Secret/Variable 的 `data`/`value` 字段需传入原始值,不要 base64 编码
|
||||
|
||||
详见 [AGENTS.md](AGENTS.md)。
|
||||
|
||||
@@ -7,7 +7,9 @@
|
||||
# - secrets.RELEASE_TOKEN (必选) Release 上传用,值为 Gitea PAT
|
||||
# - 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
|
||||
@@ -108,11 +110,13 @@ echo "━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
echo ""
|
||||
|
||||
# 1. 创建 Secret: RELEASE_TOKEN
|
||||
# 注意: Gitea Actions API 的 data 字段需传入原始值,不要使用 base64 编码
|
||||
echo "1. 创建 Secret: RELEASE_TOKEN"
|
||||
secret_payload=$(jq -n --arg t "$GITEA_TOKEN" '{data: $t}')
|
||||
resp=$(curl -s -w "\n%{http_code}" -X PUT \
|
||||
-H "Authorization: token $GITEA_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"data\":\"${GITEA_TOKEN}\"}" \
|
||||
-d "$secret_payload" \
|
||||
"${API_BASE}/repos/${OWNER}/${REPO}/actions/secrets/RELEASE_TOKEN")
|
||||
http_code=$(echo "$resp" | tail -n1)
|
||||
body=$(echo "$resp" | sed '$d')
|
||||
@@ -126,14 +130,16 @@ else
|
||||
fi
|
||||
|
||||
# 2. 创建/更新 Variable: WEBHOOK_URL(可选)
|
||||
# 注意: Secret 和 Variable 均使用原始值,不要 base64 编码
|
||||
WEBHOOK_VALUE="${WEBHOOK_URL:-$GITEA_WEBHOOK_URL}"
|
||||
if [[ -n "$WEBHOOK_VALUE" ]]; then
|
||||
echo ""
|
||||
echo "2. 创建/更新 Variable: WEBHOOK_URL"
|
||||
var_payload=$(jq -n --arg v "$WEBHOOK_VALUE" '{value: $v}')
|
||||
resp=$(curl -s -w "\n%{http_code}" -X POST \
|
||||
-H "Authorization: token $GITEA_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"value\":\"${WEBHOOK_VALUE}\"}" \
|
||||
-d "$var_payload" \
|
||||
"${API_BASE}/repos/${OWNER}/${REPO}/actions/variables/WEBHOOK_URL")
|
||||
http_code=$(echo "$resp" | tail -n1)
|
||||
body=$(echo "$resp" | sed '$d')
|
||||
@@ -145,7 +151,7 @@ if [[ -n "$WEBHOOK_VALUE" ]]; then
|
||||
resp=$(curl -s -w "\n%{http_code}" -X PUT \
|
||||
-H "Authorization: token $GITEA_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"value\":\"${WEBHOOK_VALUE}\"}" \
|
||||
-d "$var_payload" \
|
||||
"${API_BASE}/repos/${OWNER}/${REPO}/actions/variables/WEBHOOK_URL")
|
||||
http_code=$(echo "$resp" | tail -n1)
|
||||
if [[ "$http_code" == "200" || "$http_code" == "204" ]]; then
|
||||
|
||||
38
src/audit.rs
38
src/audit.rs
@@ -1,9 +1,39 @@
|
||||
use anyhow::Result;
|
||||
use serde_json::Value;
|
||||
use sqlx::PgPool;
|
||||
use sqlx::{PgPool, Postgres, Transaction};
|
||||
|
||||
/// Write an audit entry for a write operation. Failures are logged as warnings
|
||||
/// and do not interrupt the main flow.
|
||||
/// Write an audit entry within an existing transaction.
|
||||
pub async fn log_tx(
|
||||
tx: &mut Transaction<'_, Postgres>,
|
||||
action: &str,
|
||||
namespace: &str,
|
||||
kind: &str,
|
||||
name: &str,
|
||||
detail: Value,
|
||||
) {
|
||||
let actor = std::env::var("USER").unwrap_or_default();
|
||||
let result: Result<_, sqlx::Error> = sqlx::query(
|
||||
"INSERT INTO audit_log (action, namespace, kind, name, detail, actor) \
|
||||
VALUES ($1, $2, $3, $4, $5, $6)",
|
||||
)
|
||||
.bind(action)
|
||||
.bind(namespace)
|
||||
.bind(kind)
|
||||
.bind(name)
|
||||
.bind(&detail)
|
||||
.bind(&actor)
|
||||
.execute(&mut **tx)
|
||||
.await;
|
||||
|
||||
if let Err(e) = result {
|
||||
tracing::warn!(error = %e, "failed to write audit log");
|
||||
} else {
|
||||
tracing::debug!(action, namespace, kind, name, actor, "audit logged");
|
||||
}
|
||||
}
|
||||
|
||||
/// Write an audit entry using the pool (fire-and-forget, non-fatal).
|
||||
/// Kept for future use or scenarios without an active transaction.
|
||||
#[allow(dead_code)]
|
||||
pub async fn log(
|
||||
pool: &PgPool,
|
||||
action: &str,
|
||||
|
||||
@@ -4,13 +4,30 @@ use sqlx::PgPool;
|
||||
use std::fs;
|
||||
|
||||
use crate::crypto;
|
||||
use crate::db;
|
||||
use crate::output::OutputMode;
|
||||
|
||||
/// Parse "key=value" entries. Value starting with '@' reads from file.
|
||||
pub(crate) fn parse_kv(entry: &str) -> Result<(String, String)> {
|
||||
/// Parse "key=value" or "key:=<json>" entries.
|
||||
/// - `key=value` → stores the literal string `value`
|
||||
/// - `key:=<json>` → parses `<json>` as a typed JSON value (number, bool, null, array, object)
|
||||
/// - `value=@file` → reads the file content as a string (only for `=` form)
|
||||
pub(crate) fn parse_kv(entry: &str) -> Result<(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((key.to_string(), val));
|
||||
}
|
||||
|
||||
// Plain string form: key=value or key=@file
|
||||
let (key, raw_val) = entry.split_once('=').ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"Invalid format '{}'. Expected: key=value or key=@file",
|
||||
"Invalid format '{}'. Expected: key=value, key=@file, or key:=<json>",
|
||||
entry
|
||||
)
|
||||
})?;
|
||||
@@ -22,14 +39,14 @@ pub(crate) fn parse_kv(entry: &str) -> Result<(String, String)> {
|
||||
raw_val.to_string()
|
||||
};
|
||||
|
||||
Ok((key.to_string(), value))
|
||||
Ok((key.to_string(), Value::String(value)))
|
||||
}
|
||||
|
||||
pub(crate) fn build_json(entries: &[String]) -> Result<Value> {
|
||||
let mut map = Map::new();
|
||||
for entry in entries {
|
||||
let (key, value) = parse_kv(entry)?;
|
||||
map.insert(key, Value::String(value));
|
||||
map.insert(key, value);
|
||||
}
|
||||
Ok(Value::Object(map))
|
||||
}
|
||||
@@ -47,21 +64,72 @@ pub struct AddArgs<'a> {
|
||||
pub async fn run(pool: &PgPool, args: AddArgs<'_>, master_key: &[u8; 32]) -> Result<()> {
|
||||
let metadata = build_json(args.meta_entries)?;
|
||||
let secret_json = build_json(args.secret_entries)?;
|
||||
|
||||
// Encrypt the secret JSON before storing
|
||||
let encrypted_bytes = crypto::encrypt_json(master_key, &secret_json)?;
|
||||
|
||||
tracing::debug!(args.namespace, args.kind, args.name, "upserting record");
|
||||
|
||||
let meta_keys: Vec<&str> = args
|
||||
.meta_entries
|
||||
.iter()
|
||||
.filter_map(|s| s.split_once(['=', ':']).map(|(k, _)| k))
|
||||
.collect();
|
||||
let secret_keys: Vec<&str> = args
|
||||
.secret_entries
|
||||
.iter()
|
||||
.filter_map(|s| s.split_once(['=', ':']).map(|(k, _)| k))
|
||||
.collect();
|
||||
|
||||
let mut tx = pool.begin().await?;
|
||||
|
||||
// Snapshot existing row into history before overwriting (if it exists).
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct ExistingRow {
|
||||
id: uuid::Uuid,
|
||||
version: i64,
|
||||
tags: Vec<String>,
|
||||
metadata: serde_json::Value,
|
||||
encrypted: Vec<u8>,
|
||||
}
|
||||
let existing: Option<ExistingRow> = sqlx::query_as(
|
||||
"SELECT id, version, tags, metadata, encrypted FROM secrets \
|
||||
WHERE namespace = $1 AND kind = $2 AND name = $3",
|
||||
)
|
||||
.bind(args.namespace)
|
||||
.bind(args.kind)
|
||||
.bind(args.name)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
|
||||
if let Some(ex) = existing
|
||||
&& let Err(e) = db::snapshot_history(
|
||||
&mut tx,
|
||||
db::SnapshotParams {
|
||||
secret_id: ex.id,
|
||||
namespace: args.namespace,
|
||||
kind: args.kind,
|
||||
name: args.name,
|
||||
version: ex.version,
|
||||
action: "add",
|
||||
tags: &ex.tags,
|
||||
metadata: &ex.metadata,
|
||||
encrypted: &ex.encrypted,
|
||||
},
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::warn!(error = %e, "failed to snapshot history before upsert");
|
||||
}
|
||||
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT INTO secrets (namespace, kind, name, tags, metadata, encrypted, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, NOW())
|
||||
INSERT INTO secrets (namespace, kind, name, tags, metadata, encrypted, version, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, 1, NOW())
|
||||
ON CONFLICT (namespace, kind, name)
|
||||
DO UPDATE SET
|
||||
tags = EXCLUDED.tags,
|
||||
metadata = EXCLUDED.metadata,
|
||||
encrypted = EXCLUDED.encrypted,
|
||||
tags = EXCLUDED.tags,
|
||||
metadata = EXCLUDED.metadata,
|
||||
encrypted = EXCLUDED.encrypted,
|
||||
version = secrets.version + 1,
|
||||
updated_at = NOW()
|
||||
"#,
|
||||
)
|
||||
@@ -71,22 +139,11 @@ pub async fn run(pool: &PgPool, args: AddArgs<'_>, master_key: &[u8; 32]) -> Res
|
||||
.bind(args.tags)
|
||||
.bind(&metadata)
|
||||
.bind(&encrypted_bytes)
|
||||
.execute(pool)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
let meta_keys: Vec<&str> = args
|
||||
.meta_entries
|
||||
.iter()
|
||||
.filter_map(|s| s.split_once('=').map(|(k, _)| k))
|
||||
.collect();
|
||||
let secret_keys: Vec<&str> = args
|
||||
.secret_entries
|
||||
.iter()
|
||||
.filter_map(|s| s.split_once('=').map(|(k, _)| k))
|
||||
.collect();
|
||||
|
||||
crate::audit::log(
|
||||
pool,
|
||||
crate::audit::log_tx(
|
||||
&mut tx,
|
||||
"add",
|
||||
args.namespace,
|
||||
args.kind,
|
||||
@@ -99,6 +156,8 @@ pub async fn run(pool: &PgPool, args: AddArgs<'_>, master_key: &[u8; 32]) -> Res
|
||||
)
|
||||
.await;
|
||||
|
||||
tx.commit().await?;
|
||||
|
||||
let result_json = json!({
|
||||
"action": "added",
|
||||
"namespace": args.namespace,
|
||||
|
||||
@@ -1,43 +1,37 @@
|
||||
use crate::config::{self, Config, config_path};
|
||||
use anyhow::Result;
|
||||
|
||||
pub enum ConfigAction {
|
||||
SetDb { url: String },
|
||||
Show,
|
||||
Path,
|
||||
}
|
||||
|
||||
pub async fn run(action: ConfigAction) -> Result<()> {
|
||||
pub async fn run(action: crate::ConfigAction) -> Result<()> {
|
||||
match action {
|
||||
ConfigAction::SetDb { url } => {
|
||||
crate::ConfigAction::SetDb { url } => {
|
||||
let cfg = Config {
|
||||
database_url: Some(url.clone()),
|
||||
};
|
||||
config::save_config(&cfg)?;
|
||||
println!("✓ 数据库连接串已保存到: {}", config_path().display());
|
||||
println!("Database URL saved to: {}", config_path().display());
|
||||
println!(" {}", mask_password(&url));
|
||||
}
|
||||
ConfigAction::Show => {
|
||||
crate::ConfigAction::Show => {
|
||||
let cfg = config::load_config()?;
|
||||
match cfg.database_url {
|
||||
Some(url) => {
|
||||
println!("database_url = {}", mask_password(&url));
|
||||
println!("配置文件: {}", config_path().display());
|
||||
println!("config file: {}", config_path().display());
|
||||
}
|
||||
None => {
|
||||
println!("未配置数据库连接串。");
|
||||
println!("请运行:secrets config set-db <DATABASE_URL>");
|
||||
println!("Database URL not configured.");
|
||||
println!("Run: secrets config set-db <DATABASE_URL>");
|
||||
}
|
||||
}
|
||||
}
|
||||
ConfigAction::Path => {
|
||||
crate::ConfigAction::Path => {
|
||||
println!("{}", config_path().display());
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 将 postgres://user:password@host/db 中的密码替换为 ***
|
||||
/// Mask the password in a postgres://user:password@host/db URL.
|
||||
fn mask_password(url: &str) -> String {
|
||||
if let Some(at_pos) = url.rfind('@')
|
||||
&& let Some(scheme_end) = url.find("://")
|
||||
|
||||
@@ -1,24 +1,107 @@
|
||||
use anyhow::Result;
|
||||
use serde_json::json;
|
||||
use sqlx::PgPool;
|
||||
use serde_json::{Value, json};
|
||||
use sqlx::{FromRow, PgPool};
|
||||
use uuid::Uuid;
|
||||
|
||||
pub async fn run(pool: &PgPool, namespace: &str, kind: &str, name: &str) -> Result<()> {
|
||||
use crate::db;
|
||||
use crate::output::OutputMode;
|
||||
|
||||
#[derive(FromRow)]
|
||||
struct DeleteRow {
|
||||
id: Uuid,
|
||||
version: i64,
|
||||
tags: Vec<String>,
|
||||
metadata: Value,
|
||||
encrypted: Vec<u8>,
|
||||
}
|
||||
|
||||
pub async fn run(
|
||||
pool: &PgPool,
|
||||
namespace: &str,
|
||||
kind: &str,
|
||||
name: &str,
|
||||
output: OutputMode,
|
||||
) -> Result<()> {
|
||||
tracing::debug!(namespace, kind, name, "deleting record");
|
||||
|
||||
let result =
|
||||
sqlx::query("DELETE FROM secrets WHERE namespace = $1 AND kind = $2 AND name = $3")
|
||||
.bind(namespace)
|
||||
.bind(kind)
|
||||
.bind(name)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
let mut tx = pool.begin().await?;
|
||||
|
||||
if result.rows_affected() == 0 {
|
||||
let row: Option<DeleteRow> = sqlx::query_as(
|
||||
"SELECT id, version, tags, metadata, encrypted FROM secrets \
|
||||
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, "record not found for deletion");
|
||||
println!("Not found: [{}/{}] {}", namespace, kind, name);
|
||||
} else {
|
||||
crate::audit::log(pool, "delete", namespace, kind, name, json!({})).await;
|
||||
println!("Deleted: [{}/{}] {}", namespace, kind, name);
|
||||
match output {
|
||||
OutputMode::Json => println!(
|
||||
"{}",
|
||||
serde_json::to_string_pretty(
|
||||
&json!({"action":"not_found","namespace":namespace,"kind":kind,"name":name})
|
||||
)?
|
||||
),
|
||||
OutputMode::JsonCompact => println!(
|
||||
"{}",
|
||||
serde_json::to_string(
|
||||
&json!({"action":"not_found","namespace":namespace,"kind":kind,"name":name})
|
||||
)?
|
||||
),
|
||||
_ => println!("Not found: [{}/{}] {}", namespace, kind, name),
|
||||
}
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
// Snapshot before physical delete so the row can be restored via rollback.
|
||||
if let Err(e) = db::snapshot_history(
|
||||
&mut tx,
|
||||
db::SnapshotParams {
|
||||
secret_id: row.id,
|
||||
namespace,
|
||||
kind,
|
||||
name,
|
||||
version: row.version,
|
||||
action: "delete",
|
||||
tags: &row.tags,
|
||||
metadata: &row.metadata,
|
||||
encrypted: &row.encrypted,
|
||||
},
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::warn!(error = %e, "failed to snapshot history before delete");
|
||||
}
|
||||
|
||||
sqlx::query("DELETE FROM secrets WHERE id = $1")
|
||||
.bind(row.id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
crate::audit::log_tx(&mut tx, "delete", namespace, kind, name, json!({})).await;
|
||||
|
||||
tx.commit().await?;
|
||||
|
||||
match output {
|
||||
OutputMode::Json => println!(
|
||||
"{}",
|
||||
serde_json::to_string_pretty(
|
||||
&json!({"action":"deleted","namespace":namespace,"kind":kind,"name":name})
|
||||
)?
|
||||
),
|
||||
OutputMode::JsonCompact => println!(
|
||||
"{}",
|
||||
serde_json::to_string(
|
||||
&json!({"action":"deleted","namespace":namespace,"kind":kind,"name":name})
|
||||
)?
|
||||
),
|
||||
_ => println!("Deleted: [{}/{}] {}", namespace, kind, name),
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1,85 +0,0 @@
|
||||
use anyhow::Result;
|
||||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::crypto;
|
||||
|
||||
/// Row fetched for migration
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct MigrateRow {
|
||||
id: Uuid,
|
||||
namespace: String,
|
||||
kind: String,
|
||||
name: String,
|
||||
encrypted: Vec<u8>,
|
||||
}
|
||||
|
||||
/// Encrypt any records whose `encrypted` column contains raw (unencrypted) bytes.
|
||||
///
|
||||
/// After the schema migration, old JSONB rows were stored as raw UTF-8 bytes.
|
||||
/// A valid AES-256-GCM blob is always at least 28 bytes (12 nonce + 16 tag).
|
||||
/// We attempt to decrypt each row; if decryption fails, we assume it's plaintext
|
||||
/// JSON and re-encrypt it.
|
||||
pub async fn run(pool: &PgPool, master_key: &[u8; 32]) -> Result<()> {
|
||||
println!("Scanning for unencrypted secret rows...");
|
||||
|
||||
let rows: Vec<MigrateRow> =
|
||||
sqlx::query_as("SELECT id, namespace, kind, name, encrypted FROM secrets")
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
|
||||
let total = rows.len();
|
||||
let mut migrated = 0usize;
|
||||
let mut already_encrypted = 0usize;
|
||||
let mut skipped_empty = 0usize;
|
||||
|
||||
for row in rows {
|
||||
if row.encrypted.is_empty() {
|
||||
skipped_empty += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Try to decrypt; success → already encrypted, skip
|
||||
if crypto::decrypt_json(master_key, &row.encrypted).is_ok() {
|
||||
already_encrypted += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Treat as plaintext JSON bytes (from schema migration copy)
|
||||
let json_str = String::from_utf8(row.encrypted.clone()).map_err(|_| {
|
||||
anyhow::anyhow!(
|
||||
"Row [{}/{}/{}]: encrypted column contains non-UTF-8 bytes that are also not valid ciphertext. Manual inspection required.",
|
||||
row.namespace, row.kind, row.name
|
||||
)
|
||||
})?;
|
||||
|
||||
let value: serde_json::Value = serde_json::from_str(&json_str).map_err(|e| {
|
||||
anyhow::anyhow!(
|
||||
"Row [{}/{}/{}]: failed to parse as JSON: {}",
|
||||
row.namespace,
|
||||
row.kind,
|
||||
row.name,
|
||||
e
|
||||
)
|
||||
})?;
|
||||
|
||||
let encrypted_bytes = crypto::encrypt_json(master_key, &value)?;
|
||||
|
||||
sqlx::query("UPDATE secrets SET encrypted = $1, updated_at = NOW() WHERE id = $2")
|
||||
.bind(&encrypted_bytes)
|
||||
.bind(row.id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
println!(" Encrypted: [{}/{}] {}", row.namespace, row.kind, row.name);
|
||||
migrated += 1;
|
||||
}
|
||||
|
||||
println!();
|
||||
println!(
|
||||
"Done. Total: {total}, encrypted this run: {migrated}, \
|
||||
already encrypted: {already_encrypted}, empty (skipped): {skipped_empty}"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -2,6 +2,7 @@ pub mod add;
|
||||
pub mod config;
|
||||
pub mod delete;
|
||||
pub mod init;
|
||||
pub mod migrate_encrypt;
|
||||
pub mod rollback;
|
||||
pub mod run;
|
||||
pub mod search;
|
||||
pub mod update;
|
||||
|
||||
245
src/commands/rollback.rs
Normal file
245
src/commands/rollback.rs
Normal file
@@ -0,0 +1,245 @@
|
||||
use anyhow::Result;
|
||||
use serde_json::{Value, json};
|
||||
use sqlx::{FromRow, PgPool};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::output::OutputMode;
|
||||
|
||||
#[derive(FromRow)]
|
||||
struct HistoryRow {
|
||||
secret_id: Uuid,
|
||||
#[allow(dead_code)]
|
||||
namespace: String,
|
||||
#[allow(dead_code)]
|
||||
kind: String,
|
||||
#[allow(dead_code)]
|
||||
name: String,
|
||||
version: i64,
|
||||
action: String,
|
||||
tags: Vec<String>,
|
||||
metadata: Value,
|
||||
encrypted: Vec<u8>,
|
||||
}
|
||||
|
||||
pub struct RollbackArgs<'a> {
|
||||
pub namespace: &'a str,
|
||||
pub kind: &'a str,
|
||||
pub name: &'a str,
|
||||
/// Target 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<()> {
|
||||
let snap: Option<HistoryRow> = if let Some(ver) = args.to_version {
|
||||
sqlx::query_as(
|
||||
"SELECT secret_id, namespace, kind, name, version, action, tags, metadata, encrypted \
|
||||
FROM secrets_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 secret_id, namespace, kind, name, version, action, tags, metadata, encrypted \
|
||||
FROM secrets_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()
|
||||
)
|
||||
})?;
|
||||
|
||||
// Validate encrypted blob is non-trivial (re-encrypt guard).
|
||||
if !snap.encrypted.is_empty() {
|
||||
// Probe decrypt to ensure the blob is valid before restoring.
|
||||
crate::crypto::decrypt_json(master_key, &snap.encrypted)?;
|
||||
}
|
||||
|
||||
let mut tx = pool.begin().await?;
|
||||
|
||||
// Snapshot current live row (if it exists) before overwriting.
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct LiveRow {
|
||||
id: Uuid,
|
||||
version: i64,
|
||||
tags: Vec<String>,
|
||||
metadata: Value,
|
||||
encrypted: Vec<u8>,
|
||||
}
|
||||
let live: Option<LiveRow> = sqlx::query_as(
|
||||
"SELECT id, version, tags, metadata, encrypted FROM secrets \
|
||||
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(lr) = live
|
||||
&& let Err(e) = crate::db::snapshot_history(
|
||||
&mut tx,
|
||||
crate::db::SnapshotParams {
|
||||
secret_id: lr.id,
|
||||
namespace: args.namespace,
|
||||
kind: args.kind,
|
||||
name: args.name,
|
||||
version: lr.version,
|
||||
action: "rollback",
|
||||
tags: &lr.tags,
|
||||
metadata: &lr.metadata,
|
||||
encrypted: &lr.encrypted,
|
||||
},
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::warn!(error = %e, "failed to snapshot current row before rollback");
|
||||
}
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO secrets (id, namespace, kind, name, tags, metadata, encrypted, version, updated_at) \
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, NOW()) \
|
||||
ON CONFLICT (namespace, kind, name) DO UPDATE SET \
|
||||
tags = EXCLUDED.tags, \
|
||||
metadata = EXCLUDED.metadata, \
|
||||
encrypted = EXCLUDED.encrypted, \
|
||||
version = secrets.version + 1, \
|
||||
updated_at = NOW()",
|
||||
)
|
||||
.bind(snap.secret_id)
|
||||
.bind(args.namespace)
|
||||
.bind(args.kind)
|
||||
.bind(args.name)
|
||||
.bind(&snap.tags)
|
||||
.bind(&snap.metadata)
|
||||
.bind(&snap.encrypted)
|
||||
.bind(snap.version)
|
||||
.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::Json => println!("{}", serde_json::to_string_pretty(&result_json)?),
|
||||
OutputMode::JsonCompact => println!("{}", serde_json::to_string(&result_json)?),
|
||||
_ => println!(
|
||||
"Rolled back: [{}/{}] {} → version {}",
|
||||
args.namespace, args.kind, args.name, snap.version
|
||||
),
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// List history entries for a record.
|
||||
pub async fn list_history(
|
||||
pool: &PgPool,
|
||||
namespace: &str,
|
||||
kind: &str,
|
||||
name: &str,
|
||||
limit: u32,
|
||||
output: OutputMode,
|
||||
) -> 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 secrets_history \
|
||||
WHERE namespace = $1 AND kind = $2 AND name = $3 \
|
||||
ORDER BY id DESC LIMIT $4",
|
||||
)
|
||||
.bind(namespace)
|
||||
.bind(kind)
|
||||
.bind(name)
|
||||
.bind(limit as i64)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
|
||||
match 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();
|
||||
let out = if output == OutputMode::Json {
|
||||
serde_json::to_string_pretty(&arr)?
|
||||
} else {
|
||||
serde_json::to_string(&arr)?
|
||||
};
|
||||
println!("{}", out);
|
||||
}
|
||||
_ => {
|
||||
if rows.is_empty() {
|
||||
println!("No history found for [{}/{}] {}.", namespace, kind, name);
|
||||
return Ok(());
|
||||
}
|
||||
println!("History for [{}/{}] {}:", namespace, kind, name);
|
||||
for r in &rows {
|
||||
println!(
|
||||
" v{:<4} {:8} {} {}",
|
||||
r.version,
|
||||
r.action,
|
||||
r.actor,
|
||||
r.created_at.format("%Y-%m-%d %H:%M:%S UTC")
|
||||
);
|
||||
}
|
||||
println!(" (use `secrets rollback --to-version <N>` to restore)");
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
143
src/commands/run.rs
Normal file
143
src/commands/run.rs
Normal file
@@ -0,0 +1,143 @@
|
||||
use anyhow::Result;
|
||||
use serde_json::Value;
|
||||
use sqlx::PgPool;
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::commands::search::build_env_map;
|
||||
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],
|
||||
/// Prefix to prepend to every variable name. Empty string means no prefix.
|
||||
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 prefix: &'a str,
|
||||
/// The command and its arguments to execute with injected secrets.
|
||||
pub command: &'a [String],
|
||||
}
|
||||
|
||||
/// Fetch secrets matching the filter and build a flat env map.
|
||||
/// Metadata and secret fields are merged; naming: `<PREFIX_><NAME>_<KEY>` (uppercased).
|
||||
pub async fn collect_env_map(
|
||||
pool: &PgPool,
|
||||
namespace: Option<&str>,
|
||||
kind: Option<&str>,
|
||||
name: Option<&str>,
|
||||
tags: &[String],
|
||||
prefix: &str,
|
||||
master_key: &[u8; 32],
|
||||
) -> Result<HashMap<String, String>> {
|
||||
if namespace.is_none() && kind.is_none() && name.is_none() && tags.is_empty() {
|
||||
anyhow::bail!(
|
||||
"At least one filter (--namespace, --kind, --name, or --tag) is required for inject/run"
|
||||
);
|
||||
}
|
||||
let rows = crate::commands::search::fetch_rows(pool, namespace, kind, name, tags, None).await?;
|
||||
if rows.is_empty() {
|
||||
anyhow::bail!("No records matched the given filters.");
|
||||
}
|
||||
let mut map = HashMap::new();
|
||||
for row in &rows {
|
||||
let row_map = build_env_map(row, prefix, Some(master_key))?;
|
||||
for (k, v) in row_map {
|
||||
map.insert(k, v);
|
||||
}
|
||||
}
|
||||
Ok(map)
|
||||
}
|
||||
|
||||
/// `inject` command: print env vars to stdout (suitable for `eval $(...)` or export).
|
||||
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))?);
|
||||
}
|
||||
_ => {
|
||||
// Shell-safe KEY=VALUE output, one per line.
|
||||
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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `run` command: inject secrets into a child process environment and execute.
|
||||
pub async fn run_exec(pool: &PgPool, args: RunArgs<'_>, master_key: &[u8; 32]) -> Result<()> {
|
||||
if 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?;
|
||||
|
||||
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(())
|
||||
}
|
||||
|
||||
/// Quote a value for safe shell output. Wraps the value in single quotes,
|
||||
/// escaping any single quotes within the value.
|
||||
fn shell_quote(s: &str) -> String {
|
||||
format!("'{}'", s.replace('\'', "'\\''"))
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
use anyhow::Result;
|
||||
use serde_json::{Value, json};
|
||||
use sqlx::PgPool;
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::crypto;
|
||||
use crate::models::Secret;
|
||||
@@ -10,7 +11,7 @@ pub struct SearchArgs<'a> {
|
||||
pub namespace: Option<&'a str>,
|
||||
pub kind: Option<&'a str>,
|
||||
pub name: Option<&'a str>,
|
||||
pub tag: Option<&'a str>,
|
||||
pub tags: &'a [String],
|
||||
pub query: Option<&'a str>,
|
||||
pub show_secrets: bool,
|
||||
pub fields: &'a [String],
|
||||
@@ -22,74 +23,20 @@ pub struct SearchArgs<'a> {
|
||||
}
|
||||
|
||||
pub async fn run(pool: &PgPool, args: SearchArgs<'_>, master_key: Option<&[u8; 32]>) -> Result<()> {
|
||||
let mut conditions: Vec<String> = Vec::new();
|
||||
let mut idx: i32 = 1;
|
||||
|
||||
if args.namespace.is_some() {
|
||||
conditions.push(format!("namespace = ${}", idx));
|
||||
idx += 1;
|
||||
}
|
||||
if args.kind.is_some() {
|
||||
conditions.push(format!("kind = ${}", idx));
|
||||
idx += 1;
|
||||
}
|
||||
if args.name.is_some() {
|
||||
conditions.push(format!("name = ${}", idx));
|
||||
idx += 1;
|
||||
}
|
||||
if args.tag.is_some() {
|
||||
conditions.push(format!("tags @> ARRAY[${}]", idx));
|
||||
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() {
|
||||
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?;
|
||||
let rows = fetch_rows_paged(
|
||||
pool,
|
||||
PagedFetchArgs {
|
||||
namespace: args.namespace,
|
||||
kind: args.kind,
|
||||
name: args.name,
|
||||
tags: args.tags,
|
||||
query: args.query,
|
||||
sort: args.sort,
|
||||
limit: args.limit,
|
||||
offset: args.offset,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
// -f/--field: extract specific field values directly
|
||||
if !args.fields.is_empty() {
|
||||
@@ -117,7 +64,12 @@ pub async fn run(pool: &PgPool, args: SearchArgs<'_>, master_key: Option<&[u8; 3
|
||||
);
|
||||
}
|
||||
if let Some(row) = rows.first() {
|
||||
print_env(row, args.show_secrets, master_key)?;
|
||||
let map = build_env_map(row, "", master_key)?;
|
||||
let mut pairs: Vec<(String, String)> = map.into_iter().collect();
|
||||
pairs.sort_by(|a, b| a.0.cmp(&b.0));
|
||||
for (k, v) in pairs {
|
||||
println!("{}={}", k, shell_quote(&v));
|
||||
}
|
||||
} else {
|
||||
eprintln!("No records found.");
|
||||
}
|
||||
@@ -144,8 +96,195 @@ pub async fn run(pool: &PgPool, args: SearchArgs<'_>, master_key: Option<&[u8; 3
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Fetch rows with simple equality/tag filters (no pagination). Used by inject/run.
|
||||
pub async fn fetch_rows(
|
||||
pool: &PgPool,
|
||||
namespace: Option<&str>,
|
||||
kind: Option<&str>,
|
||||
name: Option<&str>,
|
||||
tags: &[String],
|
||||
query: Option<&str>,
|
||||
) -> Result<Vec<Secret>> {
|
||||
fetch_rows_paged(
|
||||
pool,
|
||||
PagedFetchArgs {
|
||||
namespace,
|
||||
kind,
|
||||
name,
|
||||
tags,
|
||||
query,
|
||||
sort: "name",
|
||||
limit: 200,
|
||||
offset: 0,
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Arguments for the internal paged fetch. Grouped to avoid too-many-arguments lint.
|
||||
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,
|
||||
}
|
||||
|
||||
async fn fetch_rows_paged(pool: &PgPool, a: PagedFetchArgs<'_>) -> Result<Vec<Secret>> {
|
||||
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 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) = 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);
|
||||
|
||||
let rows = q.fetch_all(pool).await?;
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
/// Build a flat `KEY=VALUE` map from a record's metadata and decrypted secrets.
|
||||
/// Variable names: `<PREFIX><NAME>_<FIELD>` (all uppercased, hyphens/dots → underscores).
|
||||
/// If `prefix` is empty, the name segment alone is used as the prefix.
|
||||
pub fn build_env_map(
|
||||
row: &Secret,
|
||||
prefix: &str,
|
||||
master_key: Option<&[u8; 32]>,
|
||||
) -> Result<HashMap<String, String>> {
|
||||
let name_part = row.name.to_uppercase().replace(['-', '.', ' '], "_");
|
||||
let effective_prefix = if prefix.is_empty() {
|
||||
name_part
|
||||
} else {
|
||||
format!(
|
||||
"{}_{}",
|
||||
prefix.to_uppercase().replace(['-', '.', ' '], "_"),
|
||||
name_part
|
||||
)
|
||||
};
|
||||
|
||||
let mut map = HashMap::new();
|
||||
|
||||
if let Some(meta) = row.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));
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(master_key) = master_key
|
||||
&& !row.encrypted.is_empty()
|
||||
{
|
||||
let decrypted = crypto::decrypt_json(master_key, &row.encrypted)?;
|
||||
if let Some(enc) = decrypted.as_object() {
|
||||
for (k, v) in enc {
|
||||
let key = format!(
|
||||
"{}_{}",
|
||||
effective_prefix,
|
||||
k.to_uppercase().replace(['-', '.'], "_")
|
||||
);
|
||||
map.insert(key, json_value_to_env_string(v));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(map)
|
||||
}
|
||||
|
||||
/// Quote a value for safe shell / env output. Wraps in single quotes,
|
||||
/// escaping any single quotes within the value.
|
||||
fn shell_quote(s: &str) -> String {
|
||||
format!("'{}'", s.replace('\'', "'\\''"))
|
||||
}
|
||||
|
||||
/// Convert a JSON value to its string representation suitable for env vars.
|
||||
fn json_value_to_env_string(v: &Value) -> String {
|
||||
match v {
|
||||
Value::String(s) => s.clone(),
|
||||
Value::Null => String::new(),
|
||||
other => other.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Decrypt the encrypted blob for a row. Returns an empty object on empty blobs.
|
||||
/// Returns an error value on decrypt failure (so callers can decide how to handle).
|
||||
fn try_decrypt(row: &Secret, master_key: Option<&[u8; 32]>) -> Result<Value> {
|
||||
if row.encrypted.is_empty() {
|
||||
return Ok(Value::Object(Default::default()));
|
||||
@@ -186,7 +325,7 @@ fn to_json(
|
||||
Err(e) => json!({"_error": e.to_string()}),
|
||||
}
|
||||
} else {
|
||||
json!({"_encrypted": true, "_key_count": encrypted_key_count(row, master_key)})
|
||||
json!({"_encrypted": true})
|
||||
};
|
||||
|
||||
json!({
|
||||
@@ -197,23 +336,12 @@ fn to_json(
|
||||
"tags": row.tags,
|
||||
"metadata": row.metadata,
|
||||
"secrets": secrets_val,
|
||||
"version": row.version,
|
||||
"created_at": row.created_at.format("%Y-%m-%dT%H:%M:%SZ").to_string(),
|
||||
"updated_at": row.updated_at.format("%Y-%m-%dT%H:%M:%SZ").to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Return the number of keys in the encrypted JSON (decrypts to count; 0 on failure).
|
||||
fn encrypted_key_count(row: &Secret, master_key: Option<&[u8; 32]>) -> usize {
|
||||
if row.encrypted.is_empty() {
|
||||
return 0;
|
||||
}
|
||||
let Some(key) = master_key else { return 0 };
|
||||
match crypto::decrypt_json(key, &row.encrypted) {
|
||||
Ok(Value::Object(m)) => m.len(),
|
||||
_ => 0,
|
||||
}
|
||||
}
|
||||
|
||||
fn print_text(
|
||||
row: &Secret,
|
||||
show_secrets: bool,
|
||||
@@ -266,30 +394,9 @@ fn print_text(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn print_env(row: &Secret, show_secrets: bool, master_key: Option<&[u8; 32]>) -> Result<()> {
|
||||
let prefix = row.name.to_uppercase().replace(['-', '.'], "_");
|
||||
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 decrypted = try_decrypt(row, master_key)?;
|
||||
if let Some(enc) = decrypted.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], master_key: Option<&[u8; 32]>) -> Result<()> {
|
||||
for row in rows {
|
||||
// Decrypt once per row if any field requires it
|
||||
let decrypted: Option<Value> = if fields
|
||||
.iter()
|
||||
.any(|f| f.starts_with("secret") || f.starts_with("encrypted"))
|
||||
|
||||
@@ -5,10 +5,13 @@ use uuid::Uuid;
|
||||
|
||||
use super::add::parse_kv;
|
||||
use crate::crypto;
|
||||
use crate::db;
|
||||
use crate::output::OutputMode;
|
||||
|
||||
#[derive(FromRow)]
|
||||
struct UpdateRow {
|
||||
id: Uuid,
|
||||
version: i64,
|
||||
tags: Vec<String>,
|
||||
metadata: Value,
|
||||
encrypted: Vec<u8>,
|
||||
@@ -24,20 +27,22 @@ pub struct UpdateArgs<'a> {
|
||||
pub remove_meta: &'a [String],
|
||||
pub secret_entries: &'a [String],
|
||||
pub remove_secrets: &'a [String],
|
||||
pub output: OutputMode,
|
||||
}
|
||||
|
||||
pub async fn run(pool: &PgPool, args: UpdateArgs<'_>, master_key: &[u8; 32]) -> Result<()> {
|
||||
let mut tx = pool.begin().await?;
|
||||
|
||||
let row: Option<UpdateRow> = sqlx::query_as(
|
||||
r#"
|
||||
SELECT id, tags, metadata, encrypted
|
||||
FROM secrets
|
||||
WHERE namespace = $1 AND kind = $2 AND name = $3
|
||||
"#,
|
||||
"SELECT id, version, tags, metadata, encrypted \
|
||||
FROM secrets \
|
||||
WHERE namespace = $1 AND kind = $2 AND name = $3 \
|
||||
FOR UPDATE",
|
||||
)
|
||||
.bind(args.namespace)
|
||||
.bind(args.kind)
|
||||
.bind(args.name)
|
||||
.fetch_optional(pool)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
|
||||
let row = row.ok_or_else(|| {
|
||||
@@ -49,6 +54,26 @@ pub async fn run(pool: &PgPool, args: UpdateArgs<'_>, master_key: &[u8; 32]) ->
|
||||
)
|
||||
})?;
|
||||
|
||||
// Snapshot current state before modifying.
|
||||
if let Err(e) = db::snapshot_history(
|
||||
&mut tx,
|
||||
db::SnapshotParams {
|
||||
secret_id: row.id,
|
||||
namespace: args.namespace,
|
||||
kind: args.kind,
|
||||
name: args.name,
|
||||
version: row.version,
|
||||
action: "update",
|
||||
tags: &row.tags,
|
||||
metadata: &row.metadata,
|
||||
encrypted: &row.encrypted,
|
||||
},
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::warn!(error = %e, "failed to snapshot history before update");
|
||||
}
|
||||
|
||||
// Merge tags
|
||||
let mut tags: Vec<String> = row.tags;
|
||||
for t in args.add_tags {
|
||||
@@ -65,7 +90,7 @@ pub async fn run(pool: &PgPool, args: UpdateArgs<'_>, master_key: &[u8; 32]) ->
|
||||
};
|
||||
for entry in args.meta_entries {
|
||||
let (key, value) = parse_kv(entry)?;
|
||||
meta_map.insert(key, Value::String(value));
|
||||
meta_map.insert(key, value);
|
||||
}
|
||||
for key in args.remove_meta {
|
||||
meta_map.remove(key);
|
||||
@@ -84,7 +109,7 @@ pub async fn run(pool: &PgPool, args: UpdateArgs<'_>, master_key: &[u8; 32]) ->
|
||||
};
|
||||
for entry in args.secret_entries {
|
||||
let (key, value) = parse_kv(entry)?;
|
||||
enc_map.insert(key, Value::String(value));
|
||||
enc_map.insert(key, value);
|
||||
}
|
||||
for key in args.remove_secrets {
|
||||
enc_map.remove(key);
|
||||
@@ -99,33 +124,43 @@ pub async fn run(pool: &PgPool, args: UpdateArgs<'_>, master_key: &[u8; 32]) ->
|
||||
"updating record"
|
||||
);
|
||||
|
||||
sqlx::query(
|
||||
r#"
|
||||
UPDATE secrets
|
||||
SET tags = $1, metadata = $2, encrypted = $3, updated_at = NOW()
|
||||
WHERE id = $4
|
||||
"#,
|
||||
// CAS: update only if version hasn't changed (FOR UPDATE lock ensures this).
|
||||
let result = sqlx::query(
|
||||
"UPDATE secrets \
|
||||
SET tags = $1, metadata = $2, encrypted = $3, version = version + 1, updated_at = NOW() \
|
||||
WHERE id = $4 AND version = $5",
|
||||
)
|
||||
.bind(&tags)
|
||||
.bind(metadata)
|
||||
.bind(encrypted_bytes)
|
||||
.bind(&metadata)
|
||||
.bind(&encrypted_bytes)
|
||||
.bind(row.id)
|
||||
.execute(pool)
|
||||
.bind(row.version)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
if result.rows_affected() == 0 {
|
||||
tx.rollback().await?;
|
||||
anyhow::bail!(
|
||||
"Concurrent modification detected for [{}/{}] {}. Please retry.",
|
||||
args.namespace,
|
||||
args.kind,
|
||||
args.name
|
||||
);
|
||||
}
|
||||
|
||||
let meta_keys: Vec<&str> = args
|
||||
.meta_entries
|
||||
.iter()
|
||||
.filter_map(|s| s.split_once('=').map(|(k, _)| k))
|
||||
.filter_map(|s| s.split_once(['=', ':']).map(|(k, _)| k))
|
||||
.collect();
|
||||
let secret_keys: Vec<&str> = args
|
||||
.secret_entries
|
||||
.iter()
|
||||
.filter_map(|s| s.split_once('=').map(|(k, _)| k))
|
||||
.filter_map(|s| s.split_once(['=', ':']).map(|(k, _)| k))
|
||||
.collect();
|
||||
|
||||
crate::audit::log(
|
||||
pool,
|
||||
crate::audit::log_tx(
|
||||
&mut tx,
|
||||
"update",
|
||||
args.namespace,
|
||||
args.kind,
|
||||
@@ -141,35 +176,49 @@ pub async fn run(pool: &PgPool, args: UpdateArgs<'_>, master_key: &[u8; 32]) ->
|
||||
)
|
||||
.await;
|
||||
|
||||
println!("Updated: [{}/{}] {}", args.namespace, args.kind, args.name);
|
||||
tx.commit().await?;
|
||||
|
||||
if !args.add_tags.is_empty() {
|
||||
println!(" +tags: {}", args.add_tags.join(", "));
|
||||
}
|
||||
if !args.remove_tags.is_empty() {
|
||||
println!(" -tags: {}", args.remove_tags.join(", "));
|
||||
}
|
||||
if !args.meta_entries.is_empty() {
|
||||
let keys: Vec<&str> = args
|
||||
.meta_entries
|
||||
.iter()
|
||||
.filter_map(|s| s.split_once('=').map(|(k, _)| k))
|
||||
.collect();
|
||||
println!(" +metadata: {}", keys.join(", "));
|
||||
}
|
||||
if !args.remove_meta.is_empty() {
|
||||
println!(" -metadata: {}", args.remove_meta.join(", "));
|
||||
}
|
||||
if !args.secret_entries.is_empty() {
|
||||
let keys: Vec<&str> = args
|
||||
.secret_entries
|
||||
.iter()
|
||||
.filter_map(|s| s.split_once('=').map(|(k, _)| k))
|
||||
.collect();
|
||||
println!(" +secrets: {}", keys.join(", "));
|
||||
}
|
||||
if !args.remove_secrets.is_empty() {
|
||||
println!(" -secrets: {}", args.remove_secrets.join(", "));
|
||||
let result_json = json!({
|
||||
"action": "updated",
|
||||
"namespace": args.namespace,
|
||||
"kind": args.kind,
|
||||
"name": args.name,
|
||||
"add_tags": args.add_tags,
|
||||
"remove_tags": args.remove_tags,
|
||||
"meta_keys": meta_keys,
|
||||
"remove_meta": args.remove_meta,
|
||||
"secret_keys": secret_keys,
|
||||
"remove_secrets": args.remove_secrets,
|
||||
});
|
||||
|
||||
match args.output {
|
||||
OutputMode::Json => {
|
||||
println!("{}", serde_json::to_string_pretty(&result_json)?);
|
||||
}
|
||||
OutputMode::JsonCompact => {
|
||||
println!("{}", serde_json::to_string(&result_json)?);
|
||||
}
|
||||
_ => {
|
||||
println!("Updated: [{}/{}] {}", args.namespace, args.kind, args.name);
|
||||
if !args.add_tags.is_empty() {
|
||||
println!(" +tags: {}", args.add_tags.join(", "));
|
||||
}
|
||||
if !args.remove_tags.is_empty() {
|
||||
println!(" -tags: {}", args.remove_tags.join(", "));
|
||||
}
|
||||
if !args.meta_entries.is_empty() {
|
||||
println!(" +metadata: {}", meta_keys.join(", "));
|
||||
}
|
||||
if !args.remove_meta.is_empty() {
|
||||
println!(" -metadata: {}", args.remove_meta.join(", "));
|
||||
}
|
||||
if !args.secret_entries.is_empty() {
|
||||
println!(" +secrets: {}", secret_keys.join(", "));
|
||||
}
|
||||
if !args.remove_secrets.is_empty() {
|
||||
println!(" -secrets: {}", args.remove_secrets.join(", "));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
|
||||
@@ -10,7 +10,8 @@ pub struct Config {
|
||||
|
||||
pub fn config_dir() -> PathBuf {
|
||||
dirs::config_dir()
|
||||
.unwrap_or_else(|| PathBuf::from("~/.config"))
|
||||
.or_else(|| dirs::home_dir().map(|h| h.join(".config")))
|
||||
.unwrap_or_else(|| PathBuf::from(".config"))
|
||||
.join("secrets")
|
||||
}
|
||||
|
||||
@@ -24,36 +25,38 @@ pub fn load_config() -> Result<Config> {
|
||||
return Ok(Config::default());
|
||||
}
|
||||
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)
|
||||
.with_context(|| format!("解析配置文件失败: {}", path.display()))?;
|
||||
.with_context(|| format!("failed to parse config file: {}", path.display()))?;
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
pub fn save_config(config: &Config) -> Result<()> {
|
||||
let dir = config_dir();
|
||||
fs::create_dir_all(&dir).with_context(|| format!("创建配置目录失败: {}", dir.display()))?;
|
||||
fs::create_dir_all(&dir)
|
||||
.with_context(|| format!("failed to create config dir: {}", dir.display()))?;
|
||||
|
||||
let path = config_path();
|
||||
let content = toml::to_string_pretty(config).context("序列化配置失败")?;
|
||||
fs::write(&path, &content).with_context(|| format!("写入配置文件失败: {}", path.display()))?;
|
||||
let content = toml::to_string_pretty(config).context("failed to serialize config")?;
|
||||
fs::write(&path, &content)
|
||||
.with_context(|| format!("failed to write config file: {}", path.display()))?;
|
||||
|
||||
// 设置文件权限为 0600(仅所有者读写)
|
||||
// Set file permissions to 0600 (owner read/write only)
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let perms = fs::Permissions::from_mode(0o600);
|
||||
fs::set_permissions(&path, perms)
|
||||
.with_context(|| format!("设置文件权限失败: {}", path.display()))?;
|
||||
.with_context(|| format!("failed to set file permissions: {}", path.display()))?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 按优先级解析数据库连接串:
|
||||
/// 1. --db-url CLI 参数(非空时使用)
|
||||
/// 2. ~/.config/secrets/config.toml 中的 database_url
|
||||
/// 3. 报错并提示用户配置
|
||||
/// Resolve database URL by priority:
|
||||
/// 1. --db-url CLI flag (if non-empty)
|
||||
/// 2. database_url in ~/.config/secrets/config.toml
|
||||
/// 3. Error with setup instructions
|
||||
pub fn resolve_db_url(cli_db_url: &str) -> Result<String> {
|
||||
if !cli_db_url.is_empty() {
|
||||
return Ok(cli_db_url.to_string());
|
||||
@@ -66,5 +69,5 @@ pub fn resolve_db_url(cli_db_url: &str) -> Result<String> {
|
||||
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")
|
||||
}
|
||||
|
||||
71
src/db.rs
71
src/db.rs
@@ -6,6 +6,7 @@ pub async fn create_pool(database_url: &str) -> Result<PgPool> {
|
||||
tracing::debug!("connecting to database");
|
||||
let pool = PgPoolOptions::new()
|
||||
.max_connections(5)
|
||||
.acquire_timeout(std::time::Duration::from_secs(10))
|
||||
.connect(database_url)
|
||||
.await?;
|
||||
tracing::debug!("database connection established");
|
||||
@@ -24,6 +25,7 @@ pub async fn migrate(pool: &PgPool) -> Result<()> {
|
||||
tags TEXT[] NOT NULL DEFAULT '{}',
|
||||
metadata JSONB NOT NULL DEFAULT '{}',
|
||||
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(namespace, kind, name)
|
||||
@@ -35,10 +37,14 @@ pub async fn migrate(pool: &PgPool) -> Result<()> {
|
||||
EXCEPTION WHEN OTHERS THEN NULL;
|
||||
END $$;
|
||||
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE secrets ADD COLUMN IF NOT EXISTS version BIGINT NOT NULL DEFAULT 1;
|
||||
EXCEPTION WHEN OTHERS THEN NULL;
|
||||
END $$;
|
||||
|
||||
-- Migrate encrypted column from JSONB to BYTEA if still JSONB type.
|
||||
-- After migration, old plaintext rows will have their JSONB data
|
||||
-- stored as raw bytes (not yet re-encrypted); run `secrets migrate-encrypt`
|
||||
-- to encrypt them with the master key.
|
||||
-- stored as raw bytes (UTF-8 encoded).
|
||||
DO $$ BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
@@ -79,6 +85,26 @@ pub async fn migrate(pool: &PgPool) -> Result<()> {
|
||||
|
||||
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);
|
||||
|
||||
-- History table: snapshot of secrets before each write operation.
|
||||
-- Supports rollback to any prior version via `secrets rollback`.
|
||||
CREATE TABLE IF NOT EXISTS secrets_history (
|
||||
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
secret_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 '{}',
|
||||
encrypted BYTEA NOT NULL DEFAULT '\x',
|
||||
actor VARCHAR(128) NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_history_secret_id ON secrets_history(secret_id, version DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_history_ns_kind_name ON secrets_history(namespace, kind, name, version DESC);
|
||||
"#,
|
||||
)
|
||||
.execute(pool)
|
||||
@@ -87,6 +113,47 @@ pub async fn migrate(pool: &PgPool) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Snapshot parameters grouped to avoid too-many-arguments lint.
|
||||
pub struct SnapshotParams<'a> {
|
||||
pub secret_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 serde_json::Value,
|
||||
pub encrypted: &'a [u8],
|
||||
}
|
||||
|
||||
/// Snapshot a secrets row into `secrets_history` before a write operation.
|
||||
/// `action` is one of "add", "update", "delete".
|
||||
/// Failures are non-fatal (caller should warn).
|
||||
pub async fn snapshot_history(
|
||||
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||
p: SnapshotParams<'_>,
|
||||
) -> Result<()> {
|
||||
let actor = std::env::var("USER").unwrap_or_default();
|
||||
sqlx::query(
|
||||
"INSERT INTO secrets_history \
|
||||
(secret_id, namespace, kind, name, version, action, tags, metadata, encrypted, actor) \
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)",
|
||||
)
|
||||
.bind(p.secret_id)
|
||||
.bind(p.namespace)
|
||||
.bind(p.kind)
|
||||
.bind(p.name)
|
||||
.bind(p.version)
|
||||
.bind(p.action)
|
||||
.bind(p.tags)
|
||||
.bind(p.metadata)
|
||||
.bind(p.encrypted)
|
||||
.bind(&actor)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Load the Argon2id salt from the database.
|
||||
/// Returns None if not yet initialized.
|
||||
pub async fn load_argon2_salt(pool: &PgPool) -> Result<Option<Vec<u8>>> {
|
||||
|
||||
294
src/main.rs
294
src/main.rs
@@ -18,7 +18,10 @@ use output::resolve_output_mode;
|
||||
version,
|
||||
about = "Secrets & config manager backed by PostgreSQL — optimised for AI agents",
|
||||
after_help = "QUICK START:
|
||||
# First time setup (run once per device)
|
||||
# 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
|
||||
@@ -52,7 +55,12 @@ enum Commands {
|
||||
///
|
||||
/// Prompts for a master password, derives a key with Argon2id, and stores
|
||||
/// it in the OS Keychain. Use the same password on every device.
|
||||
#[command(after_help = "EXAMPLES:
|
||||
///
|
||||
/// 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
|
||||
|
||||
@@ -60,12 +68,6 @@ enum Commands {
|
||||
secrets init")]
|
||||
Init,
|
||||
|
||||
/// Encrypt any pre-existing plaintext records in the database.
|
||||
///
|
||||
/// Run this once after upgrading from a version that stored secrets as
|
||||
/// plaintext JSONB. Requires `secrets init` to have been run first.
|
||||
MigrateEncrypt,
|
||||
|
||||
/// Add or update a record (upsert). Use -m for plaintext metadata, -s for secrets.
|
||||
#[command(after_help = "EXAMPLES:
|
||||
# Add a server
|
||||
@@ -161,9 +163,9 @@ enum Commands {
|
||||
/// Exact name filter, e.g. gitea, i-uf63f2uookgs5uxmrdyc
|
||||
#[arg(long)]
|
||||
name: Option<String>,
|
||||
/// Filter by tag, e.g. --tag aliyun
|
||||
/// Filter by tag, e.g. --tag aliyun (repeatable for AND intersection)
|
||||
#[arg(long)]
|
||||
tag: Option<String>,
|
||||
tag: Vec<String>,
|
||||
/// Fuzzy keyword (matches name, namespace, kind, tags, metadata text)
|
||||
#[arg(short, long)]
|
||||
query: Option<String>,
|
||||
@@ -207,6 +209,9 @@ enum Commands {
|
||||
/// Exact name of the record to delete
|
||||
#[arg(long)]
|
||||
name: String,
|
||||
/// 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).
|
||||
@@ -258,6 +263,9 @@ enum Commands {
|
||||
/// Delete a secret field by key (repeatable)
|
||||
#[arg(long = "remove-secret")]
|
||||
remove_secrets: Vec<String>,
|
||||
/// Output format: text (default on TTY), json, json-compact
|
||||
#[arg(short, long = "output")]
|
||||
output: Option<String>,
|
||||
},
|
||||
|
||||
/// Manage CLI configuration (database connection, etc.)
|
||||
@@ -274,6 +282,112 @@ enum Commands {
|
||||
#[command(subcommand)]
|
||||
action: ConfigAction,
|
||||
},
|
||||
|
||||
/// Show the change history for a record.
|
||||
#[command(after_help = "EXAMPLES:
|
||||
# Show last 20 versions for a service record
|
||||
secrets history -n refining --kind service --name gitea
|
||||
|
||||
# Show last 5 versions
|
||||
secrets history -n refining --kind service --name gitea --limit 5")]
|
||||
History {
|
||||
#[arg(short, long)]
|
||||
namespace: String,
|
||||
#[arg(long)]
|
||||
kind: String,
|
||||
#[arg(long)]
|
||||
name: String,
|
||||
/// Number of history entries to show [default: 20]
|
||||
#[arg(long, default_value = "20")]
|
||||
limit: u32,
|
||||
/// Output format: text (default on TTY), json, json-compact
|
||||
#[arg(short, long = "output")]
|
||||
output: Option<String>,
|
||||
},
|
||||
|
||||
/// Roll back a record to a previous version.
|
||||
#[command(after_help = "EXAMPLES:
|
||||
# Roll back to the most recent snapshot (undo last change)
|
||||
secrets rollback -n refining --kind service --name gitea
|
||||
|
||||
# Roll back to a specific version number
|
||||
secrets rollback -n refining --kind service --name gitea --to-version 3")]
|
||||
Rollback {
|
||||
#[arg(short, long)]
|
||||
namespace: String,
|
||||
#[arg(long)]
|
||||
kind: String,
|
||||
#[arg(long)]
|
||||
name: String,
|
||||
/// Target version to restore. Omit to restore the most recent snapshot.
|
||||
#[arg(long)]
|
||||
to_version: Option<i64>,
|
||||
/// Output format: text (default on TTY), json, json-compact
|
||||
#[arg(short, long = "output")]
|
||||
output: Option<String>,
|
||||
},
|
||||
|
||||
/// 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.
|
||||
#[command(after_help = "EXAMPLES:
|
||||
# Run a script with a single service's secrets injected
|
||||
secrets run -n refining --kind service --name gitea -- ./deploy.sh
|
||||
|
||||
# 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")]
|
||||
Run {
|
||||
#[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,
|
||||
/// Command and arguments to execute with injected environment
|
||||
#[arg(last = true, required = true)]
|
||||
command: Vec<String>,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
@@ -304,15 +418,8 @@ async fn main() -> Result<()> {
|
||||
.init();
|
||||
|
||||
// config subcommand needs no database or master key
|
||||
if let Commands::Config { action } = &cli.command {
|
||||
let cmd_action = match action {
|
||||
ConfigAction::SetDb { url } => {
|
||||
commands::config::ConfigAction::SetDb { url: url.clone() }
|
||||
}
|
||||
ConfigAction::Show => commands::config::ConfigAction::Show,
|
||||
ConfigAction::Path => commands::config::ConfigAction::Path,
|
||||
};
|
||||
return commands::config::run(cmd_action).await;
|
||||
if let Commands::Config { action } = cli.command {
|
||||
return commands::config::run(action).await;
|
||||
}
|
||||
|
||||
let db_url = config::resolve_db_url(&cli.db_url)?;
|
||||
@@ -320,20 +427,16 @@ async fn main() -> Result<()> {
|
||||
db::migrate(&pool).await?;
|
||||
|
||||
// init needs a pool but sets up the master key — handle before loading it
|
||||
if let Commands::Init = &cli.command {
|
||||
if let Commands::Init = cli.command {
|
||||
return commands::init::run(&pool).await;
|
||||
}
|
||||
|
||||
// All remaining commands require the master key from the OS Keychain
|
||||
let master_key = crypto::load_master_key()?;
|
||||
// All remaining commands require the master key from the OS Keychain,
|
||||
// except delete which operates on plaintext metadata only.
|
||||
|
||||
match &cli.command {
|
||||
match cli.command {
|
||||
Commands::Init | Commands::Config { .. } => unreachable!(),
|
||||
|
||||
Commands::MigrateEncrypt => {
|
||||
commands::migrate_encrypt::run(&pool, &master_key).await?;
|
||||
}
|
||||
|
||||
Commands::Add {
|
||||
namespace,
|
||||
kind,
|
||||
@@ -343,18 +446,19 @@ async fn main() -> Result<()> {
|
||||
secrets,
|
||||
output,
|
||||
} => {
|
||||
let master_key = crypto::load_master_key()?;
|
||||
let _span =
|
||||
tracing::info_span!("cmd", command = "add", %namespace, %kind, %name).entered();
|
||||
let out = resolve_output_mode(output.as_deref())?;
|
||||
commands::add::run(
|
||||
&pool,
|
||||
commands::add::AddArgs {
|
||||
namespace,
|
||||
kind,
|
||||
name,
|
||||
tags,
|
||||
meta_entries: meta,
|
||||
secret_entries: secrets,
|
||||
namespace: &namespace,
|
||||
kind: &kind,
|
||||
name: &name,
|
||||
tags: &tags,
|
||||
meta_entries: &meta,
|
||||
secret_entries: &secrets,
|
||||
output: out,
|
||||
},
|
||||
&master_key,
|
||||
@@ -376,8 +480,9 @@ async fn main() -> Result<()> {
|
||||
sort,
|
||||
output,
|
||||
} => {
|
||||
let master_key = crypto::load_master_key()?;
|
||||
let _span = tracing::info_span!("cmd", command = "search").entered();
|
||||
let show = *show_secrets || fields.iter().any(|f| f.starts_with("secret"));
|
||||
let show = show_secrets || fields.iter().any(|f| f.starts_with("secret"));
|
||||
let out = resolve_output_mode(output.as_deref())?;
|
||||
commands::search::run(
|
||||
&pool,
|
||||
@@ -385,14 +490,14 @@ async fn main() -> Result<()> {
|
||||
namespace: namespace.as_deref(),
|
||||
kind: kind.as_deref(),
|
||||
name: name.as_deref(),
|
||||
tag: tag.as_deref(),
|
||||
tags: &tag,
|
||||
query: query.as_deref(),
|
||||
show_secrets: show,
|
||||
fields,
|
||||
summary: *summary,
|
||||
limit: *limit,
|
||||
offset: *offset,
|
||||
sort,
|
||||
fields: &fields,
|
||||
summary,
|
||||
limit,
|
||||
offset,
|
||||
sort: &sort,
|
||||
output: out,
|
||||
},
|
||||
Some(&master_key),
|
||||
@@ -404,10 +509,12 @@ async fn main() -> Result<()> {
|
||||
namespace,
|
||||
kind,
|
||||
name,
|
||||
output,
|
||||
} => {
|
||||
let _span =
|
||||
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, &namespace, &kind, &name, out).await?;
|
||||
}
|
||||
|
||||
Commands::Update {
|
||||
@@ -420,21 +527,108 @@ async fn main() -> Result<()> {
|
||||
remove_meta,
|
||||
secrets,
|
||||
remove_secrets,
|
||||
output,
|
||||
} => {
|
||||
let master_key = crypto::load_master_key()?;
|
||||
let _span =
|
||||
tracing::info_span!("cmd", command = "update", %namespace, %kind, %name).entered();
|
||||
let out = resolve_output_mode(output.as_deref())?;
|
||||
commands::update::run(
|
||||
&pool,
|
||||
commands::update::UpdateArgs {
|
||||
namespace,
|
||||
kind,
|
||||
name,
|
||||
add_tags,
|
||||
remove_tags,
|
||||
meta_entries: meta,
|
||||
remove_meta,
|
||||
secret_entries: secrets,
|
||||
remove_secrets,
|
||||
namespace: &namespace,
|
||||
kind: &kind,
|
||||
name: &name,
|
||||
add_tags: &add_tags,
|
||||
remove_tags: &remove_tags,
|
||||
meta_entries: &meta,
|
||||
remove_meta: &remove_meta,
|
||||
secret_entries: &secrets,
|
||||
remove_secrets: &remove_secrets,
|
||||
output: out,
|
||||
},
|
||||
&master_key,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
Commands::History {
|
||||
namespace,
|
||||
kind,
|
||||
name,
|
||||
limit,
|
||||
output,
|
||||
} => {
|
||||
let out = resolve_output_mode(output.as_deref())?;
|
||||
commands::rollback::list_history(&pool, &namespace, &kind, &name, limit, out).await?;
|
||||
}
|
||||
|
||||
Commands::Rollback {
|
||||
namespace,
|
||||
kind,
|
||||
name,
|
||||
to_version,
|
||||
output,
|
||||
} => {
|
||||
let master_key = crypto::load_master_key()?;
|
||||
let out = resolve_output_mode(output.as_deref())?;
|
||||
commands::rollback::run(
|
||||
&pool,
|
||||
commands::rollback::RollbackArgs {
|
||||
namespace: &namespace,
|
||||
kind: &kind,
|
||||
name: &name,
|
||||
to_version,
|
||||
output: out,
|
||||
},
|
||||
&master_key,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
Commands::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,
|
||||
prefix,
|
||||
command,
|
||||
} => {
|
||||
let master_key = crypto::load_master_key()?;
|
||||
commands::run::run_exec(
|
||||
&pool,
|
||||
commands::run::RunArgs {
|
||||
namespace: namespace.as_deref(),
|
||||
kind: kind.as_deref(),
|
||||
name: name.as_deref(),
|
||||
tags: &tag,
|
||||
prefix: &prefix,
|
||||
command: &command,
|
||||
},
|
||||
&master_key,
|
||||
)
|
||||
|
||||
@@ -14,6 +14,7 @@ pub struct Secret {
|
||||
/// AES-256-GCM ciphertext: nonce(12B) || ciphertext+tag
|
||||
/// Decrypt with crypto::decrypt_json() before use.
|
||||
pub encrypted: Vec<u8>,
|
||||
pub version: i64,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user