feat: secrets CLI MVP — add/search/delete with PostgreSQL JSONB
Some checks failed
Secrets CLI - Build & Release / 检查版本 (push) Successful in 2s
Secrets CLI - Build & Release / Build (x86_64-unknown-linux-musl) (push) Failing after 41s
Secrets CLI - Build & Release / Build (aarch64-apple-darwin) (push) Failing after 55s
Secrets CLI - Build & Release / 发送通知 (push) Has been cancelled
Secrets CLI - Build & Release / Build (x86_64-pc-windows-msvc) (push) Has been cancelled

- Single `secrets` table with namespace/kind/name/tags/metadata/encrypted
- Auto-migrate on startup using uuidv7() primary keys and GIN indexes
- CLI commands: add (upsert, @file support), search (full-text + tags), delete
- Multi-platform Gitea Actions: debian (x86_64-musl), darwin-arm64, windows
  - continue-on-error + timeout-minutes=30 for offline runner tolerance
- VS Code tasks.json for local build/test/seed
- AGENTS.md for AI context

Made-with: Cursor
This commit is contained in:
voson
2026-03-18 14:10:45 +08:00
parent 3b5e26213c
commit 102e394914
16 changed files with 3656 additions and 544 deletions

View File

@@ -0,0 +1,313 @@
name: Secrets CLI - Build & Release
on:
push:
branches: [main]
paths:
- 'src/**'
- 'Cargo.toml'
- 'Cargo.lock'
- '.gitea/workflows/secrets.yml'
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: write
env:
BINARY_NAME: secrets
CARGO_INCREMENTAL: 0
CARGO_NET_RETRY: 10
CARGO_TERM_COLOR: always
RUST_BACKTRACE: short
jobs:
# ========== 版本检查(只跑一次,供后续 job 共用)==========
version:
name: 检查版本
runs-on: debian
outputs:
version: ${{ steps.version.outputs.version }}
tag: ${{ steps.version.outputs.tag }}
tag_exists: ${{ steps.version.outputs.tag_exists }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: 检查版本
id: version
run: |
version=$(grep -m1 '^version' Cargo.toml | sed 's/.*"\(.*\)".*/\1/')
tag="secrets-${version}"
echo "version=${version}" >> $GITHUB_OUTPUT
echo "tag=${tag}" >> $GITHUB_OUTPUT
if git rev-parse "refs/tags/${tag}" >/dev/null 2>&1; then
echo "tag_exists=true" >> $GITHUB_OUTPUT
echo "版本 ${tag} 已存在"
else
echo "tag_exists=false" >> $GITHUB_OUTPUT
echo "将创建新版本 ${tag}"
fi
- name: 创建 Tag
if: steps.version.outputs.tag_exists == 'false'
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git tag -a "${{ steps.version.outputs.tag }}" -m "Release ${{ steps.version.outputs.tag }}"
git push origin "${{ steps.version.outputs.tag }}"
# ========== 矩阵构建 ==========
build:
name: Build (${{ matrix.target }})
needs: version
continue-on-error: true # 某平台失败/超时不阻断其他平台和 notify
timeout-minutes: 30 # runner 不在线时 30 分钟后放弃
strategy:
fail-fast: false
matrix:
include:
- runner: debian
target: x86_64-unknown-linux-musl
archive_suffix: x86_64-linux-musl
os: linux
- runner: darwin-arm64
target: aarch64-apple-darwin
archive_suffix: aarch64-macos
os: macos
- runner: windows
target: x86_64-pc-windows-msvc
archive_suffix: x86_64-windows
os: windows
runs-on: ${{ matrix.runner }}
steps:
# ========== 环境准备 ==========
- name: 安装依赖 (Linux)
if: matrix.os == 'linux'
run: |
sudo apt-get update
sudo apt-get install -y jq curl git pkg-config musl-tools binutils
if ! command -v cargo &>/dev/null; then
echo "安装 Rust 工具链..."
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable
source "$HOME/.cargo/env"
rustup component add rustfmt clippy
fi
rustup target add ${{ matrix.target }}
echo "$HOME/.cargo/bin" >> $GITHUB_PATH
- name: 安装依赖 (macOS)
if: matrix.os == 'macos'
run: |
brew install jq
if ! command -v cargo &>/dev/null; then
echo "安装 Rust 工具链..."
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable
source "$HOME/.cargo/env"
rustup component add rustfmt clippy
fi
rustup target add ${{ matrix.target }}
echo "$HOME/.cargo/bin" >> $GITHUB_PATH
- name: 安装依赖 (Windows)
if: matrix.os == 'windows'
shell: pwsh
run: |
# 检查 Rust 是否已安装
if (-not (Get-Command cargo -ErrorAction SilentlyContinue)) {
Write-Host "安装 Rust 工具链..."
Invoke-WebRequest -Uri "https://win.rustup.rs/x86_64" -OutFile rustup-init.exe
.\rustup-init.exe -y --default-toolchain stable
Remove-Item rustup-init.exe
}
rustup component add rustfmt clippy
rustup target add ${{ matrix.target }}
- uses: actions/checkout@v4
with:
fetch-depth: 0
# ========== Cargo 缓存 ==========
- name: 缓存 Cargo 依赖
uses: actions/cache@v4
with:
path: |
~/.cargo/registry/index
~/.cargo/registry/cache
~/.cargo/git/db
target
key: cargo-secrets-${{ matrix.target }}-${{ hashFiles('Cargo.lock') }}
restore-keys: |
cargo-secrets-${{ matrix.target }}-
- name: 检查代码格式
if: matrix.os != 'windows'
run: cargo fmt -- --check
- name: 检查代码格式 (Windows)
if: matrix.os == 'windows'
shell: pwsh
run: cargo fmt -- --check
- name: 运行 Clippy 检查
if: matrix.os != 'windows'
run: cargo clippy --release --target ${{ matrix.target }} -- -D warnings
- name: 运行 Clippy 检查 (Windows)
if: matrix.os == 'windows'
shell: pwsh
run: cargo clippy --release --target ${{ matrix.target }} -- -D warnings
- name: 构建
if: matrix.os != 'windows'
env:
GIT_TAG: ${{ needs.version.outputs.version }}
run: cargo build --release --target ${{ matrix.target }} --verbose
- name: 构建 (Windows)
if: matrix.os == 'windows'
shell: pwsh
env:
GIT_TAG: ${{ needs.version.outputs.version }}
run: cargo build --release --target ${{ matrix.target }} --verbose
- name: Strip 二进制 (Linux)
if: matrix.os == 'linux'
run: strip target/${{ matrix.target }}/release/${{ env.BINARY_NAME }}
- name: Strip 二进制 (macOS)
if: matrix.os == 'macos'
run: strip -x target/${{ matrix.target }}/release/${{ env.BINARY_NAME }}
# ========== 上传 Release 产物 ==========
- name: 上传 Release 产物 (Linux/macOS)
if: needs.version.outputs.tag_exists == 'false' && matrix.os != 'windows'
env:
RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }}
run: |
[ -z "$RELEASE_TOKEN" ] && echo "跳过:未配置 RELEASE_TOKEN" && exit 0
tag="${{ needs.version.outputs.tag }}"
binary="target/${{ matrix.target }}/release/${{ env.BINARY_NAME }}"
archive="${{ env.BINARY_NAME }}-${tag}-${{ matrix.archive_suffix }}.tar.gz"
tar -czf "$archive" -C "$(dirname $binary)" "$(basename $binary)"
# 查找已有 Release由首个完成的 job 创建,后续 job 直接上传)
release_url="${{ github.server_url }}/api/v1/repos/${{ github.repository }}/releases"
release_id=$(curl -sS -H "Authorization: token $RELEASE_TOKEN" \
"${release_url}/tags/${tag}" | jq -r '.id // empty')
if [ -z "$release_id" ]; then
release_id=$(curl -sS -H "Authorization: token $RELEASE_TOKEN" \
-H "Content-Type: application/json" \
-X POST "$release_url" \
-d "{\"tag_name\": \"${tag}\", \"name\": \"${tag}\", \"body\": \"Release ${tag}\"}" \
| jq -r '.id')
fi
upload_url="${{ github.server_url }}/api/v1/repos/${{ github.repository }}/releases/${release_id}/assets"
curl -sS -H "Authorization: token $RELEASE_TOKEN" \
-F "attachment=@${archive}" \
"$upload_url"
echo "已上传: ${archive} → Release ${tag}"
- name: 上传 Release 产物 (Windows)
if: needs.version.outputs.tag_exists == 'false' && matrix.os == 'windows'
shell: pwsh
env:
RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }}
run: |
if (-not $env:RELEASE_TOKEN) { Write-Host "跳过:未配置 RELEASE_TOKEN"; exit 0 }
$tag = "${{ needs.version.outputs.tag }}"
$binary = "target\${{ matrix.target }}\release\${{ env.BINARY_NAME }}.exe"
$archive = "${{ env.BINARY_NAME }}-${tag}-${{ matrix.archive_suffix }}.zip"
Compress-Archive -Path $binary -DestinationPath $archive
$headers = @{ "Authorization" = "token $env:RELEASE_TOKEN"; "Content-Type" = "application/json" }
$release_url = "${{ github.server_url }}/api/v1/repos/${{ github.repository }}/releases"
# 查找已有 Release
$existing = Invoke-RestMethod -Uri "${release_url}/tags/${tag}" -Headers $headers -ErrorAction SilentlyContinue
if ($existing.id) {
$release_id = $existing.id
} else {
$body = @{ tag_name = $tag; name = $tag; body = "Release ${tag}" } | ConvertTo-Json
$release_id = (Invoke-RestMethod -Uri $release_url -Method Post -Headers $headers -Body $body).id
}
$upload_url = "${release_url}/${release_id}/assets"
$upload_headers = @{ "Authorization" = "token $env:RELEASE_TOKEN" }
$form = @{ attachment = Get-Item $archive }
Invoke-RestMethod -Uri $upload_url -Method Post -Headers $upload_headers -Form $form
Write-Host "已上传: ${archive} → Release ${tag}"
# ========== 汇总通知 ==========
notify:
name: 发送通知
needs: [version, build]
if: always() && github.event_name == 'push'
runs-on: debian
steps:
- uses: actions/checkout@v4
- name: 发送通知
continue-on-error: true
env:
WEBHOOK_URL: ${{ vars.WEBHOOK_URL }}
run: |
[ -z "$WEBHOOK_URL" ] && exit 0
tag="${{ needs.version.outputs.tag }}"
tag_exists="${{ needs.version.outputs.tag_exists }}"
build_result="${{ needs.build.result }}"
if [ "$build_result" = "success" ]; then
status_text="构建成功 ✅"
else
status_text="构建失败 ❌"
fi
commit_title=$(git log -1 --pretty=format:"%s" 2>/dev/null || echo "N/A")
workflow_url="${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_number }}"
if [ "$build_result" != "success" ]; then
payload=$(jq -n \
--arg title "${{ env.BINARY_NAME }} ${status_text}" \
--arg commit "$commit_title" \
--arg version "$tag" \
--arg author "${{ github.actor }}" \
--arg url "$workflow_url" \
'{msg_type: "text", content: {text: "\($title)\n提交\($commit)\n版本\($version)\n作者\($author)\n详情\($url)"}}')
elif [ "$tag_exists" = "false" ]; then
payload=$(jq -n \
--arg title "${{ env.BINARY_NAME }} ${status_text}" \
--arg commit "$commit_title" \
--arg version "$tag" \
--arg author "${{ github.actor }}" \
--arg url "$workflow_url" \
'{msg_type: "text", content: {text: "\($title)\n🆕 新版本已发布 (linux / macOS / windows)\n提交\($commit)\n版本\($version)\n作者\($author)\n详情\($url)"}}')
else
payload=$(jq -n \
--arg title "${{ env.BINARY_NAME }} ${status_text}" \
--arg commit "$commit_title" \
--arg version "$tag" \
--arg author "${{ github.actor }}" \
--arg url "$workflow_url" \
'{msg_type: "text", content: {text: "\($title)\n🔄 重复构建\n提交\($commit)\n版本\($version)\n作者\($author)\n详情\($url)"}}')
fi
curl -sS -H "Content-Type: application/json" -X POST -d "$payload" "$WEBHOOK_URL"

