feat(add,update): key:=json typed values, nested path for meta/secrets, bump 0.7.4
Some checks failed
Secrets CLI - Build & Release / 质量检查 (fmt / clippy / test) (push) Successful in 1m53s
Secrets CLI - Build & Release / Build (x86_64-unknown-linux-musl) (push) Successful in 1m3s
Secrets CLI - Build & Release / 版本 & Release (push) Successful in 3s
Secrets CLI - Build & Release / Build (macOS aarch64 + x86_64) (push) Successful in 49s
Secrets CLI - Build & Release / 发布草稿 Release (push) Has been cancelled
Secrets CLI - Build & Release / Build (x86_64-pc-windows-msvc) (push) Has been cancelled
Some checks failed
Secrets CLI - Build & Release / 质量检查 (fmt / clippy / test) (push) Successful in 1m53s
Secrets CLI - Build & Release / Build (x86_64-unknown-linux-musl) (push) Successful in 1m3s
Secrets CLI - Build & Release / 版本 & Release (push) Successful in 3s
Secrets CLI - Build & Release / Build (macOS aarch64 + x86_64) (push) Successful in 49s
Secrets CLI - Build & Release / 发布草稿 Release (push) Has been cancelled
Secrets CLI - Build & Release / Build (x86_64-pc-windows-msvc) (push) Has been cancelled
Made-with: Cursor
This commit is contained in:
26
AGENTS.md
26
AGENTS.md
@@ -23,7 +23,7 @@ secrets/
|
|||||||
audit.rs # 审计写入:log_tx(事务内)/ log(池,保留备用)
|
audit.rs # 审计写入:log_tx(事务内)/ log(池,保留备用)
|
||||||
commands/
|
commands/
|
||||||
init.rs # init 命令:主密钥初始化(每台设备一次)
|
init.rs # init 命令:主密钥初始化(每台设备一次)
|
||||||
add.rs # add 命令:upsert,事务化,含历史快照,支持 key:=json 类型化值
|
add.rs # add 命令:upsert,事务化,含历史快照,支持 key:=json 类型化值与嵌套路径写入
|
||||||
config.rs # config 命令:set-db / show / path(持久化 database_url)
|
config.rs # config 命令:set-db / show / path(持久化 database_url)
|
||||||
search.rs # search 命令:多条件查询,公开 fetch_rows / build_env_map
|
search.rs # search 命令:多条件查询,公开 fetch_rows / build_env_map
|
||||||
delete.rs # delete 命令:事务化,含历史快照
|
delete.rs # delete 命令:事务化,含历史快照
|
||||||
@@ -238,8 +238,8 @@ secrets search -n refining --kind service --name gitea -o env \
|
|||||||
# --kind server | service
|
# --kind server | service
|
||||||
# --name gitea | i-uf63f2uookgs5uxmrdyc
|
# --name gitea | i-uf63f2uookgs5uxmrdyc
|
||||||
# --tag aliyun | hongkong(可重复)
|
# --tag aliyun | hongkong(可重复)
|
||||||
# -m / --meta ip=47.117.131.22 | desc="Aliyun ECS" | url=https://...(可重复)
|
# -m / --meta ip=47.117.131.22 | desc="Aliyun ECS" | url=https://... | tls:cert@./cert.pem(可重复)
|
||||||
# -s / --secret token=<value> | ssh_key=@./key.pem | password=secret123(可重复)
|
# -s / --secret token=<value> | ssh_key=@./key.pem | password=secret123 | credentials:content@./key.pem(可重复)
|
||||||
|
|
||||||
# 添加服务器
|
# 添加服务器
|
||||||
secrets add -n refining --kind server --name i-uf63f2uookgs5uxmrdyc \
|
secrets add -n refining --kind server --name i-uf63f2uookgs5uxmrdyc \
|
||||||
@@ -258,6 +258,10 @@ secrets add -n ricnsmart --kind service --name mqtt \
|
|||||||
-m host=mqtt.ricnsmart.com -m port=1883 \
|
-m host=mqtt.ricnsmart.com -m port=1883 \
|
||||||
-s password=@./mqtt_password.txt
|
-s password=@./mqtt_password.txt
|
||||||
|
|
||||||
|
# 多行文件直接写入嵌套 secret 字段
|
||||||
|
secrets add -n refining --kind server --name i-uf63f2uookgs5uxmrdyc \
|
||||||
|
-s credentials:content@./keys/voson_shanghai_e.pem
|
||||||
|
|
||||||
# 使用类型化值(key:=<json>)存储非字符串类型
|
# 使用类型化值(key:=<json>)存储非字符串类型
|
||||||
secrets add -n refining --kind service --name prometheus \
|
secrets add -n refining --kind service --name prometheus \
|
||||||
-m scrape_interval:=15 \
|
-m scrape_interval:=15 \
|
||||||
@@ -279,10 +283,10 @@ secrets add -n refining --kind service --name prometheus \
|
|||||||
# --name gitea | i-uf63f2uookgs5uxmrdyc
|
# --name gitea | i-uf63f2uookgs5uxmrdyc
|
||||||
# --add-tag production | backup(不影响已有 tag,可重复)
|
# --add-tag production | backup(不影响已有 tag,可重复)
|
||||||
# --remove-tag staging | deprecated(可重复)
|
# --remove-tag staging | deprecated(可重复)
|
||||||
# -m / --meta ip=10.0.0.1 | desc="新描述"(新增或覆盖,可重复)
|
# -m / --meta ip=10.0.0.1 | desc="新描述" | credentials:username=root(新增或覆盖,可重复)
|
||||||
# --remove-meta old_port | legacy_key(删除 metadata 字段,可重复)
|
# --remove-meta old_port | legacy_key | credentials:content(删除 metadata 字段,可重复)
|
||||||
# -s / --secret token=<new> | ssh_key=@./new.pem(新增或覆盖,可重复)
|
# -s / --secret token=<new> | ssh_key=@./new.pem | credentials:content@./new.pem(新增或覆盖,可重复)
|
||||||
# --remove-secret old_password | deprecated_key(删除 secret 字段,可重复)
|
# --remove-secret old_password | deprecated_key | credentials:content(删除 secret 字段,可重复)
|
||||||
|
|
||||||
# 更新单个 metadata 字段
|
# 更新单个 metadata 字段
|
||||||
secrets update -n refining --kind server --name i-uf63f2uookgs5uxmrdyc \
|
secrets update -n refining --kind server --name i-uf63f2uookgs5uxmrdyc \
|
||||||
@@ -301,6 +305,14 @@ secrets update -n refining --kind service --name gitea \
|
|||||||
secrets update -n refining --kind service --name mqtt \
|
secrets update -n refining --kind service --name mqtt \
|
||||||
--remove-meta old_port --remove-secret old_password
|
--remove-meta old_port --remove-secret old_password
|
||||||
|
|
||||||
|
# 从文件更新嵌套 secret 字段
|
||||||
|
secrets update -n refining --kind server --name i-uf63f2uookgs5uxmrdyc \
|
||||||
|
-s credentials:content@./keys/voson_shanghai_e.pem
|
||||||
|
|
||||||
|
# 删除嵌套字段
|
||||||
|
secrets update -n refining --kind server --name i-uf63f2uookgs5uxmrdyc \
|
||||||
|
--remove-secret credentials:content
|
||||||
|
|
||||||
# 移除 tag
|
# 移除 tag
|
||||||
secrets update -n refining --kind service --name gitea --remove-tag staging
|
secrets update -n refining --kind service --name gitea --remove-tag staging
|
||||||
```
|
```
|
||||||
|
|||||||
2
Cargo.lock
generated
2
Cargo.lock
generated
@@ -1836,7 +1836,7 @@ checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "secrets"
|
name = "secrets"
|
||||||
version = "0.7.3"
|
version = "0.7.4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes-gcm",
|
"aes-gcm",
|
||||||
"anyhow",
|
"anyhow",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "secrets"
|
name = "secrets"
|
||||||
version = "0.7.3"
|
version = "0.7.4"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
|||||||
97
README.md
97
README.md
@@ -126,6 +126,17 @@ secrets add -n refining --kind server --name my-server \
|
|||||||
-m ip=47.117.131.22 -m desc="Aliyun Shanghai ECS" \
|
-m ip=47.117.131.22 -m desc="Aliyun Shanghai ECS" \
|
||||||
-s username=root -s ssh_key=@./keys/server.pem
|
-s username=root -s ssh_key=@./keys/server.pem
|
||||||
|
|
||||||
|
# 多行文件直接写入嵌套 secret 字段
|
||||||
|
secrets add -n refining --kind server --name my-server \
|
||||||
|
-s credentials:content@./keys/server.pem
|
||||||
|
|
||||||
|
# 使用 typed JSON 写入 secret(布尔、数字、数组、对象)
|
||||||
|
secrets add -n refining --kind service --name deploy-bot \
|
||||||
|
-s enabled:=true \
|
||||||
|
-s retry_count:=3 \
|
||||||
|
-s scopes:='["repo","workflow"]' \
|
||||||
|
-s extra:='{"region":"ap-east-1","verify_tls":true}'
|
||||||
|
|
||||||
secrets add -n refining --kind service --name gitea \
|
secrets add -n refining --kind service --name gitea \
|
||||||
--tag gitea \
|
--tag gitea \
|
||||||
-m url=https://gitea.refining.dev -m default_org=refining \
|
-m url=https://gitea.refining.dev -m default_org=refining \
|
||||||
@@ -135,6 +146,7 @@ secrets add -n refining --kind service --name gitea \
|
|||||||
secrets update -n refining --kind server --name my-server -m ip=10.0.0.1
|
secrets update -n refining --kind server --name my-server -m ip=10.0.0.1
|
||||||
secrets update -n refining --kind service --name gitea --add-tag production -s token=<new>
|
secrets update -n refining --kind service --name gitea --add-tag production -s token=<new>
|
||||||
secrets update -n refining --kind service --name mqtt --remove-meta old_port --remove-secret old_key
|
secrets update -n refining --kind service --name mqtt --remove-meta old_port --remove-secret old_key
|
||||||
|
secrets update -n refining --kind server --name my-server --remove-secret credentials:content
|
||||||
|
|
||||||
# ── delete ───────────────────────────────────────────────────────────────────
|
# ── delete ───────────────────────────────────────────────────────────────────
|
||||||
secrets delete -n refining --kind service --name legacy-mqtt
|
secrets delete -n refining --kind service --name legacy-mqtt
|
||||||
@@ -169,7 +181,90 @@ RUST_LOG=secrets=trace secrets search
|
|||||||
| `metadata` | 明文描述信息(ip、desc、domains 等) |
|
| `metadata` | 明文描述信息(ip、desc、domains 等) |
|
||||||
| `encrypted` | 敏感凭据(ssh_key、password、token 等),AES-256-GCM 加密存储 |
|
| `encrypted` | 敏感凭据(ssh_key、password、token 等),AES-256-GCM 加密存储 |
|
||||||
|
|
||||||
`-m` / `--meta` 写入 `metadata`,`-s` / `--secret` 写入 `encrypted`,`value=@file` 从文件读取内容。加解密使用主密钥(由 `secrets init` 设置)。
|
`-m` / `--meta` 写入 `metadata`,`-s` / `--secret` 写入 `encrypted`。支持 `key=value`、`key=@file`、`key:=<json>`,也支持 `credentials:content@./key.pem` 这种嵌套字段文件写入语法,避免手动转义多行文本;删除时也支持 `--remove-secret credentials:content` 和 `--remove-meta credentials:content`。加解密使用主密钥(由 `secrets init` 设置)。
|
||||||
|
|
||||||
|
### `-m` / `--meta` JSON 语法速查
|
||||||
|
|
||||||
|
`-m` 和 `-s` 走的是同一套解析规则,只是写入位置不同:`-m` 写到明文 `metadata`,适合端口、开关、标签、描述性配置等非敏感信息。
|
||||||
|
|
||||||
|
| 目标值 | 写法示例 | 实际存入 |
|
||||||
|
|------|------|------|
|
||||||
|
| 普通字符串 | `-m url=https://gitea.refining.dev` | `"https://gitea.refining.dev"` |
|
||||||
|
| 文件内容字符串 | `-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 tls:='{"enabled":true,"redirect_http":true}'` | `{"enabled":true,"redirect_http":true}` |
|
||||||
|
| 嵌套路径 + JSON | `-m deploy:strategy:='{"type":"rolling","batch":2}'` | `{"deploy":{"strategy":{"type":"rolling","batch":2}}}` |
|
||||||
|
|
||||||
|
常见规则:
|
||||||
|
|
||||||
|
- `=` 表示按字符串存储。
|
||||||
|
- `:=` 表示按 JSON 解析。
|
||||||
|
- shell 中数组和对象建议整体用单引号包住。
|
||||||
|
- 嵌套字段继续用冒号分隔:`-m runtime:max_open_conns:=20`。
|
||||||
|
|
||||||
|
示例:新增一条带 typed metadata 的记录
|
||||||
|
|
||||||
|
```bash
|
||||||
|
secrets add -n refining --kind service --name gitea \
|
||||||
|
-m url=https://gitea.refining.dev \
|
||||||
|
-m port:=3000 \
|
||||||
|
-m enabled:=true \
|
||||||
|
-m domains:='["gitea.refining.dev","git.refining.dev"]' \
|
||||||
|
-m tls:='{"enabled":true,"redirect_http":true}'
|
||||||
|
```
|
||||||
|
|
||||||
|
示例:更新已有记录中的嵌套 metadata
|
||||||
|
|
||||||
|
```bash
|
||||||
|
secrets update -n refining --kind service --name gitea \
|
||||||
|
-m deploy:strategy:='{"type":"rolling","batch":2}' \
|
||||||
|
-m runtime:max_open_conns:=20
|
||||||
|
```
|
||||||
|
|
||||||
|
### `-s` / `--secret` JSON 语法速查
|
||||||
|
|
||||||
|
当你希望写入的不是普通字符串,而是 `true`、`123`、`null`、数组或对象时,用 `:=`,右侧按 JSON 解析。
|
||||||
|
|
||||||
|
| 目标值 | 写法示例 | 实际存入 |
|
||||||
|
|------|------|------|
|
||||||
|
| 普通字符串 | `-s token=abc123` | `"abc123"` |
|
||||||
|
| 文件内容字符串 | `-s ssh_key=@./id_ed25519` | `"-----BEGIN ..."` |
|
||||||
|
| 布尔值 | `-s enabled:=true` | `true` |
|
||||||
|
| 数字 | `-s retry_count:=3` | `3` |
|
||||||
|
| `null` | `-s deprecated_at:=null` | `null` |
|
||||||
|
| 数组 | `-s scopes:='["repo","workflow"]'` | `["repo","workflow"]` |
|
||||||
|
| 对象 | `-s extra:='{"region":"ap-east-1","verify_tls":true}'` | `{"region":"ap-east-1","verify_tls":true}` |
|
||||||
|
| 嵌套路径 + JSON | `-s auth:policy:='{"mfa":true,"ttl":3600}'` | `{"auth":{"policy":{"mfa":true,"ttl":3600}}}` |
|
||||||
|
|
||||||
|
常见规则:
|
||||||
|
|
||||||
|
- `=` 表示按字符串存储,不做 JSON 解析。
|
||||||
|
- `:=` 表示按 JSON 解析,适合布尔、数字、数组、对象、`null`。
|
||||||
|
- shell 里对象和数组通常要整体加引号,推荐单引号:`-s flags:='["a","b"]'`。
|
||||||
|
- 嵌套字段继续用冒号分隔:`-s credentials:enabled:=true`。
|
||||||
|
- 如果你就是想存一个“JSON 字符串字面量”,可以写成 `-s note:='"hello"'`,但大多数字符串场景直接用 `=` 更直观。
|
||||||
|
|
||||||
|
示例:新增一条同时包含字符串、文件、布尔、数组、对象的记录
|
||||||
|
|
||||||
|
```bash
|
||||||
|
secrets add -n refining --kind service --name deploy-bot \
|
||||||
|
-s token=abc123 \
|
||||||
|
-s ssh_key=@./keys/deploy-bot.pem \
|
||||||
|
-s enabled:=true \
|
||||||
|
-s scopes:='["repo","workflow"]' \
|
||||||
|
-s policy:='{"ttl":3600,"mfa":true}'
|
||||||
|
```
|
||||||
|
|
||||||
|
示例:更新已有记录中的嵌套 JSON 字段
|
||||||
|
|
||||||
|
```bash
|
||||||
|
secrets update -n refining --kind service --name deploy-bot \
|
||||||
|
-s auth:config:='{"issuer":"gitea","rotate":true}' \
|
||||||
|
-s auth:retry:=5
|
||||||
|
```
|
||||||
|
|
||||||
## 审计日志
|
## 审计日志
|
||||||
|
|
||||||
|
|||||||
@@ -7,11 +7,13 @@ use crate::crypto;
|
|||||||
use crate::db;
|
use crate::db;
|
||||||
use crate::output::OutputMode;
|
use crate::output::OutputMode;
|
||||||
|
|
||||||
/// Parse "key=value" or "key:=<json>" entries.
|
/// Parse secret / metadata entries into a nested key path and JSON value.
|
||||||
/// - `key=value` → stores the literal string `value`
|
/// - `key=value` → stores the literal string `value`
|
||||||
/// - `key:=<json>` → parses `<json>` as a typed JSON value (number, bool, null, array, object)
|
/// - `key:=<json>` → parses `<json>` as a typed JSON value
|
||||||
/// - `value=@file` → reads the file content as a string (only for `=` form)
|
/// - `key=@file` → reads the file content as a string
|
||||||
pub(crate) fn parse_kv(entry: &str) -> Result<(String, Value)> {
|
/// - `a:b=value` → writes nested fields: `{ "a": { "b": "value" } }`
|
||||||
|
/// - `a:b@./file.txt` → shorthand for nested file reads without manual JSON escaping
|
||||||
|
pub(crate) fn parse_kv(entry: &str) -> Result<(Vec<String>, Value)> {
|
||||||
// Typed JSON form: key:=<json>
|
// Typed JSON form: key:=<json>
|
||||||
if let Some((key, json_str)) = entry.split_once(":=") {
|
if let Some((key, json_str)) = entry.split_once(":=") {
|
||||||
let val: Value = serde_json::from_str(json_str).map_err(|e| {
|
let val: Value = serde_json::from_str(json_str).map_err(|e| {
|
||||||
@@ -21,17 +23,11 @@ pub(crate) fn parse_kv(entry: &str) -> Result<(String, Value)> {
|
|||||||
e
|
e
|
||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
return Ok((key.to_string(), val));
|
return Ok((parse_key_path(key)?, val));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Plain string form: key=value or key=@file
|
// Plain string form: key=value or key=@file
|
||||||
let (key, raw_val) = entry.split_once('=').ok_or_else(|| {
|
if let Some((key, raw_val)) = entry.split_once('=') {
|
||||||
anyhow::anyhow!(
|
|
||||||
"Invalid format '{}'. Expected: key=value, key=@file, or key:=<json>",
|
|
||||||
entry
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
|
|
||||||
let value = if let Some(path) = raw_val.strip_prefix('@') {
|
let value = if let Some(path) = raw_val.strip_prefix('@') {
|
||||||
fs::read_to_string(path)
|
fs::read_to_string(path)
|
||||||
.map_err(|e| anyhow::anyhow!("Failed to read file '{}': {}", path, e))?
|
.map_err(|e| anyhow::anyhow!("Failed to read file '{}': {}", path, e))?
|
||||||
@@ -39,18 +35,129 @@ pub(crate) fn parse_kv(entry: &str) -> Result<(String, Value)> {
|
|||||||
raw_val.to_string()
|
raw_val.to_string()
|
||||||
};
|
};
|
||||||
|
|
||||||
Ok((key.to_string(), Value::String(value)))
|
return Ok((parse_key_path(key)?, Value::String(value)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shorthand file form: nested:key@file
|
||||||
|
if let Some((key, path)) = entry.split_once('@') {
|
||||||
|
let value = fs::read_to_string(path)
|
||||||
|
.map_err(|e| anyhow::anyhow!("Failed to read file '{}': {}", path, e))?;
|
||||||
|
return Ok((parse_key_path(key)?, Value::String(value)));
|
||||||
|
}
|
||||||
|
|
||||||
|
anyhow::bail!(
|
||||||
|
"Invalid format '{}'. Expected: key=value, key=@file, nested:key@file, or key:=<json>",
|
||||||
|
entry
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn build_json(entries: &[String]) -> Result<Value> {
|
pub(crate) fn build_json(entries: &[String]) -> Result<Value> {
|
||||||
let mut map = Map::new();
|
let mut map = Map::new();
|
||||||
for entry in entries {
|
for entry in entries {
|
||||||
let (key, value) = parse_kv(entry)?;
|
let (path, value) = parse_kv(entry)?;
|
||||||
map.insert(key, value);
|
insert_path(&mut map, &path, value)?;
|
||||||
}
|
}
|
||||||
Ok(Value::Object(map))
|
Ok(Value::Object(map))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn key_path_to_string(path: &[String]) -> String {
|
||||||
|
path.join(":")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn collect_key_paths(entries: &[String]) -> Result<Vec<String>> {
|
||||||
|
entries
|
||||||
|
.iter()
|
||||||
|
.map(|entry| parse_kv(entry).map(|(path, _)| key_path_to_string(&path)))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn collect_field_paths(entries: &[String]) -> Result<Vec<String>> {
|
||||||
|
entries
|
||||||
|
.iter()
|
||||||
|
.map(|entry| parse_key_path(entry).map(|path| key_path_to_string(&path)))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn parse_key_path(key: &str) -> Result<Vec<String>> {
|
||||||
|
let path: Vec<String> = key
|
||||||
|
.split(':')
|
||||||
|
.map(str::trim)
|
||||||
|
.map(ToOwned::to_owned)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
if path.is_empty() || path.iter().any(|part| part.is_empty()) {
|
||||||
|
anyhow::bail!(
|
||||||
|
"Invalid key path '{}'. Use non-empty segments like 'credentials:content'.",
|
||||||
|
key
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn insert_path(
|
||||||
|
map: &mut Map<String, Value>,
|
||||||
|
path: &[String],
|
||||||
|
value: Value,
|
||||||
|
) -> Result<()> {
|
||||||
|
if path.is_empty() {
|
||||||
|
anyhow::bail!("Key path cannot be empty");
|
||||||
|
}
|
||||||
|
|
||||||
|
if path.len() == 1 {
|
||||||
|
map.insert(path[0].clone(), value);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let head = path[0].clone();
|
||||||
|
let tail = &path[1..];
|
||||||
|
|
||||||
|
match map.entry(head.clone()) {
|
||||||
|
serde_json::map::Entry::Vacant(entry) => {
|
||||||
|
let mut child = Map::new();
|
||||||
|
insert_path(&mut child, tail, value)?;
|
||||||
|
entry.insert(Value::Object(child));
|
||||||
|
}
|
||||||
|
serde_json::map::Entry::Occupied(mut entry) => match entry.get_mut() {
|
||||||
|
Value::Object(child) => insert_path(child, tail, value)?,
|
||||||
|
_ => {
|
||||||
|
anyhow::bail!(
|
||||||
|
"Cannot set nested key '{}' because '{}' is already a non-object value",
|
||||||
|
key_path_to_string(path),
|
||||||
|
head
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn remove_path(map: &mut Map<String, Value>, path: &[String]) -> Result<bool> {
|
||||||
|
if path.is_empty() {
|
||||||
|
anyhow::bail!("Key path cannot be empty");
|
||||||
|
}
|
||||||
|
|
||||||
|
if path.len() == 1 {
|
||||||
|
return Ok(map.remove(&path[0]).is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(value) = map.get_mut(&path[0]) else {
|
||||||
|
return Ok(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
let Value::Object(child) = value else {
|
||||||
|
return Ok(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
let removed = remove_path(child, &path[1..])?;
|
||||||
|
if child.is_empty() {
|
||||||
|
map.remove(&path[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(removed)
|
||||||
|
}
|
||||||
|
|
||||||
pub struct AddArgs<'a> {
|
pub struct AddArgs<'a> {
|
||||||
pub namespace: &'a str,
|
pub namespace: &'a str,
|
||||||
pub kind: &'a str,
|
pub kind: &'a str,
|
||||||
@@ -68,16 +175,8 @@ pub async fn run(pool: &PgPool, args: AddArgs<'_>, master_key: &[u8; 32]) -> Res
|
|||||||
|
|
||||||
tracing::debug!(args.namespace, args.kind, args.name, "upserting record");
|
tracing::debug!(args.namespace, args.kind, args.name, "upserting record");
|
||||||
|
|
||||||
let meta_keys: Vec<&str> = args
|
let meta_keys = collect_key_paths(args.meta_entries)?;
|
||||||
.meta_entries
|
let secret_keys = collect_key_paths(args.secret_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?;
|
let mut tx = pool.begin().await?;
|
||||||
|
|
||||||
@@ -191,3 +290,77 @@ pub async fn run(pool: &PgPool, args: AddArgs<'_>, master_key: &[u8; 32]) -> Res
|
|||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::{build_json, key_path_to_string, parse_kv, remove_path};
|
||||||
|
use serde_json::Value;
|
||||||
|
use std::fs;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
|
fn temp_file_path(name: &str) -> PathBuf {
|
||||||
|
let nanos = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.expect("clock should be after unix epoch")
|
||||||
|
.as_nanos();
|
||||||
|
std::env::temp_dir().join(format!("secrets-{name}-{nanos}.txt"))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_nested_file_shorthand() {
|
||||||
|
let path = temp_file_path("ssh-key");
|
||||||
|
fs::write(&path, "line1\nline2\n").expect("should write temp file");
|
||||||
|
|
||||||
|
let entry = format!("credentials:content@{}", path.display());
|
||||||
|
let (path_parts, value) = parse_kv(&entry).expect("should parse nested file shorthand");
|
||||||
|
|
||||||
|
assert_eq!(key_path_to_string(&path_parts), "credentials:content");
|
||||||
|
assert_eq!(value, serde_json::Value::String("line1\nline2\n".into()));
|
||||||
|
|
||||||
|
fs::remove_file(path).expect("should remove temp file");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn build_nested_json_from_mixed_entries() {
|
||||||
|
let payload = vec![
|
||||||
|
"credentials:type=ssh".to_string(),
|
||||||
|
"credentials:enabled:=true".to_string(),
|
||||||
|
"username=root".to_string(),
|
||||||
|
];
|
||||||
|
|
||||||
|
let value = build_json(&payload).expect("should build nested json");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
value,
|
||||||
|
serde_json::json!({
|
||||||
|
"credentials": {
|
||||||
|
"type": "ssh",
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
"username": "root"
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn remove_nested_path_prunes_empty_parents() {
|
||||||
|
let mut value = serde_json::json!({
|
||||||
|
"credentials": {
|
||||||
|
"content": "pem-data"
|
||||||
|
},
|
||||||
|
"username": "root"
|
||||||
|
});
|
||||||
|
|
||||||
|
let map = match &mut value {
|
||||||
|
Value::Object(map) => map,
|
||||||
|
_ => panic!("expected object"),
|
||||||
|
};
|
||||||
|
|
||||||
|
let removed = remove_path(map, &["credentials".to_string(), "content".to_string()])
|
||||||
|
.expect("should remove nested field");
|
||||||
|
|
||||||
|
assert!(removed);
|
||||||
|
assert_eq!(value, serde_json::json!({ "username": "root" }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,7 +3,9 @@ use serde_json::{Map, Value, json};
|
|||||||
use sqlx::{FromRow, PgPool};
|
use sqlx::{FromRow, PgPool};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use super::add::parse_kv;
|
use super::add::{
|
||||||
|
collect_field_paths, collect_key_paths, insert_path, parse_key_path, parse_kv, remove_path,
|
||||||
|
};
|
||||||
use crate::crypto;
|
use crate::crypto;
|
||||||
use crate::db;
|
use crate::db;
|
||||||
use crate::output::OutputMode;
|
use crate::output::OutputMode;
|
||||||
@@ -89,11 +91,12 @@ pub async fn run(pool: &PgPool, args: UpdateArgs<'_>, master_key: &[u8; 32]) ->
|
|||||||
_ => Map::new(),
|
_ => Map::new(),
|
||||||
};
|
};
|
||||||
for entry in args.meta_entries {
|
for entry in args.meta_entries {
|
||||||
let (key, value) = parse_kv(entry)?;
|
let (path, value) = parse_kv(entry)?;
|
||||||
meta_map.insert(key, value);
|
insert_path(&mut meta_map, &path, value)?;
|
||||||
}
|
}
|
||||||
for key in args.remove_meta {
|
for key in args.remove_meta {
|
||||||
meta_map.remove(key);
|
let path = parse_key_path(key)?;
|
||||||
|
remove_path(&mut meta_map, &path)?;
|
||||||
}
|
}
|
||||||
let metadata = Value::Object(meta_map);
|
let metadata = Value::Object(meta_map);
|
||||||
|
|
||||||
@@ -108,11 +111,12 @@ pub async fn run(pool: &PgPool, args: UpdateArgs<'_>, master_key: &[u8; 32]) ->
|
|||||||
_ => Map::new(),
|
_ => Map::new(),
|
||||||
};
|
};
|
||||||
for entry in args.secret_entries {
|
for entry in args.secret_entries {
|
||||||
let (key, value) = parse_kv(entry)?;
|
let (path, value) = parse_kv(entry)?;
|
||||||
enc_map.insert(key, value);
|
insert_path(&mut enc_map, &path, value)?;
|
||||||
}
|
}
|
||||||
for key in args.remove_secrets {
|
for key in args.remove_secrets {
|
||||||
enc_map.remove(key);
|
let path = parse_key_path(key)?;
|
||||||
|
remove_path(&mut enc_map, &path)?;
|
||||||
}
|
}
|
||||||
let secret_json = Value::Object(enc_map);
|
let secret_json = Value::Object(enc_map);
|
||||||
let encrypted_bytes = crypto::encrypt_json(master_key, &secret_json)?;
|
let encrypted_bytes = crypto::encrypt_json(master_key, &secret_json)?;
|
||||||
@@ -148,16 +152,10 @@ pub async fn run(pool: &PgPool, args: UpdateArgs<'_>, master_key: &[u8; 32]) ->
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
let meta_keys: Vec<&str> = args
|
let meta_keys = collect_key_paths(args.meta_entries)?;
|
||||||
.meta_entries
|
let remove_meta_keys = collect_field_paths(args.remove_meta)?;
|
||||||
.iter()
|
let secret_keys = collect_key_paths(args.secret_entries)?;
|
||||||
.filter_map(|s| s.split_once(['=', ':']).map(|(k, _)| k))
|
let remove_secret_keys = collect_field_paths(args.remove_secrets)?;
|
||||||
.collect();
|
|
||||||
let secret_keys: Vec<&str> = args
|
|
||||||
.secret_entries
|
|
||||||
.iter()
|
|
||||||
.filter_map(|s| s.split_once(['=', ':']).map(|(k, _)| k))
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
crate::audit::log_tx(
|
crate::audit::log_tx(
|
||||||
&mut tx,
|
&mut tx,
|
||||||
@@ -169,9 +167,9 @@ pub async fn run(pool: &PgPool, args: UpdateArgs<'_>, master_key: &[u8; 32]) ->
|
|||||||
"add_tags": args.add_tags,
|
"add_tags": args.add_tags,
|
||||||
"remove_tags": args.remove_tags,
|
"remove_tags": args.remove_tags,
|
||||||
"meta_keys": meta_keys,
|
"meta_keys": meta_keys,
|
||||||
"remove_meta": args.remove_meta,
|
"remove_meta": remove_meta_keys,
|
||||||
"secret_keys": secret_keys,
|
"secret_keys": secret_keys,
|
||||||
"remove_secrets": args.remove_secrets,
|
"remove_secrets": remove_secret_keys,
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
@@ -186,9 +184,9 @@ pub async fn run(pool: &PgPool, args: UpdateArgs<'_>, master_key: &[u8; 32]) ->
|
|||||||
"add_tags": args.add_tags,
|
"add_tags": args.add_tags,
|
||||||
"remove_tags": args.remove_tags,
|
"remove_tags": args.remove_tags,
|
||||||
"meta_keys": meta_keys,
|
"meta_keys": meta_keys,
|
||||||
"remove_meta": args.remove_meta,
|
"remove_meta": remove_meta_keys,
|
||||||
"secret_keys": secret_keys,
|
"secret_keys": secret_keys,
|
||||||
"remove_secrets": args.remove_secrets,
|
"remove_secrets": remove_secret_keys,
|
||||||
});
|
});
|
||||||
|
|
||||||
match args.output {
|
match args.output {
|
||||||
@@ -210,13 +208,13 @@ pub async fn run(pool: &PgPool, args: UpdateArgs<'_>, master_key: &[u8; 32]) ->
|
|||||||
println!(" +metadata: {}", meta_keys.join(", "));
|
println!(" +metadata: {}", meta_keys.join(", "));
|
||||||
}
|
}
|
||||||
if !args.remove_meta.is_empty() {
|
if !args.remove_meta.is_empty() {
|
||||||
println!(" -metadata: {}", args.remove_meta.join(", "));
|
println!(" -metadata: {}", remove_meta_keys.join(", "));
|
||||||
}
|
}
|
||||||
if !args.secret_entries.is_empty() {
|
if !args.secret_entries.is_empty() {
|
||||||
println!(" +secrets: {}", secret_keys.join(", "));
|
println!(" +secrets: {}", secret_keys.join(", "));
|
||||||
}
|
}
|
||||||
if !args.remove_secrets.is_empty() {
|
if !args.remove_secrets.is_empty() {
|
||||||
println!(" -secrets: {}", args.remove_secrets.join(", "));
|
println!(" -secrets: {}", remove_secret_keys.join(", "));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
52
src/main.rs
52
src/main.rs
@@ -85,10 +85,28 @@ EXAMPLES:
|
|||||||
-m url=https://gitea.refining.dev -m default_org=refining \\
|
-m url=https://gitea.refining.dev -m default_org=refining \\
|
||||||
-s token=<token>
|
-s token=<token>
|
||||||
|
|
||||||
|
# Add typed JSON metadata
|
||||||
|
secrets add -n refining --kind service --name gitea \\
|
||||||
|
-m port:=3000 \\
|
||||||
|
-m enabled:=true \\
|
||||||
|
-m domains:='[\"gitea.refining.dev\",\"git.refining.dev\"]' \\
|
||||||
|
-m tls:='{\"enabled\":true,\"redirect_http\":true}'
|
||||||
|
|
||||||
# Add with token read from a file
|
# Add with token read from a file
|
||||||
secrets add -n ricnsmart --kind service --name mqtt \\
|
secrets add -n ricnsmart --kind service --name mqtt \\
|
||||||
-m host=mqtt.ricnsmart.com -m port=1883 \\
|
-m host=mqtt.ricnsmart.com -m port=1883 \\
|
||||||
-s password=@./mqtt_password.txt")]
|
-s password=@./mqtt_password.txt
|
||||||
|
|
||||||
|
# Add typed JSON secrets
|
||||||
|
secrets add -n refining --kind service --name deploy-bot \\
|
||||||
|
-s enabled:=true \\
|
||||||
|
-s retry_count:=3 \\
|
||||||
|
-s scopes:='[\"repo\",\"workflow\"]' \\
|
||||||
|
-s extra:='{\"region\":\"ap-east-1\",\"verify_tls\":true}'
|
||||||
|
|
||||||
|
# Write a multiline file into a nested secret field
|
||||||
|
secrets add -n refining --kind server --name my-server \\
|
||||||
|
-s credentials:content@./keys/server.pem")]
|
||||||
Add {
|
Add {
|
||||||
/// Namespace, e.g. refining, ricnsmart
|
/// Namespace, e.g. refining, ricnsmart
|
||||||
#[arg(short, long)]
|
#[arg(short, long)]
|
||||||
@@ -102,10 +120,10 @@ EXAMPLES:
|
|||||||
/// Tag for categorization (repeatable), e.g. --tag aliyun --tag hongkong
|
/// Tag for categorization (repeatable), e.g. --tag aliyun --tag hongkong
|
||||||
#[arg(long = "tag")]
|
#[arg(long = "tag")]
|
||||||
tags: Vec<String>,
|
tags: Vec<String>,
|
||||||
/// Plaintext metadata: key=value (repeatable; value=@file reads from file)
|
/// Plaintext metadata: key=value, key:=<json>, key=@file, or nested:path@file
|
||||||
#[arg(long = "meta", short = 'm')]
|
#[arg(long = "meta", short = 'm')]
|
||||||
meta: Vec<String>,
|
meta: Vec<String>,
|
||||||
/// Secret entry: key=value (repeatable; value=@file reads from file)
|
/// Secret entry: key=value, key:=<json>, key=@file, or nested:path@file
|
||||||
#[arg(long = "secret", short = 's')]
|
#[arg(long = "secret", short = 's')]
|
||||||
secrets: Vec<String>,
|
secrets: Vec<String>,
|
||||||
/// Output format: text (default on TTY), json, json-compact, env
|
/// Output format: text (default on TTY), json, json-compact, env
|
||||||
@@ -227,6 +245,11 @@ EXAMPLES:
|
|||||||
# Rotate a secret token
|
# Rotate a secret token
|
||||||
secrets update -n refining --kind service --name gitea -s token=<new-token>
|
secrets update -n refining --kind service --name gitea -s token=<new-token>
|
||||||
|
|
||||||
|
# Update typed JSON metadata
|
||||||
|
secrets update -n refining --kind service --name gitea \\
|
||||||
|
-m deploy:strategy:='{\"type\":\"rolling\",\"batch\":2}' \\
|
||||||
|
-m runtime:max_open_conns:=20
|
||||||
|
|
||||||
# Add a tag and rotate password at the same time
|
# Add a tag and rotate password at the same time
|
||||||
secrets update -n refining --kind service --name gitea \\
|
secrets update -n refining --kind service --name gitea \\
|
||||||
--add-tag production -s token=<new-token>
|
--add-tag production -s token=<new-token>
|
||||||
@@ -235,8 +258,21 @@ EXAMPLES:
|
|||||||
secrets update -n refining --kind service --name mqtt \\
|
secrets update -n refining --kind service --name mqtt \\
|
||||||
--remove-meta old_port --remove-secret old_password
|
--remove-meta old_port --remove-secret old_password
|
||||||
|
|
||||||
|
# Remove a nested field
|
||||||
|
secrets update -n refining --kind server --name my-server \\
|
||||||
|
--remove-secret credentials:content
|
||||||
|
|
||||||
# Remove a tag
|
# Remove a tag
|
||||||
secrets update -n refining --kind service --name gitea --remove-tag staging")]
|
secrets update -n refining --kind service --name gitea --remove-tag staging
|
||||||
|
|
||||||
|
# Update a nested secret field from a file
|
||||||
|
secrets update -n refining --kind server --name my-server \\
|
||||||
|
-s credentials:content@./keys/server.pem
|
||||||
|
|
||||||
|
# Update nested typed JSON fields
|
||||||
|
secrets update -n refining --kind service --name deploy-bot \\
|
||||||
|
-s auth:config:='{\"issuer\":\"gitea\",\"rotate\":true}' \\
|
||||||
|
-s auth:retry:=5")]
|
||||||
Update {
|
Update {
|
||||||
/// Namespace, e.g. refining, ricnsmart
|
/// Namespace, e.g. refining, ricnsmart
|
||||||
#[arg(short, long)]
|
#[arg(short, long)]
|
||||||
@@ -253,16 +289,16 @@ EXAMPLES:
|
|||||||
/// Remove a tag (repeatable)
|
/// Remove a tag (repeatable)
|
||||||
#[arg(long = "remove-tag")]
|
#[arg(long = "remove-tag")]
|
||||||
remove_tags: Vec<String>,
|
remove_tags: Vec<String>,
|
||||||
/// Set or overwrite a metadata field: key=value (repeatable, @file supported)
|
/// Set or overwrite a metadata field: key=value, key:=<json>, key=@file, or nested:path@file
|
||||||
#[arg(long = "meta", short = 'm')]
|
#[arg(long = "meta", short = 'm')]
|
||||||
meta: Vec<String>,
|
meta: Vec<String>,
|
||||||
/// Delete a metadata field by key (repeatable)
|
/// Delete a metadata field by key or nested path, e.g. old_port or credentials:content
|
||||||
#[arg(long = "remove-meta")]
|
#[arg(long = "remove-meta")]
|
||||||
remove_meta: Vec<String>,
|
remove_meta: Vec<String>,
|
||||||
/// Set or overwrite a secret field: key=value (repeatable, @file supported)
|
/// Set or overwrite a secret field: key=value, key:=<json>, key=@file, or nested:path@file
|
||||||
#[arg(long = "secret", short = 's')]
|
#[arg(long = "secret", short = 's')]
|
||||||
secrets: Vec<String>,
|
secrets: Vec<String>,
|
||||||
/// Delete a secret field by key (repeatable)
|
/// Delete a secret field by key or nested path, e.g. old_password or credentials:content
|
||||||
#[arg(long = "remove-secret")]
|
#[arg(long = "remove-secret")]
|
||||||
remove_secrets: Vec<String>,
|
remove_secrets: Vec<String>,
|
||||||
/// Output format: text (default on TTY), json, json-compact
|
/// Output format: text (default on TTY), json, json-compact
|
||||||
|
|||||||
Reference in New Issue
Block a user