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
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:
313
.gitea/workflows/secrets.yml
Normal file
313
.gitea/workflows/secrets.yml
Normal 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
2
.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
/target
|
||||
.env
|
||||
107
.vscode/tasks.json
vendored
Normal file
107
.vscode/tasks.json
vendored
Normal 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
130
AGENTS.md
Normal 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 # CI:fmt + clippy + musl 构建 + Release 上传 + 飞书通知
|
||||
.vscode/tasks.json # 本地测试任务(build / search / add+delete roundtrip 等)
|
||||
.env # DATABASE_URL(gitignore,不提交)
|
||||
```
|
||||
|
||||
## 数据库
|
||||
|
||||
- **Host**: `47.117.131.22:5432`(阿里云上海 ECS,PostgreSQL 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 Actions(runner: 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
2400
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
16
Cargo.toml
Normal file
16
Cargo.toml
Normal 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
104
README.md
Normal 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/CD(Gitea Actions)
|
||||
|
||||
推送 `main` 分支时自动:fmt/clippy 检查 → musl 构建 → 创建 Release 并上传二进制。
|
||||
|
||||
**首次使用需配置 Actions 变量和 Secrets:**
|
||||
|
||||
```bash
|
||||
# 需有 ~/.config/gitea/config.env(GITEA_URL、GITEA_TOKEN、GITEA_WEBHOOK_URL)
|
||||
./scripts/setup-gitea-actions.sh
|
||||
```
|
||||
|
||||
- `RELEASE_TOKEN`(Secret):Gitea PAT,用于创建 Release 上传二进制
|
||||
- `WEBHOOK_URL`(Variable):飞书通知,可选
|
||||
|
||||
详见 [AGENTS.md](AGENTS.md)。
|
||||
@@ -1,544 +0,0 @@
|
||||
# AI-Native Secret Manager 设计文档
|
||||
|
||||
面向 AI Agent(Cursor / OpenCode)和开发者的轻量级 Secret 管理工具。通过 CLI 提供安全的环境变量注入,secret 在使用阶段永远不进入 LLM 上下文。
|
||||
|
||||
## 背景与动机
|
||||
|
||||
### 当前痛点
|
||||
|
||||
在使用 AI 编码助手(Cursor、OpenCode)进行服务器运维、部署等操作时,经常需要提供敏感凭证:
|
||||
|
||||
```
|
||||
# 当前做法:明文存储 + 聊天中粘贴
|
||||
~/.../ricnsmart/config.toml ← 明文 TOML,iCloud 同步
|
||||
~/.../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 + WASM),Server 端永远不接触敏感明文
|
||||
|
||||
### 市场定位
|
||||
|
||||
| 工具 | 定位 | 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 只存密文和加密后的 DEK,Server 被攻破也无法获取明文。
|
||||
|
||||
#### 非敏感数据:明文存储
|
||||
|
||||
用户选择不加密的 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 key(salt 从 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/ASIC,OWASP 推荐,支持 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: MVP(Server + CLI)
|
||||
|
||||
- [ ] Cargo workspace 脚手架(`crypto` + `server` + `cli`)
|
||||
- [ ] `crypto` crate:AES-256-GCM + Argon2id + 信封加密
|
||||
- [ ] PostgreSQL 18 schema + 迁移脚本
|
||||
- [ ] `server` crate:Axum HTTPS API(CRUD + config + 认证)
|
||||
- [ ] `cli` crate:init / set / get / list / delete / generate / inject
|
||||
- [ ] 跨平台 OS Keychain 集成(macOS / Windows / Linux)
|
||||
- [ ] 跨平台 inject 命令(bash/zsh + PowerShell)
|
||||
- [ ] `secrets import config.toml` 从现有配置导入
|
||||
|
||||
### Phase 2: Web UI + 增强
|
||||
|
||||
- [ ] `web` crate:crypto 编译为 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
177
scripts/setup-gitea-actions.sh
Executable 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
87
src/commands/add.rs
Normal 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
20
src/commands/delete.rs
Normal 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
3
src/commands/mod.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
pub mod add;
|
||||
pub mod delete;
|
||||
pub mod search;
|
||||
104
src/commands/search.rs
Normal file
104
src/commands/search.rs
Normal 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
44
src/db.rs
Normal 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
132
src/main.rs
Normal 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
17
src/models.rs
Normal 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>,
|
||||
}
|
||||
Reference in New Issue
Block a user