2
.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
/target
.env

107
.vscode/tasks.json vendored Normal file
View File

@@ -0,0 +1,107 @@
{
"version": "2.0.0",
"tasks": [
{
"label": "build",
"type": "shell",
"command": "cargo build",
"group": { "kind": "build", "isDefault": true }
},
{
"label": "cli: version",
"type": "shell",
"command": "./target/debug/secrets -V",
"dependsOn": "build"
},
{
"label": "cli: help",
"type": "shell",
"command": "./target/debug/secrets --help",
"dependsOn": "build"
},
{
"label": "cli: help add",
"type": "shell",
"command": "./target/debug/secrets help add",
"dependsOn": "build"
},
{
"label": "test: search all",
"type": "shell",
"command": "./target/debug/secrets search",
"dependsOn": "build"
},
{
"label": "test: search by namespace (refining)",
"type": "shell",
"command": "./target/debug/secrets search -n refining",
"dependsOn": "build"
},
{
"label": "test: search by namespace (ricnsmart)",
"type": "shell",
"command": "./target/debug/secrets search -n ricnsmart",
"dependsOn": "build"
},
{
"label": "test: search servers",
"type": "shell",
"command": "./target/debug/secrets search --kind server",
"dependsOn": "build"
},
{
"label": "test: search services",
"type": "shell",
"command": "./target/debug/secrets search --kind service",
"dependsOn": "build"
},
{
"label": "test: search keys",
"type": "shell",
"command": "./target/debug/secrets search --kind key",
"dependsOn": "build"
},
{
"label": "test: search by tag (aliyun)",
"type": "shell",
"command": "./target/debug/secrets search --tag aliyun",
"dependsOn": "build"
},
{
"label": "test: search by tag (hongkong)",
"type": "shell",
"command": "./target/debug/secrets search --tag hongkong",
"dependsOn": "build"
},
{
"label": "test: search keyword (gitea)",
"type": "shell",
"command": "./target/debug/secrets search -q gitea",
"dependsOn": "build"
},
{
"label": "test: search with secrets revealed",
"type": "shell",
"command": "./target/debug/secrets search -n refining --kind service --name gitea --show-secrets",
"dependsOn": "build"
},
{
"label": "test: combined search (ricnsmart + server + shanghai)",
"type": "shell",
"command": "./target/debug/secrets search -n ricnsmart --kind server --tag shanghai",
"dependsOn": "build"
},
{
"label": "test: add + delete roundtrip",
"type": "shell",
"command": "echo '--- add ---' && ./target/debug/secrets add -n test --kind demo --name roundtrip-test --tag test -m foo=bar -s password=secret123 && echo '--- search ---' && ./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",
"dependsOn": "build"
},
{
"label": "test: add with file secret",
"type": "shell",
"command": "echo '--- add key from file ---' && ./target/debug/secrets add -n test --kind key --name test-key --tag test -s content=@./refining/keys/Vultr && echo '--- verify ---' && ./target/debug/secrets search -n test --kind key --show-secrets && echo '--- cleanup ---' && ./target/debug/secrets delete -n test --kind key --name test-key",
"dependsOn": "build"
}
]
}

130
AGENTS.md Normal file
View File

