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