Compare commits
2 Commits
secrets-0.
...
secrets-0.
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0a5317e477 | ||
|
|
efa76cae55 |
33
AGENTS.md
33
AGENTS.md
@@ -23,7 +23,7 @@ secrets/
|
||||
audit.rs # 审计写入:log_tx(事务内)/ log(池,保留备用)
|
||||
commands/
|
||||
init.rs # init 命令:主密钥初始化(每台设备一次)
|
||||
add.rs # add 命令:upsert,事务化,含历史快照,支持 key:=json 类型化值
|
||||
add.rs # add 命令:upsert,事务化,含历史快照,支持 key:=json 类型化值与嵌套路径写入
|
||||
config.rs # config 命令:set-db / show / path(持久化 database_url)
|
||||
search.rs # search 命令:多条件查询,公开 fetch_rows / build_env_map
|
||||
delete.rs # delete 命令:事务化,含历史快照
|
||||
@@ -153,7 +153,6 @@ secrets init # 提示输入主密码,Argon2id 派生主密钥后存入 OS
|
||||
- TTY(终端直接运行)→ 默认 `text`
|
||||
- 非 TTY(管道/重定向/AI 调用)→ 自动 `json-compact`
|
||||
- 显式 `-o json` → 美化 JSON
|
||||
- 显式 `-o env` → KEY=VALUE(可 source)
|
||||
|
||||
---
|
||||
|
||||
@@ -182,7 +181,7 @@ secrets init
|
||||
# --limit 20 | 50(默认 50)
|
||||
# --offset 0 | 10 | 20(分页偏移)
|
||||
# --sort name(默认)| updated | created
|
||||
# -o / --output text | json | json-compact | env
|
||||
# -o / --output text | json | json-compact
|
||||
|
||||
# 发现概览(起步推荐)
|
||||
secrets search --summary --limit 20
|
||||
@@ -222,10 +221,6 @@ secrets search -n refining --summary --limit 10 --offset 10
|
||||
|
||||
# 管道 / AI 调用(非 TTY 自动 json-compact)
|
||||
secrets search -n refining --kind service | jq '.[].name'
|
||||
|
||||
# 导出 metadata 为 env 文件(单条记录)
|
||||
secrets search -n refining --kind service --name gitea -o env \
|
||||
> ~/.config/gitea/config.env
|
||||
```
|
||||
|
||||
---
|
||||
@@ -238,8 +233,8 @@ secrets search -n refining --kind service --name gitea -o env \
|
||||
# --kind server | service
|
||||
# --name gitea | i-uf63f2uookgs5uxmrdyc
|
||||
# --tag aliyun | hongkong(可重复)
|
||||
# -m / --meta ip=47.117.131.22 | desc="Aliyun ECS" | url=https://...(可重复)
|
||||
# -s / --secret token=<value> | ssh_key=@./key.pem | password=secret123(可重复)
|
||||
# -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 | credentials:content@./key.pem(可重复)
|
||||
|
||||
# 添加服务器
|
||||
secrets add -n refining --kind server --name i-uf63f2uookgs5uxmrdyc \
|
||||
@@ -258,6 +253,10 @@ secrets add -n ricnsmart --kind service --name mqtt \
|
||||
-m host=mqtt.ricnsmart.com -m port=1883 \
|
||||
-s password=@./mqtt_password.txt
|
||||
|
||||
# 多行文件直接写入嵌套 secret 字段
|
||||
secrets add -n refining --kind server --name i-uf63f2uookgs5uxmrdyc \
|
||||
-s credentials:content@./keys/voson_shanghai_e.pem
|
||||
|
||||
# 使用类型化值(key:=<json>)存储非字符串类型
|
||||
secrets add -n refining --kind service --name prometheus \
|
||||
-m scrape_interval:=15 \
|
||||
@@ -279,10 +278,10 @@ secrets add -n refining --kind service --name prometheus \
|
||||
# --name gitea | i-uf63f2uookgs5uxmrdyc
|
||||
# --add-tag production | backup(不影响已有 tag,可重复)
|
||||
# --remove-tag staging | deprecated(可重复)
|
||||
# -m / --meta ip=10.0.0.1 | desc="新描述"(新增或覆盖,可重复)
|
||||
# --remove-meta old_port | legacy_key(删除 metadata 字段,可重复)
|
||||
# -s / --secret token=<new> | ssh_key=@./new.pem(新增或覆盖,可重复)
|
||||
# --remove-secret old_password | deprecated_key(删除 secret 字段,可重复)
|
||||
# -m / --meta ip=10.0.0.1 | desc="新描述" | credentials:username=root(新增或覆盖,可重复)
|
||||
# --remove-meta old_port | legacy_key | credentials:content(删除 metadata 字段,可重复)
|
||||
# -s / --secret token=<new> | ssh_key=@./new.pem | credentials:content@./new.pem(新增或覆盖,可重复)
|
||||
# --remove-secret old_password | deprecated_key | credentials:content(删除 secret 字段,可重复)
|
||||
|
||||
# 更新单个 metadata 字段
|
||||
secrets update -n refining --kind server --name i-uf63f2uookgs5uxmrdyc \
|
||||
@@ -301,6 +300,14 @@ secrets update -n refining --kind service --name gitea \
|
||||
secrets update -n refining --kind service --name mqtt \
|
||||
--remove-meta old_port --remove-secret old_password
|
||||
|
||||
# 从文件更新嵌套 secret 字段
|
||||
secrets update -n refining --kind server --name i-uf63f2uookgs5uxmrdyc \
|
||||
-s credentials:content@./keys/voson_shanghai_e.pem
|
||||
|
||||
# 删除嵌套字段
|
||||
secrets update -n refining --kind server --name i-uf63f2uookgs5uxmrdyc \
|
||||
--remove-secret credentials:content
|
||||
|
||||
# 移除 tag
|
||||
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]]
|
||||
name = "secrets"
|
||||
version = "0.7.3"
|
||||
version = "0.7.5"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"anyhow",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "secrets"
|
||||
version = "0.7.3"
|
||||
version = "0.7.5"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
|
||||
102
README.md
102
README.md
@@ -76,7 +76,6 @@ secrets run -n refining --kind service --name gitea -- printenv
|
||||
| 场景 | 推荐命令 |
|
||||
|------|----------|
|
||||
| AI 解析 / 管道处理 | `-o json` 或 `-o json-compact` |
|
||||
| 写入 metadata `.env` 文件 | `-o env` |
|
||||
| 注入 secrets 到环境变量 | `inject` / `run` |
|
||||
| 人类查看 | 默认 `text`(TTY 下自动启用) |
|
||||
| 非 TTY(管道/重定向) | 自动 `json-compact` |
|
||||
@@ -87,10 +86,6 @@ secrets run -n refining --kind service --name gitea -- printenv
|
||||
# 管道直接 jq 解析(非 TTY 自动 json-compact)
|
||||
secrets search -n refining --kind service | jq '.[].name'
|
||||
|
||||
# 导出 metadata 为可 source 的 env 文件(单条记录)
|
||||
secrets search -n refining --kind service --name gitea -o env \
|
||||
> ~/.config/gitea/config.env
|
||||
|
||||
# 需要 secrets 时,使用 inject / run
|
||||
secrets inject -n refining --kind service --name gitea > ~/.config/gitea/secrets.env
|
||||
secrets run -n refining --kind service --name gitea -- ./deploy.sh
|
||||
@@ -126,6 +121,17 @@ secrets add -n refining --kind server --name my-server \
|
||||
-m ip=47.117.131.22 -m desc="Aliyun Shanghai ECS" \
|
||||
-s username=root -s ssh_key=@./keys/server.pem
|
||||
|
||||
# 多行文件直接写入嵌套 secret 字段
|
||||
secrets add -n refining --kind server --name my-server \
|
||||
-s credentials:content@./keys/server.pem
|
||||
|
||||
# 使用 typed JSON 写入 secret(布尔、数字、数组、对象)
|
||||
secrets add -n refining --kind service --name deploy-bot \
|
||||
-s enabled:=true \
|
||||
-s retry_count:=3 \
|
||||
-s scopes:='["repo","workflow"]' \
|
||||
-s extra:='{"region":"ap-east-1","verify_tls":true}'
|
||||
|
||||
secrets add -n refining --kind service --name gitea \
|
||||
--tag gitea \
|
||||
-m url=https://gitea.refining.dev -m default_org=refining \
|
||||
@@ -135,6 +141,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 service --name gitea --add-tag production -s token=<new>
|
||||
secrets update -n refining --kind service --name mqtt --remove-meta old_port --remove-secret old_key
|
||||
secrets update -n refining --kind server --name my-server --remove-secret credentials:content
|
||||
|
||||
# ── delete ───────────────────────────────────────────────────────────────────
|
||||
secrets delete -n refining --kind service --name legacy-mqtt
|
||||
@@ -169,7 +176,90 @@ RUST_LOG=secrets=trace secrets search
|
||||
| `metadata` | 明文描述信息(ip、desc、domains 等) |
|
||||
| `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::output::OutputMode;
|
||||
|
||||
/// 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)> {
|
||||
/// Parse secret / metadata entries into a nested key path and JSON value.
|
||||
/// - `key=value` → stores the literal string `value`
|
||||
/// - `key:=<json>` → parses `<json>` as a typed JSON value
|
||||
/// - `key=@file` → reads the file content as a string
|
||||
/// - `a:b=value` → writes nested fields: `{ "a": { "b": "value" } }`
|
||||
/// - `a:b@./file.txt` → shorthand for nested file reads without manual JSON escaping
|
||||
pub(crate) fn parse_kv(entry: &str) -> Result<(Vec<String>, Value)> {
|
||||
// Typed JSON form: key:=<json>
|
||||
if let Some((key, json_str)) = entry.split_once(":=") {
|
||||
let val: Value = serde_json::from_str(json_str).map_err(|e| {
|
||||
@@ -21,36 +23,141 @@ pub(crate) fn parse_kv(entry: &str) -> Result<(String, Value)> {
|
||||
e
|
||||
)
|
||||
})?;
|
||||
return Ok((key.to_string(), val));
|
||||
return Ok((parse_key_path(key)?, 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, key=@file, or key:=<json>",
|
||||
entry
|
||||
)
|
||||
})?;
|
||||
if let Some((key, raw_val)) = entry.split_once('=') {
|
||||
let value = if let Some(path) = raw_val.strip_prefix('@') {
|
||||
fs::read_to_string(path)
|
||||
.map_err(|e| anyhow::anyhow!("Failed to read file '{}': {}", path, e))?
|
||||
} else {
|
||||
raw_val.to_string()
|
||||
};
|
||||
|
||||
let value = if let Some(path) = raw_val.strip_prefix('@') {
|
||||
fs::read_to_string(path)
|
||||
.map_err(|e| anyhow::anyhow!("Failed to read file '{}': {}", path, e))?
|
||||
} else {
|
||||
raw_val.to_string()
|
||||
};
|
||||
return Ok((parse_key_path(key)?, Value::String(value)));
|
||||
}
|
||||
|
||||
Ok((key.to_string(), Value::String(value)))
|
||||
// Shorthand file form: nested:key@file
|
||||
if let Some((key, path)) = entry.split_once('@') {
|
||||
let value = fs::read_to_string(path)
|
||||
.map_err(|e| anyhow::anyhow!("Failed to read file '{}': {}", path, e))?;
|
||||
return Ok((parse_key_path(key)?, Value::String(value)));
|
||||
}
|
||||
|
||||
anyhow::bail!(
|
||||
"Invalid format '{}'. Expected: key=value, key=@file, nested:key@file, or key:=<json>",
|
||||
entry
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn build_json(entries: &[String]) -> Result<Value> {
|
||||
let mut map = Map::new();
|
||||
for entry in entries {
|
||||
let (key, value) = parse_kv(entry)?;
|
||||
map.insert(key, value);
|
||||
let (path, value) = parse_kv(entry)?;
|
||||
insert_path(&mut map, &path, value)?;
|
||||
}
|
||||
Ok(Value::Object(map))
|
||||
}
|
||||
|
||||
pub(crate) fn key_path_to_string(path: &[String]) -> String {
|
||||
path.join(":")
|
||||
}
|
||||
|
||||
pub(crate) fn collect_key_paths(entries: &[String]) -> Result<Vec<String>> {
|
||||
entries
|
||||
.iter()
|
||||
.map(|entry| parse_kv(entry).map(|(path, _)| key_path_to_string(&path)))
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(crate) fn collect_field_paths(entries: &[String]) -> Result<Vec<String>> {
|
||||
entries
|
||||
.iter()
|
||||
.map(|entry| parse_key_path(entry).map(|path| key_path_to_string(&path)))
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(crate) fn parse_key_path(key: &str) -> Result<Vec<String>> {
|
||||
let path: Vec<String> = key
|
||||
.split(':')
|
||||
.map(str::trim)
|
||||
.map(ToOwned::to_owned)
|
||||
.collect();
|
||||
|
||||
if path.is_empty() || path.iter().any(|part| part.is_empty()) {
|
||||
anyhow::bail!(
|
||||
"Invalid key path '{}'. Use non-empty segments like 'credentials:content'.",
|
||||
key
|
||||
);
|
||||
}
|
||||
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
pub(crate) fn insert_path(
|
||||
map: &mut Map<String, Value>,
|
||||
path: &[String],
|
||||
value: Value,
|
||||
) -> Result<()> {
|
||||
if path.is_empty() {
|
||||
anyhow::bail!("Key path cannot be empty");
|
||||
}
|
||||
|
||||
if path.len() == 1 {
|
||||
map.insert(path[0].clone(), value);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let head = path[0].clone();
|
||||
let tail = &path[1..];
|
||||
|
||||
match map.entry(head.clone()) {
|
||||
serde_json::map::Entry::Vacant(entry) => {
|
||||
let mut child = Map::new();
|
||||
insert_path(&mut child, tail, value)?;
|
||||
entry.insert(Value::Object(child));
|
||||
}
|
||||
serde_json::map::Entry::Occupied(mut entry) => match entry.get_mut() {
|
||||
Value::Object(child) => insert_path(child, tail, value)?,
|
||||
_ => {
|
||||
anyhow::bail!(
|
||||
"Cannot set nested key '{}' because '{}' is already a non-object value",
|
||||
key_path_to_string(path),
|
||||
head
|
||||
);
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn remove_path(map: &mut Map<String, Value>, path: &[String]) -> Result<bool> {
|
||||
if path.is_empty() {
|
||||
anyhow::bail!("Key path cannot be empty");
|
||||
}
|
||||
|
||||
if path.len() == 1 {
|
||||
return Ok(map.remove(&path[0]).is_some());
|
||||
}
|
||||
|
||||
let Some(value) = map.get_mut(&path[0]) else {
|
||||
return Ok(false);
|
||||
};
|
||||
|
||||
let Value::Object(child) = value else {
|
||||
return Ok(false);
|
||||
};
|
||||
|
||||
let removed = remove_path(child, &path[1..])?;
|
||||
if child.is_empty() {
|
||||
map.remove(&path[0]);
|
||||
}
|
||||
|
||||
Ok(removed)
|
||||
}
|
||||
|
||||
pub struct AddArgs<'a> {
|
||||
pub namespace: &'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");
|
||||
|
||||
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 meta_keys = collect_key_paths(args.meta_entries)?;
|
||||
let secret_keys = collect_key_paths(args.secret_entries)?;
|
||||
|
||||
let mut tx = pool.begin().await?;
|
||||
|
||||
@@ -191,3 +290,77 @@ pub async fn run(pool: &PgPool, args: AddArgs<'_>, master_key: &[u8; 32]) -> Res
|
||||
|
||||
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" }));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,24 +55,6 @@ pub async fn run(pool: &PgPool, args: SearchArgs<'_>) -> Result<()> {
|
||||
};
|
||||
println!("{}", out);
|
||||
}
|
||||
OutputMode::Env => {
|
||||
if rows.len() > 1 {
|
||||
anyhow::bail!(
|
||||
"env output requires exactly one record; got {}. Add more filters.",
|
||||
rows.len()
|
||||
);
|
||||
}
|
||||
if let Some(row) = rows.first() {
|
||||
let map = build_metadata_env_map(row, "");
|
||||
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.");
|
||||
}
|
||||
}
|
||||
OutputMode::Text => {
|
||||
if rows.is_empty() {
|
||||
println!("No records found.");
|
||||
@@ -302,12 +284,6 @@ pub fn build_injected_env_map(
|
||||
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 {
|
||||
|
||||
@@ -3,7 +3,9 @@ use serde_json::{Map, Value, json};
|
||||
use sqlx::{FromRow, PgPool};
|
||||
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::db;
|
||||
use crate::output::OutputMode;
|
||||
@@ -89,11 +91,12 @@ pub async fn run(pool: &PgPool, args: UpdateArgs<'_>, master_key: &[u8; 32]) ->
|
||||
_ => Map::new(),
|
||||
};
|
||||
for entry in args.meta_entries {
|
||||
let (key, value) = parse_kv(entry)?;
|
||||
meta_map.insert(key, value);
|
||||
let (path, value) = parse_kv(entry)?;
|
||||
insert_path(&mut meta_map, &path, value)?;
|
||||
}
|
||||
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);
|
||||
|
||||
@@ -108,11 +111,12 @@ pub async fn run(pool: &PgPool, args: UpdateArgs<'_>, master_key: &[u8; 32]) ->
|
||||
_ => Map::new(),
|
||||
};
|
||||
for entry in args.secret_entries {
|
||||
let (key, value) = parse_kv(entry)?;
|
||||
enc_map.insert(key, value);
|
||||
let (path, value) = parse_kv(entry)?;
|
||||
insert_path(&mut enc_map, &path, value)?;
|
||||
}
|
||||
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 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
|
||||
.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 meta_keys = collect_key_paths(args.meta_entries)?;
|
||||
let remove_meta_keys = collect_field_paths(args.remove_meta)?;
|
||||
let secret_keys = collect_key_paths(args.secret_entries)?;
|
||||
let remove_secret_keys = collect_field_paths(args.remove_secrets)?;
|
||||
|
||||
crate::audit::log_tx(
|
||||
&mut tx,
|
||||
@@ -169,9 +167,9 @@ pub async fn run(pool: &PgPool, args: UpdateArgs<'_>, master_key: &[u8; 32]) ->
|
||||
"add_tags": args.add_tags,
|
||||
"remove_tags": args.remove_tags,
|
||||
"meta_keys": meta_keys,
|
||||
"remove_meta": args.remove_meta,
|
||||
"remove_meta": remove_meta_keys,
|
||||
"secret_keys": secret_keys,
|
||||
"remove_secrets": args.remove_secrets,
|
||||
"remove_secrets": remove_secret_keys,
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
@@ -186,9 +184,9 @@ pub async fn run(pool: &PgPool, args: UpdateArgs<'_>, master_key: &[u8; 32]) ->
|
||||
"add_tags": args.add_tags,
|
||||
"remove_tags": args.remove_tags,
|
||||
"meta_keys": meta_keys,
|
||||
"remove_meta": args.remove_meta,
|
||||
"remove_meta": remove_meta_keys,
|
||||
"secret_keys": secret_keys,
|
||||
"remove_secrets": args.remove_secrets,
|
||||
"remove_secrets": remove_secret_keys,
|
||||
});
|
||||
|
||||
match args.output {
|
||||
@@ -210,13 +208,13 @@ pub async fn run(pool: &PgPool, args: UpdateArgs<'_>, master_key: &[u8; 32]) ->
|
||||
println!(" +metadata: {}", meta_keys.join(", "));
|
||||
}
|
||||
if !args.remove_meta.is_empty() {
|
||||
println!(" -metadata: {}", args.remove_meta.join(", "));
|
||||
println!(" -metadata: {}", remove_meta_keys.join(", "));
|
||||
}
|
||||
if !args.secret_entries.is_empty() {
|
||||
println!(" +secrets: {}", secret_keys.join(", "));
|
||||
}
|
||||
if !args.remove_secrets.is_empty() {
|
||||
println!(" -secrets: {}", args.remove_secrets.join(", "));
|
||||
println!(" -secrets: {}", remove_secret_keys.join(", "));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
61
src/main.rs
61
src/main.rs
@@ -85,10 +85,28 @@ EXAMPLES:
|
||||
-m url=https://gitea.refining.dev -m default_org=refining \\
|
||||
-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
|
||||
secrets add -n ricnsmart --kind service --name mqtt \\
|
||||
-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 {
|
||||
/// Namespace, e.g. refining, ricnsmart
|
||||
#[arg(short, long)]
|
||||
@@ -102,13 +120,13 @@ EXAMPLES:
|
||||
/// Tag for categorization (repeatable), e.g. --tag aliyun --tag hongkong
|
||||
#[arg(long = "tag")]
|
||||
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')]
|
||||
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')]
|
||||
secrets: Vec<String>,
|
||||
/// Output format: text (default on TTY), json, json-compact, env
|
||||
/// Output format: text (default on TTY), json, json-compact
|
||||
#[arg(short, long = "output")]
|
||||
output: Option<String>,
|
||||
},
|
||||
@@ -117,7 +135,7 @@ EXAMPLES:
|
||||
///
|
||||
/// Supports fuzzy search (-q), exact lookup (--name), field extraction (-f),
|
||||
/// summary view (--summary), pagination (--limit / --offset), and structured
|
||||
/// output (-o json / json-compact / env). When stdout is not a TTY, output
|
||||
/// output (-o json / json-compact). When stdout is not a TTY, output
|
||||
/// defaults to json-compact automatically.
|
||||
#[command(after_help = "EXAMPLES:
|
||||
# Discover all records (summary, safe default limit)
|
||||
@@ -139,9 +157,6 @@ EXAMPLES:
|
||||
secrets search -n refining --kind service --name gitea \\
|
||||
-f metadata.url -f metadata.default_org
|
||||
|
||||
# Export metadata as env vars (single record only)
|
||||
secrets search -n refining --kind service --name gitea -o env
|
||||
|
||||
# Inject decrypted secrets only when needed
|
||||
secrets inject -n refining --kind service --name gitea
|
||||
secrets run -n refining --kind service --name gitea -- printenv
|
||||
@@ -189,7 +204,7 @@ EXAMPLES:
|
||||
/// Sort order: name (default), updated, created
|
||||
#[arg(long, default_value = "name")]
|
||||
sort: String,
|
||||
/// Output format: text (default on TTY), json, json-compact, env
|
||||
/// Output format: text (default on TTY), json, json-compact
|
||||
#[arg(short, long = "output")]
|
||||
output: Option<String>,
|
||||
},
|
||||
@@ -227,6 +242,11 @@ EXAMPLES:
|
||||
# Rotate a secret token
|
||||
secrets update -n refining --kind service --name gitea -s token=<new-token>
|
||||
|
||||
# Update typed JSON metadata
|
||||
secrets update -n refining --kind service --name gitea \\
|
||||
-m deploy:strategy:='{\"type\":\"rolling\",\"batch\":2}' \\
|
||||
-m runtime:max_open_conns:=20
|
||||
|
||||
# Add a tag and rotate password at the same time
|
||||
secrets update -n refining --kind service --name gitea \\
|
||||
--add-tag production -s token=<new-token>
|
||||
@@ -235,8 +255,21 @@ EXAMPLES:
|
||||
secrets update -n refining --kind service --name mqtt \\
|
||||
--remove-meta old_port --remove-secret old_password
|
||||
|
||||
# Remove a nested field
|
||||
secrets update -n refining --kind server --name my-server \\
|
||||
--remove-secret credentials:content
|
||||
|
||||
# Remove a tag
|
||||
secrets update -n refining --kind service --name gitea --remove-tag staging")]
|
||||
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 {
|
||||
/// Namespace, e.g. refining, ricnsmart
|
||||
#[arg(short, long)]
|
||||
@@ -253,16 +286,16 @@ EXAMPLES:
|
||||
/// Remove a tag (repeatable)
|
||||
#[arg(long = "remove-tag")]
|
||||
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')]
|
||||
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")]
|
||||
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')]
|
||||
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")]
|
||||
remove_secrets: Vec<String>,
|
||||
/// Output format: text (default on TTY), json, json-compact
|
||||
|
||||
@@ -12,8 +12,6 @@ pub enum OutputMode {
|
||||
Json,
|
||||
/// Single-line JSON (default when stdout is NOT a TTY, e.g. piped to jq)
|
||||
JsonCompact,
|
||||
/// KEY=VALUE pairs suitable for `source` or `.env` files
|
||||
Env,
|
||||
}
|
||||
|
||||
impl FromStr for OutputMode {
|
||||
@@ -24,9 +22,8 @@ impl FromStr for OutputMode {
|
||||
"text" => Ok(Self::Text),
|
||||
"json" => Ok(Self::Json),
|
||||
"json-compact" => Ok(Self::JsonCompact),
|
||||
"env" => Ok(Self::Env),
|
||||
other => Err(anyhow::anyhow!(
|
||||
"Unknown output format '{}'. Valid: text, json, json-compact, env",
|
||||
"Unknown output format '{}'. Valid: text, json, json-compact",
|
||||
other
|
||||
)),
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user