@@ -0,0 +1,130 @@
# Secrets CLI — AGENTS.md
跨设备密钥与配置管理 CLI 工具,将 refining / ricnsmart 两个项目的服务器信息、服务凭据存储到 PostgreSQL 18供 AI 工具读取上下文。
## 项目结构
```
secrets/
src/
main.rs # CLI 入口clap 命令定义auto-migrate
db.rs # PgPool 创建 + 建表/索引(幂等)
models.rs # Secret 结构体sqlx::FromRow + serde
commands/
add.rs # add 命令upsert支持 --meta key=value / --secret key=@file
search.rs # search 命令:多条件动态查询
delete.rs # delete 命令
scripts/
seed-data.sh # 从 refining/ricnsmart config.toml 导入全量数据
.gitea/workflows/
secrets.yml # CIfmt + clippy + musl 构建 + Release 上传 + 飞书通知
.vscode/tasks.json # 本地测试任务build / search / add+delete roundtrip 等)
.env # DATABASE_URLgitignore不提交
```
## 数据库
- **Host**: `47.117.131.22:5432`(阿里云上海 ECSPostgreSQL 18 with io_uring
- **Database**: `secrets`
- **连接串**: `postgres://postgres:<password>@47.117.131.22:5432/secrets`
- **表**: 单张 `secrets`首次连接自动建表auto-migrate
### 表结构
```sql
secrets (
id UUID PRIMARY KEY DEFAULT uuidv7(), -- PG18 时间有序 UUID
namespace VARCHAR(64) NOT NULL, -- 一级隔离: "refining" | "ricnsmart"
kind VARCHAR(64) NOT NULL, -- 类型: "server" | "service"(可扩展)
name VARCHAR(256) NOT NULL, -- 人类可读标识
tags TEXT[] NOT NULL DEFAULT '{}', -- 灵活标签: ["aliyun","hongkong"]
metadata JSONB NOT NULL DEFAULT '{}', -- 明文描述: ip, desc, domains, location...
encrypted JSONB NOT NULL DEFAULT '{}', -- 敏感数据: ssh_key, password, token...
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(namespace, kind, name)
)
```
### 字段职责划分
| 字段 | 存什么 | 示例 |
|------|--------|------|
| `namespace` | 项目/团队隔离 | `refining`, `ricnsmart` |
| `kind` | 记录类型 | `server`, `service` |
| `name` | 唯一标识名 | `i-uf63f2uookgs5uxmrdyc`, `gitea` |
| `tags` | 多维分类标签 | `["aliyun","hongkong","ricn"]` |
| `metadata` | 明文非敏感信息 | `{"ip":"47.243.154.187","desc":"Grafana","domains":["..."]}` |
| `encrypted` | 敏感凭据MVP 阶段明文存储,后续对 value 加密) | `{"ssh_key":"-----BEGIN...","password":"..."}` |
## CLI 命令
```bash
# 查看版本
secrets -V / --version
# 查看帮助
secrets -h / --help
secrets help <subcommand> # 子命令详细帮助,如 secrets help add
# 添加或更新记录upsert
secrets add -n <namespace> --kind <kind> --name <name> \
[--tag <tag>]... # 可重复
[-m key=value]... # --meta 明文字段,-m 是短标志
[-s key=value]... # --secret 敏感字段value 以 @ 开头表示从文件读取
# 搜索(默认隐藏 encrypted 内容)
secrets search [-n <namespace>] [--kind <kind>] [--tag <tag>] [-q <keyword>] [--show-secrets]
# -q 匹配范围name、namespace、kind、metadata 全文内容、tags
# 删除
secrets delete -n <namespace> --kind <kind> --name <name>
```
### 示例
```bash
# 添加服务器
secrets add -n refining --kind server --name i-uf63f2uookgs5uxmrdyc \
--tag aliyun --tag shanghai \
-m ip=47.117.131.22 -m desc="Aliyun Shanghai ECS" \
-s username=root -s ssh_key=@./keys/voson_shanghai_e.pem
# 添加服务凭据
secrets add -n refining --kind service --name gitea \
--tag gitea \
-m url=https://gitea.refining.dev \
-s token=<token>
# 搜索含 mqtt 的所有记录
secrets search -q mqtt
# 查看 refining 的全部服务配置(显示 secrets
secrets search -n refining --kind service --show-secrets
# 按 tag 筛选
secrets search --tag hongkong
```
## 代码规范
- 错误处理:统一使用 `anyhow::Result`,不用 `unwrap()`
- 异步:全程 `tokio`,数据库操作 `sqlx` async
- SQL使用 `sqlx::query` / `sqlx::query_as` 绑定参数,禁止字符串拼接(搜索的动态 WHERE 子句除外,需使用参数绑定 `$1/$2`
- 新增 `kind` 类型时:只需在 `add` 调用时传入,无需改代码
- 字段命名CLI 短标志 `-n`=namespace`-m`=meta`-s`=secret`-q`=query
## CI/CD
- Gitea Actionsrunner: debian
- 触发:`src/**``Cargo.toml``Cargo.lock` 变更推送到 main
- 构建目标:`x86_64-unknown-linux-musl`(静态链接,无 glibc 依赖)
- 新版本自动打 Tag格式 `secrets-<version>`)并上传二进制到 Gitea Release
- 通知:飞书 Webhook`vars.WEBHOOK_URL`
- 所需 secrets/vars`RELEASE_TOKEN`Release 上传Gitea PAT`vars.WEBHOOK_URL`(通知,可选)
## 环境变量
| 变量 | 说明 |
|------|------|
| `DATABASE_URL` | PostgreSQL 连接串,优先级高于 `--db-url` 参数 |

2400
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

16
Cargo.toml Normal file
View File

@@ -0,0 +1,16 @@
[package]
name = "secrets"
version = "0.1.0"
edition = "2024"
[dependencies]
anyhow = "1.0.102"
chrono = { version = "0.4.44", features = ["serde"] }
clap = { version = "4.6.0", features = ["derive", "env"] }
dotenvy = "0.15.7"
serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.149"
sqlx = { version = "0.8.6", features = ["runtime-tokio", "tls-native-tls", "postgres", "uuid", "json", "chrono"] }
tokio = { version = "1.50.0", features = ["full"] }
toml = "1.0.7"
uuid = { version = "1.22.0", features = ["serde", "v4"] }

104
README.md Normal file
View File

@@ -0,0 +1,104 @@
# secrets
跨设备密钥与配置管理 CLI基于 Rust + PostgreSQL 18。
将服务器信息、服务凭据统一存入数据库,供本地工具和 AI 读取上下文。
## 安装
```bash
cargo build --release
# 或从 Release 页面下载预编译二进制
```
配置数据库连接:
```bash
export DATABASE_URL=postgres://postgres:<password>@<host>:5432/secrets
# 或在项目根目录创建 .env 文件写入上述变量
```
## 使用
```bash
# 查看版本
secrets -V
secrets --version
# 查看帮助
secrets --help
secrets -h
# 查看子命令帮助
secrets help add
secrets help search
secrets help delete
# 添加服务器
secrets add -n refining --kind server --name my-server \
--tag aliyun --tag shanghai \
-m ip=1.2.3.4 -m desc="My Server" \
-s username=root \
-s ssh_key=@./keys/my.pem
# 添加服务凭据
secrets add -n refining --kind service --name gitea \
-m url=https://gitea.example.com \
-s token=<token>
# 搜索(默认隐藏敏感字段)
secrets search
secrets search -n refining --kind server
secrets search --tag hongkong
secrets search -q mqtt # 关键词匹配 name / metadata / tags
secrets search -n refining --kind service --name gitea --show-secrets
# 删除
secrets delete -n refining --kind server --name my-server
```
## 数据模型
单张 `secrets` 表,首次连接自动建表。
| 字段 | 说明 |
|------|------|
| `namespace` | 一级隔离,如 `refining``ricnsmart` |
| `kind` | 记录类型,如 `server``service`(可自由扩展) |
| `name` | 人类可读唯一标识 |
| `tags` | 多维标签,如 `["aliyun","hongkong"]` |
| `metadata` | 明文描述信息ip、desc、domains 等) |
| `encrypted` | 敏感凭据ssh_key、password、token 等MVP 阶段明文存储,预留加密字段 |
`-m` / `--meta` 写入 `metadata``-s` / `--secret` 写入 `encrypted``value=@file` 从文件读取内容。
## 项目结构
```
src/
main.rs # CLI 入口clap
db.rs # 连接池 + auto-migrate
models.rs # Secret 结构体
commands/
add.rs # upsert
search.rs # 多条件查询
delete.rs # 删除
scripts/
seed-data.sh # 导入 refining / ricnsmart 全量数据
```
## CI/CDGitea Actions
推送 `main` 分支时自动fmt/clippy 检查 → musl 构建 → 创建 Release 并上传二进制。
**首次使用需配置 Actions 变量和 Secrets**
```bash
# 需有 ~/.config/gitea/config.envGITEA_URL、GITEA_TOKEN、GITEA_WEBHOOK_URL
./scripts/setup-gitea-actions.sh
```
- `RELEASE_TOKEN`SecretGitea PAT用于创建 Release 上传二进制
- `WEBHOOK_URL`Variable飞书通知可选
详见 [AGENTS.md](AGENTS.md)。

