Compare commits
4 Commits
secrets-0.
...
secrets-0.
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0a5317e477 | ||
|
|
efa76cae55 | ||
|
|
5a5867adc1 | ||
|
|
4ddafbe4b6 |
8
.vscode/tasks.json
vendored
8
.vscode/tasks.json
vendored
@@ -104,9 +104,9 @@
|
|||||||
"dependsOn": "build"
|
"dependsOn": "build"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"label": "test: search with secrets revealed",
|
"label": "test: inject service secrets",
|
||||||
"type": "shell",
|
"type": "shell",
|
||||||
"command": "./target/debug/secrets search -n refining --kind service --show-secrets",
|
"command": "./target/debug/secrets inject -n refining --kind service --name gitea",
|
||||||
"dependsOn": "build"
|
"dependsOn": "build"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -118,7 +118,7 @@
|
|||||||
{
|
{
|
||||||
"label": "test: add + delete roundtrip",
|
"label": "test: add + delete roundtrip",
|
||||||
"type": "shell",
|
"type": "shell",
|
||||||
"command": "echo '--- add ---' && ./target/debug/secrets add -n test --kind demo --name roundtrip-test --tag test -m foo=bar -s password=secret123 && echo '--- search ---' && ./target/debug/secrets search -n test --show-secrets && echo '--- delete ---' && ./target/debug/secrets delete -n test --kind demo --name roundtrip-test && echo '--- verify deleted ---' && ./target/debug/secrets search -n test",
|
"command": "echo '--- add ---' && ./target/debug/secrets add -n test --kind demo --name roundtrip-test --tag test -m foo=bar -s password=secret123 && echo '--- search metadata ---' && ./target/debug/secrets search -n test && echo '--- inject secrets ---' && ./target/debug/secrets inject -n test --kind demo --name roundtrip-test && echo '--- delete ---' && ./target/debug/secrets delete -n test --kind demo --name roundtrip-test && echo '--- verify deleted ---' && ./target/debug/secrets search -n test",
|
||||||
"dependsOn": "build"
|
"dependsOn": "build"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -142,7 +142,7 @@
|
|||||||
{
|
{
|
||||||
"label": "test: add with file secret",
|
"label": "test: add with file secret",
|
||||||
"type": "shell",
|
"type": "shell",
|
||||||
"command": "echo '--- add key from file ---' && ./target/debug/secrets add -n test --kind key --name test-key --tag test -s content=@./refining/keys/Vultr && echo '--- verify ---' && ./target/debug/secrets search -n test --kind key --show-secrets && echo '--- cleanup ---' && ./target/debug/secrets delete -n test --kind key --name test-key",
|
"command": "echo '--- add key from file ---' && ./target/debug/secrets add -n test --kind key --name test-key --tag test -s content=@./refining/keys/Vultr && echo '--- verify metadata ---' && ./target/debug/secrets search -n test --kind key && echo '--- verify inject ---' && ./target/debug/secrets inject -n test --kind key --name test-key && echo '--- cleanup ---' && ./target/debug/secrets delete -n test --kind key --name test-key",
|
||||||
"dependsOn": "build"
|
"dependsOn": "build"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
53
AGENTS.md
53
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 命令:事务化,含历史快照
|
||||||
@@ -153,7 +153,6 @@ secrets init # 提示输入主密码,Argon2id 派生主密钥后存入 OS
|
|||||||
- TTY(终端直接运行)→ 默认 `text`
|
- TTY(终端直接运行)→ 默认 `text`
|
||||||
- 非 TTY(管道/重定向/AI 调用)→ 自动 `json-compact`
|
- 非 TTY(管道/重定向/AI 调用)→ 自动 `json-compact`
|
||||||
- 显式 `-o json` → 美化 JSON
|
- 显式 `-o json` → 美化 JSON
|
||||||
- 显式 `-o env` → KEY=VALUE(可 source)
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -176,13 +175,13 @@ secrets init
|
|||||||
# --name gitea | i-uf63f2uookgs5uxmrdyc | mqtt
|
# --name gitea | i-uf63f2uookgs5uxmrdyc | mqtt
|
||||||
# --tag aliyun | hongkong | production
|
# --tag aliyun | hongkong | production
|
||||||
# -q / --query mqtt | grafana | gitea (模糊匹配 name/namespace/kind/tags/metadata)
|
# -q / --query mqtt | grafana | gitea (模糊匹配 name/namespace/kind/tags/metadata)
|
||||||
# --show-secrets 不带值的 flag,显示 encrypted 字段内容
|
# --show-secrets 已弃用;search 不再直接展示 secrets
|
||||||
# -f / --field metadata.ip | metadata.url | secret.token | secret.ssh_key
|
# -f / --field metadata.ip | metadata.url | metadata.default_org
|
||||||
# --summary 不带值的 flag,仅返回摘要(name/tags/desc/updated_at)
|
# --summary 不带值的 flag,仅返回摘要(name/tags/desc/updated_at)
|
||||||
# --limit 20 | 50(默认 50)
|
# --limit 20 | 50(默认 50)
|
||||||
# --offset 0 | 10 | 20(分页偏移)
|
# --offset 0 | 10 | 20(分页偏移)
|
||||||
# --sort name(默认)| updated | created
|
# --sort name(默认)| updated | created
|
||||||
# -o / --output text | json | json-compact | env
|
# -o / --output text | json | json-compact
|
||||||
|
|
||||||
# 发现概览(起步推荐)
|
# 发现概览(起步推荐)
|
||||||
secrets search --summary --limit 20
|
secrets search --summary --limit 20
|
||||||
@@ -193,14 +192,17 @@ secrets search --sort updated --limit 10 --summary
|
|||||||
secrets search -n refining --kind service --name gitea
|
secrets search -n refining --kind service --name gitea
|
||||||
secrets search -n refining --kind server --name i-uf63f2uookgs5uxmrdyc
|
secrets search -n refining --kind server --name i-uf63f2uookgs5uxmrdyc
|
||||||
|
|
||||||
# 精确定位并获取完整内容(含 secrets)
|
# 精确定位并获取完整内容(secrets 保持加密占位)
|
||||||
secrets search -n refining --kind service --name gitea -o json --show-secrets
|
secrets search -n refining --kind service --name gitea -o json
|
||||||
|
|
||||||
# 直接提取字段值(最短路径,-f secret.* 自动解锁 secrets)
|
# 直接提取 metadata 字段值(最短路径)
|
||||||
secrets search -n refining --kind service --name gitea -f secret.token
|
|
||||||
secrets search -n refining --kind service --name gitea -f metadata.url
|
secrets search -n refining --kind service --name gitea -f metadata.url
|
||||||
secrets search -n refining --kind service --name gitea \
|
secrets search -n refining --kind service --name gitea \
|
||||||
-f metadata.url -f metadata.default_org -f secret.token
|
-f metadata.url -f metadata.default_org
|
||||||
|
|
||||||
|
# 需要 secrets 时,改用 inject / run
|
||||||
|
secrets inject -n refining --kind service --name gitea
|
||||||
|
secrets run -n refining --kind service --name gitea -- printenv
|
||||||
|
|
||||||
# 模糊关键词搜索
|
# 模糊关键词搜索
|
||||||
secrets search -q mqtt
|
secrets search -q mqtt
|
||||||
@@ -219,11 +221,6 @@ secrets search -n refining --summary --limit 10 --offset 10
|
|||||||
|
|
||||||
# 管道 / AI 调用(非 TTY 自动 json-compact)
|
# 管道 / AI 调用(非 TTY 自动 json-compact)
|
||||||
secrets search -n refining --kind service | jq '.[].name'
|
secrets search -n refining --kind service | jq '.[].name'
|
||||||
secrets search -n refining --kind service --name gitea --show-secrets | jq '.secrets.token'
|
|
||||||
|
|
||||||
# 导出为 env 文件(单条记录)
|
|
||||||
secrets search -n refining --kind service --name gitea -o env --show-secrets \
|
|
||||||
> ~/.config/gitea/config.env
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -236,8 +233,8 @@ secrets search -n refining --kind service --name gitea -o env --show-secrets \
|
|||||||
# --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 \
|
||||||
@@ -256,6 +253,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 \
|
||||||
@@ -277,10 +278,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 \
|
||||||
@@ -299,6 +300,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
|
||||||
```
|
```
|
||||||
@@ -466,7 +475,7 @@ secrets --db-url "postgres://..." search -n refining
|
|||||||
- 新增 `kind` 类型时:只需在 `add` 调用时传入,无需改代码
|
- 新增 `kind` 类型时:只需在 `add` 调用时传入,无需改代码
|
||||||
- 字段命名:CLI 短标志 `-n`=namespace,`-m`=meta,`-s`=secret,`-q`=query,`-v`=verbose,`-f`=field,`-o`=output
|
- 字段命名:CLI 短标志 `-n`=namespace,`-m`=meta,`-s`=secret,`-q`=query,`-v`=verbose,`-f`=field,`-o`=output
|
||||||
- 日志:用户可见输出用 `println!`;调试/运维信息用 `tracing::debug!`/`info!`/`warn!`/`error!`
|
- 日志:用户可见输出用 `println!`;调试/运维信息用 `tracing::debug!`/`info!`/`warn!`/`error!`
|
||||||
- 审计:`add`/`update`/`delete` 成功后调用 `audit::log()`,写入 `audit_log` 表;失败只 warn 不中断
|
- 审计:`add`/`update`/`delete` 成功后调用 `audit::log_tx`,写入 `audit_log` 表;失败只 warn 不中断
|
||||||
- 加密:`encrypted` 列存储 AES-256-GCM 密文;`add`/`update`/`search`/`delete` 需主密钥(`secrets init` 后从 OS 钥匙串加载)
|
- 加密:`encrypted` 列存储 AES-256-GCM 密文;`add`/`update`/`search`/`delete` 需主密钥(`secrets init` 后从 OS 钥匙串加载)
|
||||||
- 输出:读命令通过 `OutputMode` 支持 text/json/json-compact/env;写命令 `add` 同样支持 `-o json`
|
- 输出:读命令通过 `OutputMode` 支持 text/json/json-compact/env;写命令 `add` 同样支持 `-o json`
|
||||||
|
|
||||||
|
|||||||
2
Cargo.lock
generated
2
Cargo.lock
generated
@@ -1836,7 +1836,7 @@ checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "secrets"
|
name = "secrets"
|
||||||
version = "0.7.1"
|
version = "0.7.5"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes-gcm",
|
"aes-gcm",
|
||||||
"anyhow",
|
"anyhow",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "secrets"
|
name = "secrets"
|
||||||
version = "0.7.1"
|
version = "0.7.5"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
|||||||
129
README.md
129
README.md
@@ -54,37 +54,41 @@ secrets search --sort updated --limit 10 --summary
|
|||||||
# 精确定位(namespace + kind + name 三元组)
|
# 精确定位(namespace + kind + name 三元组)
|
||||||
secrets search -n refining --kind service --name gitea
|
secrets search -n refining --kind service --name gitea
|
||||||
|
|
||||||
# 获取完整记录含 secrets(JSON 格式,AI 最易解析)
|
# 获取完整记录(secrets 保持加密占位)
|
||||||
secrets search -n refining --kind service --name gitea -o json --show-secrets
|
secrets search -n refining --kind service --name gitea -o json
|
||||||
|
|
||||||
# 直接提取单个字段值(最短路径)
|
# 直接提取单个 metadata 字段值(最短路径)
|
||||||
secrets search -n refining --kind service --name gitea -f secret.token
|
|
||||||
secrets search -n refining --kind service --name gitea -f metadata.url
|
secrets search -n refining --kind service --name gitea -f metadata.url
|
||||||
|
|
||||||
# 同时提取多个字段
|
# 同时提取多个 metadata 字段
|
||||||
secrets search -n refining --kind service --name gitea \
|
secrets search -n refining --kind service --name gitea \
|
||||||
-f metadata.url -f metadata.default_org -f secret.token
|
-f metadata.url -f metadata.default_org
|
||||||
|
|
||||||
|
# 需要 secrets 时,改用 inject / run
|
||||||
|
secrets inject -n refining --kind service --name gitea
|
||||||
|
secrets run -n refining --kind service --name gitea -- printenv
|
||||||
```
|
```
|
||||||
|
|
||||||
`-f secret.*` 会自动解锁 secrets,无需额外加 `--show-secrets`。
|
`search` 只负责发现、定位和读取 metadata,不直接展示 secrets。
|
||||||
|
|
||||||
### 输出格式
|
### 输出格式
|
||||||
|
|
||||||
| 场景 | 推荐命令 |
|
| 场景 | 推荐命令 |
|
||||||
|------|----------|
|
|------|----------|
|
||||||
| AI 解析 / 管道处理 | `-o json` 或 `-o json-compact` |
|
| AI 解析 / 管道处理 | `-o json` 或 `-o json-compact` |
|
||||||
| 写入 `.env` 文件 | `-o env --show-secrets` |
|
| 注入 secrets 到环境变量 | `inject` / `run` |
|
||||||
| 人类查看 | 默认 `text`(TTY 下自动启用) |
|
| 人类查看 | 默认 `text`(TTY 下自动启用) |
|
||||||
| 非 TTY(管道/重定向) | 自动 `json-compact` |
|
| 非 TTY(管道/重定向) | 自动 `json-compact` |
|
||||||
|
|
||||||
|
说明:`text` 输出中的时间会按当前机器本地时区显示;`json/json-compact` 继续使用 UTC(RFC3339 风格)以便脚本和 AI 稳定解析。
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 管道直接 jq 解析(非 TTY 自动 json-compact)
|
# 管道直接 jq 解析(非 TTY 自动 json-compact)
|
||||||
secrets search -n refining --kind service | jq '.[].name'
|
secrets search -n refining --kind service | jq '.[].name'
|
||||||
secrets search -n refining --kind service --name gitea --show-secrets | jq '.secrets.token'
|
|
||||||
|
|
||||||
# 导出为可 source 的 env 文件(单条记录)
|
# 需要 secrets 时,使用 inject / run
|
||||||
secrets search -n refining --kind service --name gitea -o env --show-secrets \
|
secrets inject -n refining --kind service --name gitea > ~/.config/gitea/secrets.env
|
||||||
> ~/.config/gitea/config.env
|
secrets run -n refining --kind service --name gitea -- ./deploy.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
## 完整命令参考
|
## 完整命令参考
|
||||||
@@ -106,8 +110,8 @@ secrets search -n refining --kind service # 按 namespace + kin
|
|||||||
secrets search -n refining --kind service --name gitea # 精确查找
|
secrets search -n refining --kind service --name gitea # 精确查找
|
||||||
secrets search -q mqtt # 关键词模糊搜索
|
secrets search -q mqtt # 关键词模糊搜索
|
||||||
secrets search --tag hongkong # 按 tag 过滤
|
secrets search --tag hongkong # 按 tag 过滤
|
||||||
secrets search -n refining --kind service --name gitea -f secret.token # 提取字段
|
secrets search -n refining --kind service --name gitea -f metadata.url # 提取 metadata 字段
|
||||||
secrets search -n refining --kind service --name gitea -o json --show-secrets # 完整 JSON
|
secrets search -n refining --kind service --name gitea -o json # 完整记录(secrets 保持占位)
|
||||||
secrets search --sort updated --limit 10 --summary # 最近改动
|
secrets search --sort updated --limit 10 --summary # 最近改动
|
||||||
secrets search -n refining --summary --limit 10 --offset 10 # 翻页
|
secrets search -n refining --summary --limit 10 --offset 10 # 翻页
|
||||||
|
|
||||||
@@ -117,6 +121,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 \
|
||||||
@@ -126,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 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
|
||||||
@@ -160,7 +176,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
|
||||||
|
```
|
||||||
|
|
||||||
## 审计日志
|
## 审计日志
|
||||||
|
|
||||||
|
|||||||
34
src/audit.rs
34
src/audit.rs
@@ -1,5 +1,5 @@
|
|||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
use sqlx::{PgPool, Postgres, Transaction};
|
use sqlx::{Postgres, Transaction};
|
||||||
|
|
||||||
/// Write an audit entry within an existing transaction.
|
/// Write an audit entry within an existing transaction.
|
||||||
pub async fn log_tx(
|
pub async fn log_tx(
|
||||||
@@ -30,35 +30,3 @@ pub async fn log_tx(
|
|||||||
tracing::debug!(action, namespace, kind, name, actor, "audit logged");
|
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,
|
|
||||||
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(pool)
|
|
||||||
.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");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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,36 +23,141 @@ 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!(
|
let value = if let Some(path) = raw_val.strip_prefix('@') {
|
||||||
"Invalid format '{}'. Expected: key=value, key=@file, or key:=<json>",
|
fs::read_to_string(path)
|
||||||
entry
|
.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('@') {
|
return Ok((parse_key_path(key)?, Value::String(value)));
|
||||||
fs::read_to_string(path)
|
}
|
||||||
.map_err(|e| anyhow::anyhow!("Failed to read file '{}': {}", path, e))?
|
|
||||||
} else {
|
|
||||||
raw_val.to_string()
|
|
||||||
};
|
|
||||||
|
|
||||||
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> {
|
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,17 +3,11 @@ use serde_json::{Value, json};
|
|||||||
use sqlx::{FromRow, PgPool};
|
use sqlx::{FromRow, PgPool};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::output::OutputMode;
|
use crate::output::{OutputMode, format_local_time};
|
||||||
|
|
||||||
#[derive(FromRow)]
|
#[derive(FromRow)]
|
||||||
struct HistoryRow {
|
struct HistoryRow {
|
||||||
secret_id: Uuid,
|
secret_id: Uuid,
|
||||||
#[allow(dead_code)]
|
|
||||||
namespace: String,
|
|
||||||
#[allow(dead_code)]
|
|
||||||
kind: String,
|
|
||||||
#[allow(dead_code)]
|
|
||||||
name: String,
|
|
||||||
version: i64,
|
version: i64,
|
||||||
action: String,
|
action: String,
|
||||||
tags: Vec<String>,
|
tags: Vec<String>,
|
||||||
@@ -33,7 +27,7 @@ pub struct RollbackArgs<'a> {
|
|||||||
pub async fn run(pool: &PgPool, args: RollbackArgs<'_>, master_key: &[u8; 32]) -> Result<()> {
|
pub async fn run(pool: &PgPool, args: RollbackArgs<'_>, master_key: &[u8; 32]) -> Result<()> {
|
||||||
let snap: Option<HistoryRow> = if let Some(ver) = args.to_version {
|
let snap: Option<HistoryRow> = if let Some(ver) = args.to_version {
|
||||||
sqlx::query_as(
|
sqlx::query_as(
|
||||||
"SELECT secret_id, namespace, kind, name, version, action, tags, metadata, encrypted \
|
"SELECT secret_id, version, action, tags, metadata, encrypted \
|
||||||
FROM secrets_history \
|
FROM secrets_history \
|
||||||
WHERE namespace = $1 AND kind = $2 AND name = $3 AND version = $4 \
|
WHERE namespace = $1 AND kind = $2 AND name = $3 AND version = $4 \
|
||||||
ORDER BY id DESC LIMIT 1",
|
ORDER BY id DESC LIMIT 1",
|
||||||
@@ -46,7 +40,7 @@ pub async fn run(pool: &PgPool, args: RollbackArgs<'_>, master_key: &[u8; 32]) -
|
|||||||
.await?
|
.await?
|
||||||
} else {
|
} else {
|
||||||
sqlx::query_as(
|
sqlx::query_as(
|
||||||
"SELECT secret_id, namespace, kind, name, version, action, tags, metadata, encrypted \
|
"SELECT secret_id, version, action, tags, metadata, encrypted \
|
||||||
FROM secrets_history \
|
FROM secrets_history \
|
||||||
WHERE namespace = $1 AND kind = $2 AND name = $3 \
|
WHERE namespace = $1 AND kind = $2 AND name = $3 \
|
||||||
ORDER BY id DESC LIMIT 1",
|
ORDER BY id DESC LIMIT 1",
|
||||||
@@ -234,7 +228,7 @@ pub async fn list_history(
|
|||||||
r.version,
|
r.version,
|
||||||
r.action,
|
r.action,
|
||||||
r.actor,
|
r.actor,
|
||||||
r.created_at.format("%Y-%m-%d %H:%M:%S UTC")
|
format_local_time(r.created_at)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
println!(" (use `secrets rollback --to-version <N>` to restore)");
|
println!(" (use `secrets rollback --to-version <N>` to restore)");
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ use serde_json::Value;
|
|||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|
||||||
use crate::commands::search::build_env_map;
|
use crate::commands::search::build_injected_env_map;
|
||||||
use crate::output::OutputMode;
|
use crate::output::OutputMode;
|
||||||
|
|
||||||
pub struct InjectArgs<'a> {
|
pub struct InjectArgs<'a> {
|
||||||
@@ -48,7 +48,7 @@ pub async fn collect_env_map(
|
|||||||
}
|
}
|
||||||
let mut map = HashMap::new();
|
let mut map = HashMap::new();
|
||||||
for row in &rows {
|
for row in &rows {
|
||||||
let row_map = build_env_map(row, prefix, Some(master_key))?;
|
let row_map = build_injected_env_map(row, prefix, master_key)?;
|
||||||
for (k, v) in row_map {
|
for (k, v) in row_map {
|
||||||
map.insert(k, v);
|
map.insert(k, v);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ use std::collections::HashMap;
|
|||||||
|
|
||||||
use crate::crypto;
|
use crate::crypto;
|
||||||
use crate::models::Secret;
|
use crate::models::Secret;
|
||||||
use crate::output::OutputMode;
|
use crate::output::{OutputMode, format_local_time};
|
||||||
|
|
||||||
pub struct SearchArgs<'a> {
|
pub struct SearchArgs<'a> {
|
||||||
pub namespace: Option<&'a str>,
|
pub namespace: Option<&'a str>,
|
||||||
@@ -22,7 +22,9 @@ pub struct SearchArgs<'a> {
|
|||||||
pub output: OutputMode,
|
pub output: OutputMode,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn run(pool: &PgPool, args: SearchArgs<'_>, master_key: Option<&[u8; 32]>) -> Result<()> {
|
pub async fn run(pool: &PgPool, args: SearchArgs<'_>) -> Result<()> {
|
||||||
|
validate_safe_search_args(args.show_secrets, args.fields)?;
|
||||||
|
|
||||||
let rows = fetch_rows_paged(
|
let rows = fetch_rows_paged(
|
||||||
pool,
|
pool,
|
||||||
PagedFetchArgs {
|
PagedFetchArgs {
|
||||||
@@ -40,15 +42,12 @@ pub async fn run(pool: &PgPool, args: SearchArgs<'_>, master_key: Option<&[u8; 3
|
|||||||
|
|
||||||
// -f/--field: extract specific field values directly
|
// -f/--field: extract specific field values directly
|
||||||
if !args.fields.is_empty() {
|
if !args.fields.is_empty() {
|
||||||
return print_fields(&rows, args.fields, master_key);
|
return print_fields(&rows, args.fields);
|
||||||
}
|
}
|
||||||
|
|
||||||
match args.output {
|
match args.output {
|
||||||
OutputMode::Json | OutputMode::JsonCompact => {
|
OutputMode::Json | OutputMode::JsonCompact => {
|
||||||
let arr: Vec<Value> = rows
|
let arr: Vec<Value> = rows.iter().map(|r| to_json(r, args.summary)).collect();
|
||||||
.iter()
|
|
||||||
.map(|r| to_json(r, args.show_secrets, args.summary, master_key))
|
|
||||||
.collect();
|
|
||||||
let out = if args.output == OutputMode::Json {
|
let out = if args.output == OutputMode::Json {
|
||||||
serde_json::to_string_pretty(&arr)?
|
serde_json::to_string_pretty(&arr)?
|
||||||
} else {
|
} else {
|
||||||
@@ -56,31 +55,13 @@ pub async fn run(pool: &PgPool, args: SearchArgs<'_>, master_key: Option<&[u8; 3
|
|||||||
};
|
};
|
||||||
println!("{}", out);
|
println!("{}", out);
|
||||||
}
|
}
|
||||||
OutputMode::Env => {
|
|
||||||
if rows.len() > 1 {
|
|
||||||
anyhow::bail!(
|
|
||||||
"env output requires exactly one record; got {}. Add more filters.",
|
|
||||||
rows.len()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if let Some(row) = rows.first() {
|
|
||||||
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.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
OutputMode::Text => {
|
OutputMode::Text => {
|
||||||
if rows.is_empty() {
|
if rows.is_empty() {
|
||||||
println!("No records found.");
|
println!("No records found.");
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
for row in &rows {
|
for row in &rows {
|
||||||
print_text(row, args.show_secrets, args.summary, master_key)?;
|
print_text(row, args.summary)?;
|
||||||
}
|
}
|
||||||
println!("{} record(s) found.", rows.len());
|
println!("{} record(s) found.", rows.len());
|
||||||
if rows.len() == args.limit as usize {
|
if rows.len() == args.limit as usize {
|
||||||
@@ -96,6 +77,30 @@ pub async fn run(pool: &PgPool, args: SearchArgs<'_>, master_key: Option<&[u8; 3
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn validate_safe_search_args(show_secrets: bool, fields: &[String]) -> Result<()> {
|
||||||
|
if show_secrets {
|
||||||
|
anyhow::bail!(
|
||||||
|
"`search` no longer reveals secrets. Use `secrets inject` or `secrets run` instead."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(field) = fields.iter().find(|field| is_secret_field(field)) {
|
||||||
|
anyhow::bail!(
|
||||||
|
"Field '{}' is sensitive. `search -f` only supports metadata.* fields; use `secrets inject` or `secrets run` for secrets.",
|
||||||
|
field
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_secret_field(field: &str) -> bool {
|
||||||
|
matches!(
|
||||||
|
field.split_once('.').map(|(section, _)| section),
|
||||||
|
Some("secret" | "secrets" | "encrypted")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/// Fetch rows with simple equality/tag filters (no pagination). Used by inject/run.
|
/// Fetch rows with simple equality/tag filters (no pagination). Used by inject/run.
|
||||||
pub async fn fetch_rows(
|
pub async fn fetch_rows(
|
||||||
pool: &PgPool,
|
pool: &PgPool,
|
||||||
@@ -218,16 +223,9 @@ async fn fetch_rows_paged(pool: &PgPool, a: PagedFetchArgs<'_>) -> Result<Vec<Se
|
|||||||
Ok(rows)
|
Ok(rows)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build a flat `KEY=VALUE` map from a record's metadata and decrypted secrets.
|
fn env_prefix(row: &Secret, prefix: &str) -> String {
|
||||||
/// 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 name_part = row.name.to_uppercase().replace(['-', '.', ' '], "_");
|
||||||
let effective_prefix = if prefix.is_empty() {
|
if prefix.is_empty() {
|
||||||
name_part
|
name_part
|
||||||
} else {
|
} else {
|
||||||
format!(
|
format!(
|
||||||
@@ -235,7 +233,14 @@ pub fn build_env_map(
|
|||||||
prefix.to_uppercase().replace(['-', '.', ' '], "_"),
|
prefix.to_uppercase().replace(['-', '.', ' '], "_"),
|
||||||
name_part
|
name_part
|
||||||
)
|
)
|
||||||
};
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a flat `KEY=VALUE` map from metadata only.
|
||||||
|
/// 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_metadata_env_map(row: &Secret, prefix: &str) -> HashMap<String, String> {
|
||||||
|
let effective_prefix = env_prefix(row, prefix);
|
||||||
|
|
||||||
let mut map = HashMap::new();
|
let mut map = HashMap::new();
|
||||||
|
|
||||||
@@ -250,9 +255,19 @@ pub fn build_env_map(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(master_key) = master_key
|
map
|
||||||
&& !row.encrypted.is_empty()
|
}
|
||||||
{
|
|
||||||
|
/// Build a flat `KEY=VALUE` map from metadata and decrypted secrets.
|
||||||
|
pub fn build_injected_env_map(
|
||||||
|
row: &Secret,
|
||||||
|
prefix: &str,
|
||||||
|
master_key: &[u8; 32],
|
||||||
|
) -> Result<HashMap<String, String>> {
|
||||||
|
let effective_prefix = env_prefix(row, prefix);
|
||||||
|
let mut map = build_metadata_env_map(row, prefix);
|
||||||
|
|
||||||
|
if !row.encrypted.is_empty() {
|
||||||
let decrypted = crypto::decrypt_json(master_key, &row.encrypted)?;
|
let decrypted = crypto::decrypt_json(master_key, &row.encrypted)?;
|
||||||
if let Some(enc) = decrypted.as_object() {
|
if let Some(enc) = decrypted.as_object() {
|
||||||
for (k, v) in enc {
|
for (k, v) in enc {
|
||||||
@@ -269,12 +284,6 @@ pub fn build_env_map(
|
|||||||
Ok(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.
|
/// Convert a JSON value to its string representation suitable for env vars.
|
||||||
fn json_value_to_env_string(v: &Value) -> String {
|
fn json_value_to_env_string(v: &Value) -> String {
|
||||||
match v {
|
match v {
|
||||||
@@ -284,23 +293,7 @@ fn json_value_to_env_string(v: &Value) -> String {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Decrypt the encrypted blob for a row. Returns an empty object on empty blobs.
|
fn to_json(row: &Secret, summary: bool) -> Value {
|
||||||
fn try_decrypt(row: &Secret, master_key: Option<&[u8; 32]>) -> Result<Value> {
|
|
||||||
if row.encrypted.is_empty() {
|
|
||||||
return Ok(Value::Object(Default::default()));
|
|
||||||
}
|
|
||||||
let key = master_key.ok_or_else(|| {
|
|
||||||
anyhow::anyhow!("master key required to decrypt secrets (run `secrets init`)")
|
|
||||||
})?;
|
|
||||||
crypto::decrypt_json(key, &row.encrypted)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn to_json(
|
|
||||||
row: &Secret,
|
|
||||||
show_secrets: bool,
|
|
||||||
summary: bool,
|
|
||||||
master_key: Option<&[u8; 32]>,
|
|
||||||
) -> Value {
|
|
||||||
if summary {
|
if summary {
|
||||||
let desc = row
|
let desc = row
|
||||||
.metadata
|
.metadata
|
||||||
@@ -319,11 +312,8 @@ fn to_json(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
let secrets_val = if show_secrets {
|
let secrets_val = if row.encrypted.is_empty() {
|
||||||
match try_decrypt(row, master_key) {
|
Value::Object(Default::default())
|
||||||
Ok(v) => v,
|
|
||||||
Err(e) => json!({"_error": e.to_string()}),
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
json!({"_encrypted": true})
|
json!({"_encrypted": true})
|
||||||
};
|
};
|
||||||
@@ -342,12 +332,7 @@ fn to_json(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn print_text(
|
fn print_text(row: &Secret, summary: bool) -> Result<()> {
|
||||||
row: &Secret,
|
|
||||||
show_secrets: bool,
|
|
||||||
summary: bool,
|
|
||||||
master_key: Option<&[u8; 32]>,
|
|
||||||
) -> Result<()> {
|
|
||||||
println!("[{}/{}] {}", row.namespace, row.kind, row.name);
|
println!("[{}/{}] {}", row.namespace, row.kind, row.name);
|
||||||
if summary {
|
if summary {
|
||||||
let desc = row
|
let desc = row
|
||||||
@@ -360,10 +345,7 @@ fn print_text(
|
|||||||
println!(" tags: [{}]", row.tags.join(", "));
|
println!(" tags: [{}]", row.tags.join(", "));
|
||||||
}
|
}
|
||||||
println!(" desc: {}", desc);
|
println!(" desc: {}", desc);
|
||||||
println!(
|
println!(" updated: {}", format_local_time(row.updated_at));
|
||||||
" updated: {}",
|
|
||||||
row.updated_at.format("%Y-%m-%d %H:%M:%S UTC")
|
|
||||||
);
|
|
||||||
} else {
|
} else {
|
||||||
println!(" id: {}", row.id);
|
println!(" id: {}", row.id);
|
||||||
if !row.tags.is_empty() {
|
if !row.tags.is_empty() {
|
||||||
@@ -376,61 +358,33 @@ fn print_text(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
if !row.encrypted.is_empty() {
|
if !row.encrypted.is_empty() {
|
||||||
if show_secrets {
|
println!(" secrets: [encrypted] (use `secrets inject` or `secrets run`)");
|
||||||
match try_decrypt(row, master_key) {
|
|
||||||
Ok(v) => println!(" secrets: {}", serde_json::to_string_pretty(&v)?),
|
|
||||||
Err(e) => println!(" secrets: [decrypt error: {}]", e),
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
println!(" secrets: [encrypted] (--show-secrets to reveal)");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
println!(
|
println!(" created: {}", format_local_time(row.created_at));
|
||||||
" created: {}",
|
|
||||||
row.created_at.format("%Y-%m-%d %H:%M:%S UTC")
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
println!();
|
println!();
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Extract one or more field paths like `metadata.url` or `secret.token`.
|
/// Extract one or more field paths like `metadata.url`.
|
||||||
fn print_fields(rows: &[Secret], fields: &[String], master_key: Option<&[u8; 32]>) -> Result<()> {
|
fn print_fields(rows: &[Secret], fields: &[String]) -> Result<()> {
|
||||||
for row in rows {
|
for row in rows {
|
||||||
let decrypted: Option<Value> = if fields
|
|
||||||
.iter()
|
|
||||||
.any(|f| f.starts_with("secret") || f.starts_with("encrypted"))
|
|
||||||
{
|
|
||||||
Some(try_decrypt(row, master_key)?)
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
};
|
|
||||||
|
|
||||||
for field in fields {
|
for field in fields {
|
||||||
let val = extract_field(row, field, decrypted.as_ref())?;
|
let val = extract_field(row, field)?;
|
||||||
println!("{}", val);
|
println!("{}", val);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn extract_field(row: &Secret, field: &str, decrypted: Option<&Value>) -> Result<String> {
|
fn extract_field(row: &Secret, field: &str) -> Result<String> {
|
||||||
let (section, key) = field.split_once('.').ok_or_else(|| {
|
let (section, key) = field
|
||||||
anyhow::anyhow!(
|
.split_once('.')
|
||||||
"Invalid field path '{}'. Use metadata.<key> or secret.<key>",
|
.ok_or_else(|| anyhow::anyhow!("Invalid field path '{}'. Use metadata.<key>.", field))?;
|
||||||
field
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
|
|
||||||
let obj = match section {
|
let obj = match section {
|
||||||
"metadata" | "meta" => &row.metadata,
|
"metadata" | "meta" => &row.metadata,
|
||||||
"secret" | "secrets" | "encrypted" => {
|
other => anyhow::bail!("Unknown field section '{}'. Use 'metadata'.", other),
|
||||||
decrypted.ok_or_else(|| anyhow::anyhow!("secret field requires master key"))?
|
|
||||||
}
|
|
||||||
other => anyhow::bail!(
|
|
||||||
"Unknown field section '{}'. Use 'metadata' or 'secret'",
|
|
||||||
other
|
|
||||||
),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
obj.get(key)
|
obj.get(key)
|
||||||
@@ -449,3 +403,70 @@ fn extract_field(row: &Secret, field: &str, decrypted: Option<&Value>) -> Result
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use chrono::Utc;
|
||||||
|
use serde_json::json;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
fn sample_secret() -> Secret {
|
||||||
|
let key = [0x42u8; 32];
|
||||||
|
let encrypted = crypto::encrypt_json(&key, &json!({"token": "abc123"})).unwrap();
|
||||||
|
|
||||||
|
Secret {
|
||||||
|
id: Uuid::nil(),
|
||||||
|
namespace: "refining".to_string(),
|
||||||
|
kind: "service".to_string(),
|
||||||
|
name: "gitea.main".to_string(),
|
||||||
|
tags: vec!["prod".to_string()],
|
||||||
|
metadata: json!({"url": "https://gitea.refining.dev", "enabled": true}),
|
||||||
|
encrypted,
|
||||||
|
version: 1,
|
||||||
|
created_at: Utc::now(),
|
||||||
|
updated_at: Utc::now(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_show_secrets_flag() {
|
||||||
|
let err = validate_safe_search_args(true, &[]).unwrap_err();
|
||||||
|
assert!(err.to_string().contains("no longer reveals secrets"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_secret_field_extraction() {
|
||||||
|
let fields = vec!["secret.token".to_string()];
|
||||||
|
let err = validate_safe_search_args(false, &fields).unwrap_err();
|
||||||
|
assert!(err.to_string().contains("sensitive"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn metadata_env_map_excludes_secret_values() {
|
||||||
|
let row = sample_secret();
|
||||||
|
let map = build_metadata_env_map(&row, "");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
map.get("GITEA_MAIN_URL").map(String::as_str),
|
||||||
|
Some("https://gitea.refining.dev")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
map.get("GITEA_MAIN_ENABLED").map(String::as_str),
|
||||||
|
Some("true")
|
||||||
|
);
|
||||||
|
assert!(!map.contains_key("GITEA_MAIN_TOKEN"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn injected_env_map_includes_secret_values() {
|
||||||
|
let row = sample_secret();
|
||||||
|
let key = [0x42u8; 32];
|
||||||
|
let map = build_injected_env_map(&row, "", &key).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
map.get("GITEA_MAIN_TOKEN").map(String::as_str),
|
||||||
|
Some("abc123")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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(", "));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -105,15 +105,6 @@ pub fn store_master_key(key: &[u8; 32]) -> Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Delete the Master Key from the OS Keychain (used by tests / reset).
|
|
||||||
#[cfg(test)]
|
|
||||||
pub fn delete_master_key() -> Result<()> {
|
|
||||||
let entry =
|
|
||||||
keyring::Entry::new(KEYRING_SERVICE, KEYRING_USER).context("create keychain entry")?;
|
|
||||||
let _ = entry.delete_credential();
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Minimal hex helpers (avoid extra dep) ────────────────────────────────────
|
// ─── Minimal hex helpers (avoid extra dep) ────────────────────────────────────
|
||||||
|
|
||||||
mod hex {
|
mod hex {
|
||||||
|
|||||||
94
src/main.rs
94
src/main.rs
@@ -28,13 +28,16 @@ use output::resolve_output_mode;
|
|||||||
secrets search --summary --limit 20
|
secrets search --summary --limit 20
|
||||||
|
|
||||||
# Precise lookup (JSON output for easy parsing)
|
# Precise lookup (JSON output for easy parsing)
|
||||||
secrets search -n refining --kind service --name gitea -o json --show-secrets
|
secrets search -n refining --kind service --name gitea -o json
|
||||||
|
|
||||||
# Extract a single field value directly
|
# Extract a single metadata field directly
|
||||||
secrets search -n refining --kind service --name gitea -f secret.token
|
secrets search -n refining --kind service --name gitea -f metadata.url
|
||||||
|
|
||||||
# Pipe-friendly (non-TTY defaults to json-compact automatically)
|
# Pipe-friendly (non-TTY defaults to json-compact automatically)
|
||||||
secrets search -n refining --kind service | jq '.[].name'"
|
secrets search -n refining --kind service | jq '.[].name'
|
||||||
|
|
||||||
|
# Inject secrets into environment variables when you really need them
|
||||||
|
secrets inject -n refining --kind service --name gitea"
|
||||||
)]
|
)]
|
||||||
struct Cli {
|
struct Cli {
|
||||||
/// Database URL, overrides saved config (one-time override)
|
/// Database URL, overrides saved config (one-time override)
|
||||||
@@ -82,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)]
|
||||||
@@ -99,13 +120,13 @@ 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
|
||||||
#[arg(short, long = "output")]
|
#[arg(short, long = "output")]
|
||||||
output: Option<String>,
|
output: Option<String>,
|
||||||
},
|
},
|
||||||
@@ -114,7 +135,7 @@ EXAMPLES:
|
|||||||
///
|
///
|
||||||
/// Supports fuzzy search (-q), exact lookup (--name), field extraction (-f),
|
/// Supports fuzzy search (-q), exact lookup (--name), field extraction (-f),
|
||||||
/// summary view (--summary), pagination (--limit / --offset), and structured
|
/// summary view (--summary), pagination (--limit / --offset), and structured
|
||||||
/// output (-o json / json-compact / env). When stdout is not a TTY, output
|
/// output (-o json / json-compact). When stdout is not a TTY, output
|
||||||
/// defaults to json-compact automatically.
|
/// defaults to json-compact automatically.
|
||||||
#[command(after_help = "EXAMPLES:
|
#[command(after_help = "EXAMPLES:
|
||||||
# Discover all records (summary, safe default limit)
|
# Discover all records (summary, safe default limit)
|
||||||
@@ -129,19 +150,16 @@ EXAMPLES:
|
|||||||
# Fuzzy keyword search (matches name, namespace, kind, tags, metadata)
|
# Fuzzy keyword search (matches name, namespace, kind, tags, metadata)
|
||||||
secrets search -q mqtt
|
secrets search -q mqtt
|
||||||
|
|
||||||
# Extract a single field value (implies --show-secrets for secret.*)
|
# Extract a single metadata field value
|
||||||
secrets search -n refining --kind service --name gitea -f secret.token
|
|
||||||
secrets search -n refining --kind service --name gitea -f metadata.url
|
secrets search -n refining --kind service --name gitea -f metadata.url
|
||||||
|
|
||||||
# Multiple fields at once
|
# Multiple fields at once
|
||||||
secrets search -n refining --kind service --name gitea \\
|
secrets search -n refining --kind service --name gitea \\
|
||||||
-f metadata.url -f metadata.default_org -f secret.token
|
-f metadata.url -f metadata.default_org
|
||||||
|
|
||||||
# Full JSON output with secrets revealed (ideal for AI parsing)
|
# Inject decrypted secrets only when needed
|
||||||
secrets search -n refining --kind service --name gitea -o json --show-secrets
|
secrets inject -n refining --kind service --name gitea
|
||||||
|
secrets run -n refining --kind service --name gitea -- printenv
|
||||||
# Export as env vars (source-able; single record only)
|
|
||||||
secrets search -n refining --kind service --name gitea -o env --show-secrets
|
|
||||||
|
|
||||||
# Paginate large result sets
|
# Paginate large result sets
|
||||||
secrets search -n refining --summary --limit 10 --offset 0
|
secrets search -n refining --summary --limit 10 --offset 0
|
||||||
@@ -151,8 +169,7 @@ EXAMPLES:
|
|||||||
secrets search --sort updated --limit 5 --summary
|
secrets search --sort updated --limit 5 --summary
|
||||||
|
|
||||||
# Non-TTY / pipe: output is json-compact by default
|
# Non-TTY / pipe: output is json-compact by default
|
||||||
secrets search -n refining --kind service | jq '.[].name'
|
secrets search -n refining --kind service | jq '.[].name'")]
|
||||||
secrets search -n refining --kind service --name gitea --show-secrets | jq '.secrets.token'")]
|
|
||||||
Search {
|
Search {
|
||||||
/// Filter by namespace, e.g. refining, ricnsmart
|
/// Filter by namespace, e.g. refining, ricnsmart
|
||||||
#[arg(short, long)]
|
#[arg(short, long)]
|
||||||
@@ -169,10 +186,10 @@ EXAMPLES:
|
|||||||
/// Fuzzy keyword (matches name, namespace, kind, tags, metadata text)
|
/// Fuzzy keyword (matches name, namespace, kind, tags, metadata text)
|
||||||
#[arg(short, long)]
|
#[arg(short, long)]
|
||||||
query: Option<String>,
|
query: Option<String>,
|
||||||
/// Reveal encrypted secret values in output
|
/// Deprecated: search never reveals secrets; use inject/run instead
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
show_secrets: bool,
|
show_secrets: bool,
|
||||||
/// Extract field value(s) directly: metadata.<key> or secret.<key> (repeatable)
|
/// Extract metadata field value(s) directly: metadata.<key> (repeatable)
|
||||||
#[arg(short = 'f', long = "field")]
|
#[arg(short = 'f', long = "field")]
|
||||||
fields: Vec<String>,
|
fields: Vec<String>,
|
||||||
/// Return lightweight summary only (namespace, kind, name, tags, desc, updated_at)
|
/// Return lightweight summary only (namespace, kind, name, tags, desc, updated_at)
|
||||||
@@ -187,7 +204,7 @@ EXAMPLES:
|
|||||||
/// Sort order: name (default), updated, created
|
/// Sort order: name (default), updated, created
|
||||||
#[arg(long, default_value = "name")]
|
#[arg(long, default_value = "name")]
|
||||||
sort: String,
|
sort: String,
|
||||||
/// Output format: text (default on TTY), json, json-compact, env
|
/// Output format: text (default on TTY), json, json-compact
|
||||||
#[arg(short, long = "output")]
|
#[arg(short, long = "output")]
|
||||||
output: Option<String>,
|
output: Option<String>,
|
||||||
},
|
},
|
||||||
@@ -225,6 +242,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>
|
||||||
@@ -233,8 +255,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)]
|
||||||
@@ -251,16 +286,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
|
||||||
@@ -501,9 +536,7 @@ async fn main() -> Result<()> {
|
|||||||
sort,
|
sort,
|
||||||
output,
|
output,
|
||||||
} => {
|
} => {
|
||||||
let master_key = crypto::load_master_key()?;
|
|
||||||
let _span = tracing::info_span!("cmd", command = "search").entered();
|
let _span = tracing::info_span!("cmd", command = "search").entered();
|
||||||
let show = show_secrets || fields.iter().any(|f| f.starts_with("secret"));
|
|
||||||
let out = resolve_output_mode(output.as_deref())?;
|
let out = resolve_output_mode(output.as_deref())?;
|
||||||
commands::search::run(
|
commands::search::run(
|
||||||
&pool,
|
&pool,
|
||||||
@@ -513,7 +546,7 @@ async fn main() -> Result<()> {
|
|||||||
name: name.as_deref(),
|
name: name.as_deref(),
|
||||||
tags: &tag,
|
tags: &tag,
|
||||||
query: query.as_deref(),
|
query: query.as_deref(),
|
||||||
show_secrets: show,
|
show_secrets,
|
||||||
fields: &fields,
|
fields: &fields,
|
||||||
summary,
|
summary,
|
||||||
limit,
|
limit,
|
||||||
@@ -521,7 +554,6 @@ async fn main() -> Result<()> {
|
|||||||
sort: &sort,
|
sort: &sort,
|
||||||
output: out,
|
output: out,
|
||||||
},
|
},
|
||||||
Some(&master_key),
|
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
use chrono::{DateTime, Local, Utc};
|
||||||
use std::io::IsTerminal;
|
use std::io::IsTerminal;
|
||||||
use std::str::FromStr;
|
use std::str::FromStr;
|
||||||
|
|
||||||
@@ -11,8 +12,6 @@ pub enum OutputMode {
|
|||||||
Json,
|
Json,
|
||||||
/// Single-line JSON (default when stdout is NOT a TTY, e.g. piped to jq)
|
/// Single-line JSON (default when stdout is NOT a TTY, e.g. piped to jq)
|
||||||
JsonCompact,
|
JsonCompact,
|
||||||
/// KEY=VALUE pairs suitable for `source` or `.env` files
|
|
||||||
Env,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl FromStr for OutputMode {
|
impl FromStr for OutputMode {
|
||||||
@@ -23,9 +22,8 @@ impl FromStr for OutputMode {
|
|||||||
"text" => Ok(Self::Text),
|
"text" => Ok(Self::Text),
|
||||||
"json" => Ok(Self::Json),
|
"json" => Ok(Self::Json),
|
||||||
"json-compact" => Ok(Self::JsonCompact),
|
"json-compact" => Ok(Self::JsonCompact),
|
||||||
"env" => Ok(Self::Env),
|
|
||||||
other => Err(anyhow::anyhow!(
|
other => Err(anyhow::anyhow!(
|
||||||
"Unknown output format '{}'. Valid: text, json, json-compact, env",
|
"Unknown output format '{}'. Valid: text, json, json-compact",
|
||||||
other
|
other
|
||||||
)),
|
)),
|
||||||
}
|
}
|
||||||
@@ -45,3 +43,10 @@ pub fn resolve_output_mode(explicit: Option<&str>) -> anyhow::Result<OutputMode>
|
|||||||
Ok(OutputMode::JsonCompact)
|
Ok(OutputMode::JsonCompact)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Format a UTC timestamp for local human-readable output.
|
||||||
|
pub fn format_local_time(dt: DateTime<Utc>) -> String {
|
||||||
|
dt.with_timezone(&Local)
|
||||||
|
.format("%Y-%m-%d %H:%M:%S %:z")
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user