View File

@@ -1,544 +0,0 @@
# AI-Native Secret Manager 设计文档
面向 AI AgentCursor / OpenCode和开发者的轻量级 Secret 管理工具。通过 CLI 提供安全的环境变量注入secret 在使用阶段永远不进入 LLM 上下文。
## 背景与动机
### 当前痛点
在使用 AI 编码助手Cursor、OpenCode进行服务器运维、部署等操作时经常需要提供敏感凭证
```
# 当前做法:明文存储 + 聊天中粘贴
~/.../ricnsmart/config.toml ← 明文 TOMLiCloud 同步
~/.../refining/config.toml ← 包含 AK/SK、密码、API Key
~/.../*/keys/*.pem ← SSH 私钥
聊天中直接粘贴 AccessKey ← 明文进入 LLM context → 发送到云端 API
```
**核心安全问题**Secret 明文反复进入 LLM 的上下文窗口,被发送到 Anthropic/OpenAI 等远程服务器,并留存在聊天历史中。
### 设计目标
1. **使用阶段 Secret 不进入 LLM 上下文**AI Agent 只知道环境变量名(`$VAR_NAME`),不知道值
2. **跨设备、跨平台**macOS / Windows / Linux 多台电脑共享同一套 secrets
3. **面向 AI Agent**AI 通过 CLI 进行 CRUD 和环境变量注入
4. **人性化管理**Web UI 提供可视化的 secret 管理界面
5. **轻量安全**客户端加密CLI + WASMServer 端永远不接触敏感明文
### 市场定位
| 工具 | 定位 | AI Agent 支持 | 客户端加密 | 自托管 |
|------|------|--------------|-----------|--------|
| HashiCorp Vault | 企业级 Secret 管理 | 无 | 否 | 是 |
| Infisical | 开发团队 Secret 管理 | 无 | 有 | 是 |
| 1Password MCP | 消费级密码管理 + AI | 有SaaS | 有 | 否 |
| SOPS / age | 文件加密 | 无 | 是 | - |
| **本项目** | **AI Agent 的 Secret 注入** | **核心功能** | **是** | **是** |
## 架构
### 整体架构
```
Rust crypto crate加密核心
┌──────────────────────────┐
│ AES-256-GCM / Argon2id │
│ DEK 管理 / 信封加密 │
└──────┬──────────┬─────────┘
│ │
编译为 native │ │ 编译为 WASM
│ │
┌────────────────────┐ │ ┌────┴───────────────┐
│ CLI (Rust 跨平台) │◄──────┘ │ Web UI (浏览器) │
│ + OS Keychain │ │ + WASM 加密模块 │
│ macOS / Win / Linux│ │ + wasm-bindgen │
└─────────┬──────────┘ └─────────┬──────────┘
│ HTTPS │ HTTPS
└──────────────┬───────────────────┘
┌─────────┴──────────┐
│ Remote Server │
│ (Rust / Axum) │
│ ├─ HTTPS API │
│ └─ 托管 Web UI + │
│ WASM 静态文件 │
└─────────┬──────────┘
┌─────────┴──────────┐
│ PostgreSQL 18 │
└────────────────────┘
```
### 组件
| 组件 | 职责 | 技术选型 |
|------|------|---------|
| **crypto** | 加密核心AES-256-GCM + Argon2id + 信封加密) | Rust crate编译为 native + wasm32 |
| **server** | HTTPS API、数据库 CRUD、认证、托管静态文件 | Rust / Axum |
| **cli** | 客户端加解密、OS Keychain、环境变量注入、CRUD | Rust / clap |
| **web (WASM)** | 浏览器端加解密绑定 | Rust / wasm-bindgen |
| **web-ui** | 可视化 Secret 管理界面 | HTML / JS / CSS |
| **PostgreSQL 18** | 持久化存储(密文 + 明文元数据) | 云端 PG |
### Cargo Workspace 结构
```
secrets/
├── crates/
│ ├── crypto/ ← 加密核心(编译目标: native + wasm32
│ ├── server/ ← Axum HTTPS Server
│ ├── cli/ ← CLI 客户端(链接 crypto native
│ └── web/ ← WASM 绑定wasm-bindgen链接 crypto wasm
├── web-ui/ ← 前端代码(调用 WASM 模块)
└── Cargo.toml ← workspace 根配置
```
## 核心设计
### 1. 安全模型:分层暴露策略
Secret 在不同生命周期阶段有不同的暴露等级:
| 阶段 | Secret 暴露情况 | 说明 |
|------|----------------|------|
| **创建** | 可能经过 LLM | 用户通过聊天告知 AI或 AI 执行 `secrets set` 命令。一次性暴露,可接受 |
| **使用** | 不经过 LLM | AI 只引用 `$VAR_NAME`,本地 shell 展开后执行。反复使用,始终安全 |
| **generate** | 完全不经过 LLM | CLI 本地生成随机密码 → 本地加密 → 密文发送 Server。值不经过 AI |
**核心价值**:一个 secret 可能被使用几十上百次,只有创建时可能暴露一次,之后的所有使用都通过环境变量引用,安全。
工作流示例:
```
# 创建阶段一次性secret 可能经过 LLM
AI Agent: secrets set ricnsmart/aliyun.ak "LTAI5t..."
# 使用阶段反复使用secret 不经过 LLM
AI Agent: eval $(secrets inject ricnsmart --export)
AI Agent: ssh -i "$RICNSMART_SSH_KEY" "$RICNSMART_SSH_USER@$RICNSMART_HOST_PRIMARY" "..."
↑ AI 只写变量名,本地 shell 展开后执行
# 生成阶段secret 完全不经过 LLM
AI Agent: secrets generate ricnsmart/db.password --length 32
→ CLI 本地生成 → 本地加密 → 密文存 Server
→ 返回: "已生成并存储,环境变量名: RICNSMART_DB_PASSWORD"
```
### SSH 远程命令场景
环境变量注入天然支持 SSH 远程命令场景:
```bash
eval $(secrets inject ricnsmart --export)
ssh user@server "mysql -u root -p'$DB_PASSWORD' -e 'CREATE DATABASE app'"
```
执行过程:
1. `eval` 将 secrets 注入当前 shell 环境变量
2. AI 写命令只引用 `$DB_PASSWORD`(变量名)
3. **本地 shell 展开** `$DB_PASSWORD` 为真实值
4. 展开后的命令通过 **SSH 加密通道**传输到远程执行
5. AI 始终只看到变量名,从不看到实际值
### 2. 加密模型:客户端加密 + 混合存储
采用**混合加密模型**:用户决定每条 secret 是否加密。敏感数据客户端加密后存密文,非敏感数据明文存储以支持搜索。
#### 敏感数据:信封加密
```
secret_value
→ AES-256-GCM(DEK) → 密文 → 存入 PostgreSQL (encrypted_value)
DEK (Data Encryption Key)
→ AES-256-GCM(Master Key) → 加密 DEK → 存入 PostgreSQL (encrypted_dek)
Master Key
→ Argon2id(password, salt) → 派生 → 存入 OS Keychain
```
**每条加密的 secret 使用独立的 DEK**,限制单个密钥泄露的影响范围。
PostgreSQL 只存密文和加密后的 DEKServer 被攻破也无法获取明文。
#### 非敏感数据:明文存储
用户选择不加密的 secret如 URL、用户名等以明文存储支持服务端搜索和索引。
#### 元数据JSONB 灵活字段
每条 secret 附带 JSONB 元数据字段,始终为明文,用于描述、标签、备注等。支持 GIN 索引高效搜索。
### 3. 跨设备 Master Key 同步
采用**密码派生**方案,跨平台支持:
```bash
# 新设备初始化(只需一次)
secrets init --server "https://secrets.example.com"
# 输入 master password
# → 从 Server 获取 Argon2id salt
# → 本地派生 Master Key
# → 存入 OS Keychain
# 之后自动从 Keychain 获取,无需再次输入
```
所有设备使用相同密码,派生出相同的 Master Key。Argon2id salt 存储在 Server 端 PostgreSQL 中。
| 平台 | Keychain 实现 |
|------|--------------|
| macOS | Keychain Services |
| Windows | Credential Manager |
| Linux | libsecret (Secret Service API) |
Rust `keyring` crate 统一封装三个平台。
### 4. Web UI 浏览器端加密Rust WASM
Web UI 通过 Rust 编译为 WebAssembly 实现浏览器端加密,与 CLI **共享完全相同的加密逻辑**
```
用户打开 Web UI
→ 输入 master password
→ WASM 模块中 Argon2id 派生 master keysalt 从 Server API 获取)
→ master key 仅存于浏览器内存
创建敏感 secret:
→ WASM 加密 → 密文通过 HTTPS API 发送到 Server
查看敏感 secret:
→ Server 返回密文 → WASM 解密 → 浏览器中显示
页面关闭:
→ master key 从内存消失
```
**Server 端全程不接触明文**,无论是 CLI 还是 Web UI 访问,安全模型一致。
使用 WASM 而非 Web Crypto API 的原因:
- Web Crypto API 不支持 Argon2id只有 PBKDF2
- WASM 直接复用 CLI 的 Rust 加密代码,保证一致性
- Rust 内存安全 + 成熟加密 crate优于 JS 实现
### 5. 环境变量命名规则
```
project: ricnsmart, key: aliyun.access_key_id
→ 环境变量: RICNSMART_ALIYUN_ACCESS_KEY_ID
project: refining, key: grafana.password
→ 环境变量: REFINING_GRAFANA_PASSWORD
规则: upper(project) + "_" + upper(key.replace(".", "_"))
```
## Server API
### 认证
API Token 认证。首次 `secrets init` 时生成 Token存入客户端 OS Keychain。所有 API 请求通过 `Authorization: Bearer <token>` 头认证。
### 接口列表
```
# 配置
GET /api/config/:key 获取配置(如 argon2_salt
POST /api/config 设置配置
# 项目
GET /api/projects 列出所有项目
POST /api/projects 创建项目
DELETE /api/projects/:id 删除项目
# Secrets
GET /api/secrets 列出 secrets支持过滤不含加密值
?project=ricnsmart
&pattern=ssh.*
&kind=file
POST /api/secrets 创建 secret接收密文或明文
GET /api/secrets/:id 获取 secret 详情(含加密值密文,客户端解密)
PUT /api/secrets/:id 更新 secret
DELETE /api/secrets/:id 删除 secret
# 静态文件
GET / Web UI 入口
GET /assets/* Web UI 静态资源 + WASM 文件
```
### 请求/响应示例
**创建加密 secret**
```json
POST /api/secrets
{
"project": "ricnsmart",
"key": "aliyun.access_key_id",
"is_encrypted": true,
"kind": "text",
"encrypted_value": "<base64 密文>",
"encrypted_dek": "<base64 加密后的 DEK>",
"nonce": "<base64>",
"dek_nonce": "<base64>",
"metadata": {
"description": "阿里云主账号 AccessKey",
"tags": ["cloud", "aliyun", "production"],
"env_var": "RICNSMART_ALIYUN_ACCESS_KEY_ID"
}
}
```
**创建非加密 secret**
```json
POST /api/secrets
{
"project": "ricnsmart",
"key": "gitea.url",
"is_encrypted": false,
"kind": "text",
"plaintext_value": "https://gitea.refining.dev",
"metadata": {
"description": "Gitea 服务地址",
"tags": ["service", "gitea"]
}
}
```
**列出 secrets 响应**(不含加密值):
```json
[
{
"id": "uuid-...",
"project": "ricnsmart",
"key": "aliyun.access_key_id",
"is_encrypted": true,
"kind": "text",
"metadata": {
"description": "阿里云主账号 AccessKey",
"tags": ["cloud", "aliyun", "production"]
},
"created_at": "2025-03-06T10:00:00Z",
"updated_at": "2025-03-06T10:00:00Z"
}
]
```
## 数据模型
### PostgreSQL 18 Schema
```sql
CREATE TABLE projects (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT UNIQUE NOT NULL, -- "ricnsmart", "refining"
description TEXT,
created_at TIMESTAMPTZ DEFAULT now()
);
CREATE TABLE secrets (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
project_id UUID REFERENCES projects(id) ON DELETE CASCADE,
key TEXT NOT NULL, -- "aliyun.access_key_id"
-- 敏感数据(客户端加密后存储)
encrypted_value BYTEA, -- AES-256-GCM 密文
encrypted_dek BYTEA, -- 加密后的 DEK
nonce BYTEA, -- GCM nonce
dek_nonce BYTEA, -- DEK 加密的 nonce
-- 非敏感数据(明文存储,可搜索)
plaintext_value TEXT, -- 用户选择不加密时存储明文
-- 灵活元数据(明文 JSONB可搜索
metadata JSONB DEFAULT '{}', -- 描述、标签、备注等
is_encrypted BOOLEAN NOT NULL DEFAULT true,
kind TEXT NOT NULL DEFAULT 'text', -- "text", "ssh-key", "json", "file"
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now(),
UNIQUE(project_id, key)
);
-- JSONB GIN 索引,支持高效搜索
CREATE INDEX idx_secrets_metadata ON secrets USING GIN (metadata);
-- 按 kind 过滤
CREATE INDEX idx_secrets_kind ON secrets (kind);
-- 全局配置Argon2id salt 等)
CREATE TABLE config (
key TEXT PRIMARY KEY,
value BYTEA NOT NULL
);
-- 审计日志
CREATE TABLE audit_log (
id BIGSERIAL PRIMARY KEY,
secret_id UUID REFERENCES secrets(id) ON DELETE SET NULL,
action TEXT NOT NULL, -- "read", "write", "delete", "inject", "generate"
device TEXT, -- 设备标识
detail TEXT, -- 额外信息
created_at TIMESTAMPTZ DEFAULT now()
);
```
### 从现有 TOML 配置迁移的映射
```
# ricnsmart/config.toml
[[servers]] → ricnsmart/server.tianchu-primary (kind=json, encrypted)
hostname = "..." encrypted_value: {...}
public_ip = "8.153.204.96" metadata: {"description": "天储主服务器"}
[services.gitea] → ricnsmart/gitea.url (kind=text, 不加密)
url = "https://gitea.refining.dev" plaintext_value: "https://gitea.refining.dev"
ricnsmart/gitea.token (kind=text, 加密)
token = "92a627..." encrypted_value: <密文>
# SSH 密钥 → ricnsmart/ssh-key.ricnsmart-sh (kind=file, 加密)
~/.../keys/ricnsmart-sh.pem encrypted_value: <密文>
metadata: {"original_path": "ricnsmart-sh.pem"}
```
## CLI 命令设计
CLI 是 AI Agent 和人共用的主要接口。
```bash
# ===== 初始化 =====
secrets init --server "https://secrets.example.com"
# 输入 master password → 从 Server 获取 salt → Argon2id 派生 Master Key
# → Master Key + API Token 存入 OS Keychain
# ===== 项目管理 =====
secrets project list # 列出所有项目
secrets project create ricnsmart # 创建项目
secrets project delete ricnsmart # 删除项目(需确认)
# ===== Secret CRUD =====
secrets set ricnsmart/aliyun.ak "LTAI5t..." # 创建(默认加密)
secrets set ricnsmart/gitea.url "https://..." --no-encrypt # 创建(不加密)
secrets set ricnsmart/ssh.key --file ~/.ssh/ricnsmart.pem # 从文件创建(加密)
secrets set ricnsmart/server.primary --json '{"host":...}' # JSON 类型(加密)
secrets get ricnsmart/aliyun.ak # 获取值(本地解密,输出到 stdout
secrets list # 列出所有(不显示值)
secrets list ricnsmart/ # 按项目过滤
secrets list --pattern "ssh.*" # 模糊匹配 key
secrets list --kind file # 按类型过滤
secrets delete ricnsmart/aliyun.ak # 删除
# ===== 生成随机密码 =====
secrets generate ricnsmart/db.password --length 32 # 默认字符集
secrets generate ricnsmart/api.key --length 64 --charset hex # 指定字符集
# → 本地生成 → 本地加密 → 密文存 Server
# → 输出: "已生成并存储,环境变量名: RICNSMART_DB_PASSWORD"
# ===== 环境变量注入 =====
# macOS / Linux (bash/zsh)
eval $(secrets inject ricnsmart --export)
# Windows PowerShell
secrets inject ricnsmart --export-ps | Invoke-Expression
# 通用方式(所有平台,启动子进程)
secrets inject ricnsmart -- ./app
# 选择性注入
secrets inject ricnsmart --keys "aliyun.*,ssh.*" --export
# ===== 导入 =====
secrets import config.toml --project ricnsmart # 从 TOML 导入
secrets import config.toml --project ricnsmart --dry-run # 预览
```
## 安全注意事项
### Secret 生命周期
```
创建: CLI set / generate → 本地加密 → HTTPS → Server 存密文到 PG
Web UI 输入 → WASM 加密 → HTTPS → Server 存密文到 PG
读取: CLI inject → HTTPS 拉取密文 → 本地解密 → 注入环境变量
Web UI 查看 → HTTPS 拉取密文 → WASM 解密 → 浏览器显示
使用: AI 写 shell 命令引用 $VAR → 本地 shell 展开为真实值 → 执行
清理: 进程退出后环境变量消失Web UI 关闭后 master key 消失
```
### 威胁模型
| 威胁 | 防御措施 |
|------|---------|
| PostgreSQL 被攻破 | 客户端加密PG 只有密文Server 不持有 Master Key |
| Server 被攻破 | 敏感数据为客户端密文,非敏感数据暴露但不含凭证 |
| 设备丢失 | Master Key 在 OS Keychain需要设备密码/生物识别) |
| LLM 提供商窥探 | 使用阶段 secret 不进入 LLM context只有变量名 |
| 命令输出泄露 secret | 可选输出脱敏Phase 2替换已知 secret 值为 `***` |
| Master Password 弱 | Argon2id 高计算成本推荐m=64MB, t=3, p=4 |
| 传输层攻击 | HTTPS/TLS 加密传输 |
| WASM 模块被篡改 | Server 托管 WASM可通过 SRI (Subresource Integrity) 校验 |
### 不防御的场景
- 设备上的 root 权限攻击者(可直接读取进程环境变量或内存)
- 用户主动将 secret 粘贴到聊天中(行为问题,非技术问题)
- AI 通过 `echo $VAR` 读取环境变量值并返回到 LLM 上下文已知弱点Phase 2 输出脱敏可缓解)
## 技术选型
| 组件 | 选择 | 理由 |
|------|------|------|
| 语言 | Rust | 单二进制分发、加密库成熟、性能好、编译为 native + WASM |
| 加密 | `aes-gcm` crate | AEAD业界标准 |
| 密钥派生 | `argon2` crate | 抗 GPU/ASICOWASP 推荐,支持 WASM 编译 |
| OS Keychain | `keyring` crate | 跨平台macOS Keychain / Windows Credential Manager / Linux libsecret |
| 数据库 | PostgreSQL 18 + `sqlx` crate | async、编译期 SQL 检查、JSONB 原生支持 |
| HTTP Server | `axum` crate | Rust 生态主流、tokio 驱动、tower 中间件 |
| WASM 绑定 | `wasm-bindgen` + `wasm-pack` | Rust → WASM 标准工具链 |
| CLI 框架 | `clap` crate | Rust 生态标准 |
| 序列化 | `serde` + `serde_json` | JSON 处理标准库 |
## 开发计划
### Phase 1: MVPServer + CLI
- [ ] Cargo workspace 脚手架(`crypto` + `server` + `cli`
- [ ] `crypto` crateAES-256-GCM + Argon2id + 信封加密
- [ ] PostgreSQL 18 schema + 迁移脚本
- [ ] `server` crateAxum HTTPS APICRUD + config + 认证)
- [ ] `cli` crateinit / set / get / list / delete / generate / inject
- [ ] 跨平台 OS Keychain 集成macOS / Windows / Linux
- [ ] 跨平台 inject 命令bash/zsh + PowerShell
- [ ] `secrets import config.toml` 从现有配置导入
### Phase 2: Web UI + 增强
- [ ] `web` cratecrypto 编译为 WASM + wasm-bindgen 绑定
- [ ] Web UI 前端CRUD 管理界面 + WASM 浏览器端加解密
- [ ] Server 托管 Web UI + WASM 静态文件
- [ ] TOTP 支持(存储 TOTP seed生成一次性验证码
- [ ] 输出脱敏(可选,替换已知 secret 值为 `***`
- [ ] 审计日志
### Phase 3: 扩展
- [ ] MCP Server可选 AI 增强stdio 模式)
- [ ] 模板渲染(`secrets template config.tpl > config.yaml`
- [ ] 团队共享(多用户 + RBAC
- [ ] Secret 轮换提醒
- [ ] CI/CD 集成Gitea Actions 中使用)
## 参考
- [MCP 协议规范](https://modelcontextprotocol.io/)
- [Infisical](https://github.com/Infisical/infisical) — 开源 Secret Manager
- [age](https://github.com/FiloSottile/age) — 文件加密工具
- [SOPS](https://github.com/getsops/sops) — Mozilla 的加密文件编辑器
- [Rust `aes-gcm`](https://docs.rs/aes-gcm/) — AEAD 加密
- [Rust `argon2`](https://docs.rs/argon2/) — 密码哈希
- [Rust `keyring`](https://docs.rs/keyring/) — 跨平台 Keychain
- [Rust `axum`](https://docs.rs/axum/) — HTTP Server 框架
- [wasm-bindgen](https://rustwasm.github.io/wasm-bindgen/) — Rust WASM 绑定
- [wasm-pack](https://rustwasm.github.io/wasm-pack/) — Rust WASM 打包工具

177
scripts/setup-gitea-actions.sh Executable file
View File

@@ -0,0 +1,177 @@
#!/usr/bin/env bash
#
# 为 refining/secrets 仓库配置 Gitea Actions 所需的 Secrets 和 Variables
# 参考: .gitea/workflows/secrets.yml
#
# 所需配置:
# - secrets.RELEASE_TOKEN (必选) Release 上传用,值为 Gitea PAT
# - vars.WEBHOOK_URL (可选) 飞书通知
#
# 注意: Gitea 不允许 secret/variable 名以 GITEA_ 或 GITHUB_ 开头,故使用 RELEASE_TOKEN
#
# 用法:
# 1. 从 ~/.config/gitea/config.env 读取 GITEA_URL, GITEA_TOKEN, GITEA_WEBHOOK_URL
# 2. 或通过环境变量覆盖: GITEA_TOKEN作为 RELEASE_TOKEN 的值), WEBHOOK_URL
# 3. 或使用 secrets CLI 获取: 需 DATABASE_URL从 refining/service gitea 读取
#
set -e
OWNER="refining"
REPO="secrets"
# 解析参数
USE_SECRETS_CLI=false
while [[ $# -gt 0 ]]; do
case $1 in
--from-secrets) USE_SECRETS_CLI=true; shift ;;
-h|--help)
echo "用法: $0 [--from-secrets]"
echo ""
echo " --from-secrets 从 secrets CLI (refining/service gitea) 获取 token 和 webhook_url"
echo " 否则从 ~/.config/gitea/config.env 读取"
echo ""
echo "环境变量覆盖:"
echo " GITEA_URL Gitea 实例地址"
echo " GITEA_TOKEN 用于 Release 上传的 PAT (创建 RELEASE_TOKEN secret)"
echo " WEBHOOK_URL 飞书 Webhook URL (创建 variable可选)"
exit 0
;;
*) shift ;;
esac
done
# 加载配置
load_config() {
local config="$HOME/.config/gitea/config.env"
if [[ -f "$config" ]]; then
# shellcheck source=/dev/null
source "$config"
fi
}
# 从 secrets CLI 获取 gitea 凭据
fetch_from_secrets() {
if ! command -v secrets &>/dev/null; then
echo "❌ secrets CLI 未找到,请先构建: cargo build --release" >&2
return 1
fi
# 输出 JSON 格式便于解析;需要 --show-secrets
# secrets 当前无 JSON 输出,用简单解析
local out
out=$(secrets search -n refining --kind service -q gitea --show-secrets 2>/dev/null || true)
if [[ -z "$out" ]]; then
echo "❌ 未找到 refining/service gitea 记录" >&2
return 1
fi
# 简化:从 metadata 和 secrets 中提取,实际格式需根据 search 输出调整
# 此处仅作占位,实际解析较复杂;建议用户优先用 config.env
echo "⚠️ --from-secrets 暂不支持自动解析,请使用 config.env 或环境变量" >&2
return 1
}
load_config
# 优先使用环境变量
if [[ -n "$GITEA_TOKEN" && -z "$GITEA_URL" ]]; then
echo "❌ 请设置 GITEA_URL (或确保 config.env 中有)" >&2
exit 1
fi
if [[ -z "$GITEA_URL" ]]; then
echo "❌ GITEA_URL 未配置"
echo " 请创建 ~/.config/gitea/config.env 或设置环境变量" >&2
exit 1
fi
# 去掉 URL 尾部斜杠
GITEA_URL="${GITEA_URL%/}"
# 确保使用 /api/v1 基础路径(若用户只写了根 URL
[[ "$GITEA_URL" != *"/api/v1"* ]] || true
API_BASE="${GITEA_URL}/api/v1"
# 获取 GITEA_TOKEN作为 workflow 中 secrets.RELEASE_TOKEN 的值)
if [[ -z "$GITEA_TOKEN" ]]; then
if $USE_SECRETS_CLI; then
fetch_from_secrets || exit 1
fi
echo "❌ GITEA_TOKEN 未配置"
echo " 在 ~/.config/gitea/config.env 中设置,或 export GITEA_TOKEN=xxx" >&2
echo " Token 需具备 repo 写权限(创建 Release、上传附件" >&2
exit 1
fi
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo "配置 Gitea Actions: $OWNER/$REPO"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo ""
# 1. 创建 Secret: RELEASE_TOKEN
echo "1. 创建 Secret: RELEASE_TOKEN"
encoded=$(echo -n "$GITEA_TOKEN" | base64)
resp=$(curl -s -w "\n%{http_code}" -X PUT \
-H "Authorization: token $GITEA_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"data\":\"${encoded}\"}" \
"${API_BASE}/repos/${OWNER}/${REPO}/actions/secrets/RELEASE_TOKEN")
http_code=$(echo "$resp" | tail -n1)
body=$(echo "$resp" | sed '$d')
if [[ "$http_code" == "200" || "$http_code" == "201" || "$http_code" == "204" ]]; then
echo " ✓ RELEASE_TOKEN 已创建/更新"
else
echo " ❌ 失败 (HTTP $http_code)" >&2
echo "$body" | jq -r '.message // .' 2>/dev/null || echo "$body" >&2
exit 1
fi
# 2. 创建/更新 Variable: WEBHOOK_URL可选
WEBHOOK_VALUE="${WEBHOOK_URL:-$GITEA_WEBHOOK_URL}"
if [[ -n "$WEBHOOK_VALUE" ]]; then
echo ""
echo "2. 创建/更新 Variable: WEBHOOK_URL"
resp=$(curl -s -w "\n%{http_code}" -X POST \
-H "Authorization: token $GITEA_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"value\":\"${WEBHOOK_VALUE}\"}" \
"${API_BASE}/repos/${OWNER}/${REPO}/actions/variables/WEBHOOK_URL")
http_code=$(echo "$resp" | tail -n1)
body=$(echo "$resp" | sed '$d')
if [[ "$http_code" == "200" || "$http_code" == "201" || "$http_code" == "204" ]]; then
echo " ✓ WEBHOOK_URL 已创建/更新"
elif [[ "$http_code" == "409" ]]; then
# 变量已存在,用 PUT 更新
resp=$(curl -s -w "\n%{http_code}" -X PUT \
-H "Authorization: token $GITEA_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"value\":\"${WEBHOOK_VALUE}\"}" \
"${API_BASE}/repos/${OWNER}/${REPO}/actions/variables/WEBHOOK_URL")
http_code=$(echo "$resp" | tail -n1)
if [[ "$http_code" == "200" || "$http_code" == "204" ]]; then
echo " ✓ WEBHOOK_URL 已更新"
else
echo " ⚠ 更新失败 (HTTP $http_code)" >&2
fi
else
echo " ⚠ 失败 (HTTP $http_code),飞书通知将不可用" >&2
fi
else
echo ""
echo "2. 跳过 WEBHOOK_URL未配置 GITEA_WEBHOOK_URL 或 WEBHOOK_URL"
echo " 飞书通知将不可用;如需可后续在仓库 Settings → Variables 中添加"
fi
echo ""
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo "✓ 配置完成"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo ""
echo "Workflow 将使用:"
echo " - secrets.RELEASE_TOKEN 创建 Release 并上传二进制"
echo " - vars.WEBHOOK_URL 发送飞书通知(如已配置)"
echo ""
echo "推送代码触发构建:"
echo " git push origin main"
echo ""

87
src/commands/add.rs Normal file
View File

@@ -0,0 +1,87 @@
use anyhow::Result;
use serde_json::{Map, Value};
use sqlx::PgPool;
use std::fs;
/// Parse "key=value" entries. Value starting with '@' reads from file.
fn parse_kv(entry: &str) -> Result<(String, String)> {
let (key, raw_val) = entry.split_once('=').ok_or_else(|| {
anyhow::anyhow!(
"Invalid format '{}'. Expected: key=value or key=@file",
entry
)
})?;
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()
};
Ok((key.to_string(), value))
}
fn build_json(entries: &[String]) -> Result<Value> {
let mut map = Map::new();
for entry in entries {
let (key, value) = parse_kv(entry)?;
map.insert(key, Value::String(value));
}
Ok(Value::Object(map))
}
pub async fn run(
pool: &PgPool,
namespace: &str,
kind: &str,
name: &str,
tags: &[String],
meta_entries: &[String],
secret_entries: &[String],
) -> Result<()> {
let metadata = build_json(meta_entries)?;
let encrypted = build_json(secret_entries)?;
sqlx::query(
r#"
INSERT INTO secrets (namespace, kind, name, tags, metadata, encrypted, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, NOW())
ON CONFLICT (namespace, kind, name)
DO UPDATE SET
tags = EXCLUDED.tags,
metadata = EXCLUDED.metadata,
encrypted = EXCLUDED.encrypted,
updated_at = NOW()
"#,
)
.bind(namespace)
.bind(kind)
.bind(name)
.bind(tags)
.bind(&metadata)
.bind(&encrypted)
.execute(pool)
.await?;
println!("Added: [{}/{}] {}", namespace, kind, name);
if !tags.is_empty() {
println!(" tags: {}", tags.join(", "));
}
if !meta_entries.is_empty() {
let keys: Vec<&str> = meta_entries
.iter()
.filter_map(|s| s.split_once('=').map(|(k, _)| k))
.collect();
println!(" metadata: {}", keys.join(", "));
}
if !secret_entries.is_empty() {
let keys: Vec<&str> = secret_entries
.iter()
.filter_map(|s| s.split_once('=').map(|(k, _)| k))
.collect();
println!(" secrets: {}", keys.join(", "));
}
Ok(())
}

20
src/commands/delete.rs Normal file
View File

@@ -0,0 +1,20 @@
use anyhow::Result;
use sqlx::PgPool;
pub async fn run(pool: &PgPool, namespace: &str, kind: &str, name: &str) -> Result<()> {
let result = sqlx::query(
"DELETE FROM secrets WHERE namespace = $1 AND kind = $2 AND name = $3",
)
.bind(namespace)
.bind(kind)
.bind(name)
.execute(pool)
.await?;
if result.rows_affected() == 0 {
println!("Not found: [{}/{}] {}", namespace, kind, name);
} else {
println!("Deleted: [{}/{}] {}", namespace, kind, name);
}
Ok(())
}

3
src/commands/mod.rs Normal file
View File

@@ -0,0 +1,3 @@
pub mod add;
pub mod delete;
pub mod search;

104
src/commands/search.rs Normal file
View File

@@ -0,0 +1,104 @@
use anyhow::Result;
use sqlx::PgPool;
use crate::models::Secret;
pub async fn run(
pool: &PgPool,
namespace: Option<&str>,
kind: Option<&str>,
tag: Option<&str>,
query: Option<&str>,
show_secrets: bool,
) -> Result<()> {
let mut conditions: Vec<String> = Vec::new();
let mut idx: i32 = 1;
if namespace.is_some() {
conditions.push(format!("namespace = ${}", idx));
idx += 1;
}
if kind.is_some() {
conditions.push(format!("kind = ${}", idx));
idx += 1;
}
if tag.is_some() {
conditions.push(format!("tags @> ARRAY[${}]", idx));
idx += 1;
}
if query.is_some() {
conditions.push(format!(
"(name ILIKE ${i} OR namespace ILIKE ${i} OR kind ILIKE ${i} OR metadata::text ILIKE ${i} OR EXISTS (SELECT 1 FROM unnest(tags) t WHERE t ILIKE ${i}))",
i = idx
));
}
let where_clause = if conditions.is_empty() {
String::new()
} else {
format!("WHERE {}", conditions.join(" AND "))
};
let sql = format!(
"SELECT * FROM secrets {} ORDER BY namespace, kind, name",
where_clause
);
let mut q = sqlx::query_as::<_, Secret>(&sql);
if let Some(v) = namespace {
q = q.bind(v);
}
if let Some(v) = kind {
q = q.bind(v);
}
if let Some(v) = tag {
q = q.bind(v);
}
if let Some(v) = query {
q = q.bind(format!("%{}%", v));
}
let rows = q.fetch_all(pool).await?;
if rows.is_empty() {
println!("No records found.");
return Ok(());
}
for row in &rows {
println!(
"[{}/{}] {}",
row.namespace, row.kind, row.name,
);
println!(" id: {}", row.id);
if !row.tags.is_empty() {
println!(" tags: [{}]", row.tags.join(", "));
}
let meta_obj = row.metadata.as_object();
if let Some(m) = meta_obj {
if !m.is_empty() {
println!(" metadata: {}", serde_json::to_string_pretty(&row.metadata)?);
}
}
if show_secrets {
println!(" secrets: {}", serde_json::to_string_pretty(&row.encrypted)?);
} else {
let keys: Vec<String> = row
.encrypted
.as_object()
.map(|m| m.keys().cloned().collect())
.unwrap_or_default();
if !keys.is_empty() {
println!(" secrets: [{}] (--show-secrets to reveal)", keys.join(", "));
}
}
println!(" created: {}", row.created_at.format("%Y-%m-%d %H:%M:%S UTC"));
println!();
}
println!("{} record(s) found.", rows.len());
Ok(())
}

44
src/db.rs Normal file
View File

@@ -0,0 +1,44 @@
use anyhow::Result;
use sqlx::postgres::PgPoolOptions;
use sqlx::PgPool;
pub async fn create_pool(database_url: &str) -> Result<PgPool> {
let pool = PgPoolOptions::new()
.max_connections(5)
.connect(database_url)
.await?;
Ok(pool)
}
pub async fn migrate(pool: &PgPool) -> Result<()> {
sqlx::raw_sql(
r#"
CREATE TABLE IF NOT EXISTS secrets (
id UUID PRIMARY KEY DEFAULT uuidv7(),
namespace VARCHAR(64) NOT NULL,
kind VARCHAR(64) NOT NULL,
name VARCHAR(256) NOT NULL,
tags TEXT[] NOT NULL DEFAULT '{}',
metadata JSONB NOT NULL DEFAULT '{}',
encrypted JSONB NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(namespace, kind, name)
);
-- idempotent column add for existing tables
DO $$ BEGIN
ALTER TABLE secrets ADD COLUMN IF NOT EXISTS metadata JSONB NOT NULL DEFAULT '{}';
EXCEPTION WHEN OTHERS THEN NULL;
END $$;
CREATE INDEX IF NOT EXISTS idx_secrets_namespace ON secrets(namespace);
CREATE INDEX IF NOT EXISTS idx_secrets_kind ON secrets(kind);
CREATE INDEX IF NOT EXISTS idx_secrets_tags ON secrets USING GIN(tags);
CREATE INDEX IF NOT EXISTS idx_secrets_metadata ON secrets USING GIN(metadata jsonb_path_ops);
"#,
)
.execute(pool)
.await?;
Ok(())
}

132
src/main.rs Normal file
View File

@@ -0,0 +1,132 @@
mod commands;
mod db;
mod models;
use anyhow::Result;
use clap::{Parser, Subcommand};
use dotenvy::dotenv;
#[derive(Parser)]
#[command(name = "secrets", version, about = "Secrets & config manager backed by PostgreSQL")]
struct Cli {
/// Database URL (or set DATABASE_URL env var)
#[arg(long, env = "DATABASE_URL", global = true, default_value = "")]
db_url: String,
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand)]
enum Commands {
/// Add or update a record (upsert)
Add {
/// Namespace (e.g. refining, ricnsmart)
#[arg(short, long)]
namespace: String,
/// Kind of record (server, service, key, ...)
#[arg(long)]
kind: String,
/// Human-readable name
#[arg(long)]
name: String,
/// Tags for categorization (repeatable)
#[arg(long = "tag")]
tags: Vec<String>,
/// Plaintext metadata entry: key=value (repeatable, key=@file reads from file)
#[arg(long = "meta", short = 'm')]
meta: Vec<String>,
/// Secret entry: key=value (repeatable, key=@file reads from file)
#[arg(long = "secret", short = 's')]
secrets: Vec<String>,
},
/// Search records
Search {
/// Filter by namespace
#[arg(short, long)]
namespace: Option<String>,
/// Filter by kind
#[arg(long)]
kind: Option<String>,
/// Filter by tag
#[arg(long)]
tag: Option<String>,
/// Search by keyword (matches name, namespace, kind)
#[arg(short, long)]
query: Option<String>,
/// Reveal encrypted secret values
#[arg(long)]
show_secrets: bool,
},
/// Delete a record
Delete {
/// Namespace
#[arg(short, long)]
namespace: String,
/// Kind
#[arg(long)]
kind: String,
/// Name
#[arg(long)]
name: String,
},
}
#[tokio::main]
async fn main() -> Result<()> {
dotenv().ok();
let cli = Cli::parse();
let db_url = if cli.db_url.is_empty() {
std::env::var("DATABASE_URL").map_err(|_| {
anyhow::anyhow!("DATABASE_URL not set. Use --db-url or set DATABASE_URL env var.")
})?
} else {
cli.db_url.clone()
};
let pool = db::create_pool(&db_url).await?;
db::migrate(&pool).await?;
match &cli.command {
Commands::Add {
namespace,
kind,
name,
tags,
meta,
secrets,
} => {
commands::add::run(&pool, namespace, kind, name, tags, meta, secrets).await?;
}
Commands::Search {
namespace,
kind,
tag,
query,
show_secrets,
} => {
commands::search::run(
&pool,
namespace.as_deref(),
kind.as_deref(),
tag.as_deref(),
query.as_deref(),
*show_secrets,
)
.await?;
}
Commands::Delete {
namespace,
kind,
name,
} => {
commands::delete::run(&pool, namespace, kind, name).await?;
}
}
Ok(())
}

17
src/models.rs Normal file
View File

@@ -0,0 +1,17 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use uuid::Uuid;
#[derive(Debug, Serialize, Deserialize, sqlx::FromRow)]
pub struct Secret {
pub id: Uuid,
pub namespace: String,
pub kind: String,
pub name: String,
pub tags: Vec<String>,
pub metadata: Value,
pub encrypted: Value,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}