Compare commits

...

20 Commits

Author SHA1 Message Date
voson
f7afd7f819 docs: 同步 CI 触发路径、覆盖式 tag/Release 说明与 RUST_LOG 示例
All checks were successful
Secrets MCP — Build & Release / 检查 / 构建 / 发版 (push) Successful in 2m11s
Secrets MCP — Build & Release / 部署 secrets-mcp (push) Successful in 5s
- AGENTS.md / README:与 workflow 变更路径、远端 tag 覆盖及非 draft Release 行为一致
- deploy/.env.example:补充可选 RUST_LOG 注释

Made-with: Cursor
2026-03-22 16:15:29 +08:00
voson
719bdd7e08 feat(secrets-mcp): public home at /, login at /login (0.2.2)
All checks were successful
Secrets MCP — Build & Release / 检查 / 构建 / 发版 (push) Successful in 3m17s
Secrets MCP — Build & Release / 部署 secrets-mcp (push) Successful in 5s
Bump secrets-mcp to 0.2.2 and sync Cargo.lock.

Add home.html landing with SEO and footer link to the refining/secrets
repository; serve it at / and expose /login for sign-in.

Update OAuth error redirects and dashboard unauthenticated redirects to
/login. Improve login page meta tags, back-home link, and OAuth error
alert. Refresh llms.txt and robots.txt.

Made-with: Cursor
2026-03-22 16:11:59 +08:00
voson
1e597559a2 feat(core): FK for user_id columns; MCP search requires user
All checks were successful
Secrets MCP — Build & Release / 检查 / 构建 / 发版 (push) Successful in 3m10s
Secrets MCP — Build & Release / 部署 secrets-mcp (push) Successful in 5s
- Add fk_entries_user_id, fk_entries_history_user_id, fk_audit_log_user_id (ON DELETE SET NULL)
- Add scripts/cleanup-orphan-user-ids.sql for pre-deploy orphan user_id cleanup
- Remove deprecated SERVER_MASTER_KEY / per-user key wrap helpers from secrets-core
- secrets-mcp: require authenticated user for secrets_search; improve body-read failure response
- Bump secrets-mcp to 0.2.1

Made-with: Cursor
2026-03-22 15:40:02 +08:00
voson
e3ca43ca3f release(secrets-mcp): 0.2.0
All checks were successful
Secrets MCP — Build & Release / 检查 / 构建 / 发版 (push) Successful in 3m12s
Secrets MCP — Build & Release / 部署 secrets-mcp (push) Successful in 5s
- 日志时间戳使用本地时区(chrono RFC3339 + 偏移)
- MCP tools / Web 路由与行为调整
- 新增 static/llms.txt、robots.txt;文档与 deploy 示例同步

Made-with: Cursor
2026-03-22 14:44:00 +08:00
voson
0b57605103 feat(secrets-mcp): MCP 请求日志、探测 404 与资源元数据
All checks were successful
Secrets MCP — Build & Release / 检查 / 构建 / 发版 (push) Successful in 3m10s
Secrets MCP — Build & Release / 部署 secrets-mcp (push) Successful in 5s
- 新增 logging 中间件:记录 client_ip、ua、JSON-RPC、tool 等
- tools 各入口/出口结构化日志
- 探测型 404(/.well-known、GET /mcp)降为 debug
- /.well-known/oauth-protected-resource 最小元数据
- secrets-mcp 0.1.11

Made-with: Cursor
2026-03-21 17:57:10 +08:00
voson
8b191937cd docs(AGENTS): 精简提交/推送规则第4条
Made-with: Cursor
2026-03-21 16:56:06 +08:00
voson
11c936a5b8 docs(AGENTS): 明确提交/推送前必须检查版本号与运行 fmt/clippy/test
Made-with: Cursor
2026-03-21 16:48:47 +08:00
voson
b6349dd1c8 chore(secrets-mcp): bump version to 0.1.10
All checks were successful
Secrets MCP — Build & Release / 检查 / 构建 / 发版 (push) Successful in 2m57s
Secrets MCP — Build & Release / 部署 secrets-mcp (push) Successful in 5s
Made-with: Cursor
2026-03-21 16:46:33 +08:00
voson
f720983328 refactor(db): 移除无意义 actor,修复 history 多租户与模型
Some checks failed
Secrets MCP — Build & Release / 部署 secrets-mcp (push) Has been cancelled
Secrets MCP — Build & Release / 检查 / 构建 / 发版 (push) Has started running
- 删除 entries_history / audit_log / secrets_history 的 actor 列及写入逻辑
- MCP secrets_history 透传当前 user_id
- Entry 增加 user_id,search 查询不再用伪 UUID
- 迁移:保留 users.api_key,从 api_keys 表回退时生成新明文 key 并删表
- 文档:audit_log auth 语义、API Key 存储说明

Made-with: Cursor
2026-03-21 16:45:50 +08:00
voson
7bd0603dc6 chore(secrets-mcp): bump version to 0.1.9
All checks were successful
Secrets MCP — Build & Release / 检查 / 构建 / 发版 (push) Successful in 2m47s
Secrets MCP — Build & Release / 部署 secrets-mcp (push) Successful in 5s
Made-with: Cursor
2026-03-21 12:25:38 +08:00
voson
17a95bea5b refactor(audit): 移除旧格式兼容,user_id 统一走列字段
Some checks failed
Secrets MCP — Build & Release / 部署 secrets-mcp (push) Has been cancelled
Secrets MCP — Build & Release / 检查 / 构建 / 发版 (push) Has been cancelled
- audit_log 查询去掉 detail->>'user_id' 回退分支
- login_detail 不再冗余写入 user_id 到 detail JSON
- 迁移 SQL 去掉多余的 ALTER TABLE ADD COLUMN

Made-with: Cursor
2026-03-21 12:24:00 +08:00
voson
a42db62702 style(secrets-mcp): rustfmt web.rs audit mapping
All checks were successful
Secrets MCP — Build & Release / 检查 / 构建 / 发版 (push) Successful in 5m20s
Secrets MCP — Build & Release / 部署 secrets-mcp (push) Successful in 6s
Made-with: Cursor
2026-03-21 12:06:29 +08:00
voson
2edb970cba chore(secrets-mcp): bump version to 0.1.8
Some checks failed
Secrets MCP — Build & Release / 检查 / 构建 / 发版 (push) Failing after 19s
Secrets MCP — Build & Release / 部署 secrets-mcp (push) Has been skipped
Made-with: Cursor
2026-03-21 12:05:22 +08:00
voson
17f8ac0dbc web: 审计页时间按浏览器本地时区显示
Some checks failed
Secrets MCP — Build & Release / 检查 / 构建 / 发版 (push) Failing after 25s
Secrets MCP — Build & Release / 部署 secrets-mcp (push) Has been skipped
Made-with: Cursor
2026-03-21 12:03:44 +08:00
voson
259fbe10a6 ci: 精简 Release upsert 逻辑
All checks were successful
Secrets MCP — Build & Release / 检查 / 构建 / 发版 (push) Successful in 4m36s
Secrets MCP — Build & Release / 部署 secrets-mcp (push) Successful in 5s
提取 auth/api 公共变量避免重复;用 xargs 单行替换 while 循环清理
旧 assets;POST 分支用管道直接取 id 省去临时文件。
279 行 → 248 行。

Made-with: Cursor
2026-03-21 11:36:43 +08:00
voson
c815fb4cc8 ci: 修复覆盖重发时 Release 唯一约束冲突
DELETE + POST 同名 release 会触发 Gitea 的 UQE_release_n 约束。
改为:已有 release → PATCH 更新 name/body,再逐个删除旧 assets 后重传;
      无 release → 正常 POST 新建。

Made-with: Cursor
2026-03-21 11:33:45 +08:00
voson
90cd1eca15 ci: 允许对同版本覆盖重发版
Some checks failed
Secrets MCP — Build & Release / 检查 / 构建 / 发版 (push) Failing after 4m33s
Secrets MCP — Build & Release / 部署 secrets-mcp (push) Has been skipped
- 解析版本时不再 exit 1,改为记录 tag_exists=true 并打印警告
- 创建 Tag 步骤:若 tag 已存在则先本地删除再远端删除,再重新打带注释的 tag
- 创建 Release 步骤:先查询同名 Release,若存在则 DELETE 旧 Release,再 POST 新建

Made-with: Cursor
2026-03-21 11:22:24 +08:00
voson
da007348ea ci: 合并为 ci + deploy 两个 job,check 先于 build
Some checks failed
Secrets MCP — Build & Release / 检查 / 构建 / 发版 (push) Failing after 7s
Secrets MCP — Build & Release / 部署 secrets-mcp (push) Has been skipped
单台 self-hosted runner 下并行 job 只是排队,多 job 拆分带来的
artifact 传递、重复 checkout、调度延迟反而更慢。

改动:
- 原 version/check/build-linux/publish-release 四个 job 合并为单个 ci job
- 步骤顺序:版本拦截 → fmt/clippy/test → build → 打 tag → 发 Release
- tag 在构建成功后才创建,避免失败提交留下脏 tag
- Release 创建+上传+发布合并为单步,去掉草稿中转
- deploy job 仅保留 artifact 下载 + SSH 部署逻辑,不再重复编译
- 整体从 400 行缩减至 244 行

Made-with: Cursor
2026-03-21 11:18:10 +08:00
voson
f2344b7543 feat(secrets-mcp): 审计页、audit_log user_id、OAuth 登录与仪表盘 footer
All checks were successful
Secrets MCP — Build & Release / 版本 & Release (push) Successful in 3s
Secrets MCP — Build & Release / 质量检查 (fmt / clippy / test) (push) Successful in 7m20s
Secrets MCP — Build & Release / Build Linux (musl) (push) Successful in 8m23s
Secrets MCP — Build & Release / 发布草稿 Release (push) Successful in 1s
Secrets MCP — Build & Release / 部署 secrets-mcp (push) Successful in 6s
- audit_log 增加 user_id;业务写审计透传 user_id
- Web /audit 与侧边栏;Dashboard 版本 footer 贴底(margin-top: auto)
- 停止 API Key 鉴权成功写入登录审计
- 文档、CI、release-check 配套更新

Made-with: Cursor
2026-03-21 11:12:11 +08:00
voson
ee028d45c3 ci: 优化 workflow 并行度与产物传递
- check 与 build-linux 改为并行执行,节省约 10min
- 新增 upload-artifact / download-artifact,deploy-mcp 直接复用二进制,免重复编译(节省约 15min)
- check / build 缓存加入 target/ 目录,加速增量编译
- 提取 MUSL_TARGET 全局变量,消除 x86_64-unknown-linux-musl 硬编码
- publish-release 增加 check 结果检查,质量失败时不发布 Release
- 移除 build-linux 冗余飞书通知,publish-release 汇总已覆盖

Made-with: Cursor
2026-03-21 10:07:29 +08:00
31 changed files with 1838 additions and 584 deletions

View File

@@ -7,7 +7,6 @@ on:
- 'crates/**' - 'crates/**'
- 'Cargo.toml' - 'Cargo.toml'
- 'Cargo.lock' - 'Cargo.lock'
# systemd / 部署模板变更也应跑构建(产物无变时可快速跳过 check
- 'deploy/**' - 'deploy/**'
- '.gitea/workflows/**' - '.gitea/workflows/**'
@@ -25,225 +24,162 @@ env:
CARGO_NET_RETRY: 10 CARGO_NET_RETRY: 10
CARGO_TERM_COLOR: always CARGO_TERM_COLOR: always
RUST_BACKTRACE: short RUST_BACKTRACE: short
MUSL_TARGET: x86_64-unknown-linux-musl
jobs: jobs:
version: ci:
name: 版本 & Release name: 检查 / 构建 / 发版
runs-on: debian runs-on: debian
timeout-minutes: 40
outputs: outputs:
version: ${{ steps.ver.outputs.version }}
tag: ${{ steps.ver.outputs.tag }} tag: ${{ steps.ver.outputs.tag }}
tag_exists: ${{ steps.ver.outputs.tag_exists }} version: ${{ steps.ver.outputs.version }}
release_id: ${{ steps.release.outputs.release_id }}
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with: with:
fetch-depth: 0 fetch-depth: 0
# ── 版本解析 ────────────────────────────────────────────────────────
- name: 解析版本 - name: 解析版本
id: ver id: ver
run: | run: |
version=$(grep -m1 '^version' crates/secrets-mcp/Cargo.toml | sed 's/.*"\(.*\)".*/\1/') version=$(grep -m1 '^version' crates/secrets-mcp/Cargo.toml | sed 's/.*"\(.*\)".*/\1/')
tag="secrets-mcp-${version}" tag="secrets-mcp-${version}"
previous_tag=$(git tag --list 'secrets-mcp-*' --sort=-v:refname | awk -v tag="$tag" '$0 != tag { print; exit }')
echo "version=${version}" >> "$GITHUB_OUTPUT" echo "version=${version}" >> "$GITHUB_OUTPUT"
echo "tag=${tag}" >> "$GITHUB_OUTPUT" echo "tag=${tag}" >> "$GITHUB_OUTPUT"
echo "previous_tag=${previous_tag}" >> "$GITHUB_OUTPUT"
if git rev-parse "refs/tags/${tag}" >/dev/null 2>&1; then if git rev-parse "refs/tags/${tag}" >/dev/null 2>&1; then
echo "⚠ 版本 ${tag} 已存在,将覆盖重新发版。"
echo "tag_exists=true" >> "$GITHUB_OUTPUT" echo "tag_exists=true" >> "$GITHUB_OUTPUT"
echo "版本 ${tag} 已存在"
else else
echo "tag_exists=false" >> "$GITHUB_OUTPUT"
echo "将创建新版本 ${tag}" echo "将创建新版本 ${tag}"
echo "tag_exists=false" >> "$GITHUB_OUTPUT"
fi fi
- name: 严格拦截重复版本 # ── Rust 工具链 ──────────────────────────────────────────────────────
if: steps.ver.outputs.tag_exists == 'true' - name: 安装 Rust 与 musl 工具链
run: | run: |
echo "错误: 版本 ${{ steps.ver.outputs.tag }} 已存在,禁止重复发版。" sudo apt-get update -qq
echo "请先 bump crates/secrets-mcp/Cargo.toml 中的 version并执行 cargo build 同步 Cargo.lock。" sudo apt-get install -y -qq pkg-config musl-tools binutils jq
exit 1 if ! command -v rustup >/dev/null 2>&1; then
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain "${RUST_TOOLCHAIN}"
echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
fi
source "$HOME/.cargo/env" 2>/dev/null || true
rustup toolchain install "${RUST_TOOLCHAIN}" --profile minimal \
--component rustfmt --component clippy
rustup default "${RUST_TOOLCHAIN}"
rustup target add "${MUSL_TARGET}" --toolchain "${RUST_TOOLCHAIN}"
rustc -V && cargo -V
- name: 缓存 Cargo
uses: actions/cache@v4
with:
path: |
~/.cargo/registry/index
~/.cargo/registry/cache
~/.cargo/git/db
target
key: cargo-${{ env.MUSL_TARGET }}-${{ env.RUST_TOOLCHAIN }}-${{ hashFiles('Cargo.lock') }}
restore-keys: |
cargo-${{ env.MUSL_TARGET }}-${{ env.RUST_TOOLCHAIN }}-
cargo-${{ env.MUSL_TARGET }}-
# ── 质量检查(先于构建,失败即止)──────────────────────────────────
- name: fmt
run: cargo fmt -- --check
- name: clippy
run: cargo clippy --locked -- -D warnings
- name: test
run: cargo test --locked
# ── 构建(质量检查通过后才执行)────────────────────────────────────
- name: 构建 secrets-mcp (musl)
run: |
cargo build --release --locked --target "${MUSL_TARGET}" -p secrets-mcp
strip "target/${MUSL_TARGET}/release/${MCP_BINARY}"
- name: 上传构建产物
uses: actions/upload-artifact@v3
with:
name: ${{ env.MCP_BINARY }}-linux-musl
path: target/${{ env.MUSL_TARGET }}/release/${{ env.MCP_BINARY }}
retention-days: 3
# ── 创建 / 覆盖 Tag构建成功后才打───────────────────────────────
- name: 创建 Tag - name: 创建 Tag
if: steps.ver.outputs.tag_exists == 'false'
run: | run: |
git config user.name "github-actions[bot]" git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com" git config user.email "github-actions[bot]@users.noreply.github.com"
git tag -a "${{ steps.ver.outputs.tag }}" -m "Release ${{ steps.ver.outputs.tag }}" tag="${{ steps.ver.outputs.tag }}"
git push origin "${{ steps.ver.outputs.tag }}" if [ "${{ steps.ver.outputs.tag_exists }}" = "true" ]; then
git tag -d "$tag" 2>/dev/null || true
git push origin ":refs/tags/$tag" 2>/dev/null || true
fi
git tag -a "$tag" -m "Release $tag"
git push origin "$tag"
- name: 解析或创建 Release # ── Release可选需配置 RELEASE_TOKEN───────────────────────────
id: release - name: Upsert Release
if: env.RELEASE_TOKEN != ''
env: env:
RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }} RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }}
run: | run: |
if [ -z "$RELEASE_TOKEN" ]; then
echo "release_id=" >> "$GITHUB_OUTPUT"
exit 0
fi
command -v jq >/dev/null 2>&1 || (sudo apt-get update -qq && sudo apt-get install -y -qq jq)
tag="${{ steps.ver.outputs.tag }}" tag="${{ steps.ver.outputs.tag }}"
version="${{ steps.ver.outputs.version }}" version="${{ steps.ver.outputs.version }}"
release_api="${{ github.server_url }}/api/v1/repos/${{ github.repository }}/releases" api="${{ github.server_url }}/api/v1/repos/${{ github.repository }}/releases"
auth="Authorization: token $RELEASE_TOKEN"
http_code=$(curl -sS -o /tmp/release.json -w '%{http_code}' \ previous_tag=$(git tag --list 'secrets-mcp-*' --sort=-v:refname | awk -v t="$tag" '$0 != t { print; exit }')
-H "Authorization: token $RELEASE_TOKEN" \
"${release_api}/tags/${tag}")
if [ "$http_code" = "200" ]; then
release_id=$(jq -r '.id // empty' /tmp/release.json)
if [ -n "$release_id" ]; then
echo "已找到现有 Release: ${release_id}"
echo "release_id=${release_id}" >> "$GITHUB_OUTPUT"
exit 0
fi
fi
previous_tag="${{ steps.ver.outputs.previous_tag }}"
if [ -n "$previous_tag" ]; then if [ -n "$previous_tag" ]; then
changes=$(git log --pretty=format:'- %s (%h)' "${previous_tag}..HEAD") changes=$(git log --pretty=format:'- %s (%h)' "${previous_tag}..HEAD")
else else
changes=$(git log --pretty=format:'- %s (%h)') changes=$(git log --pretty=format:'- %s (%h)')
fi fi
[ -z "$changes" ] && changes="- 首次发布" [ -z "$changes" ] && changes="- 首次发布"
body=$(printf '## 变更日志\n\n%s' "$changes") body=$(printf '## 变更日志\n\n%s' "$changes")
payload=$(jq -n \ # Upsert: 存在 → PATCH + 清旧 assets不存在 → POST
--arg tag "$tag" \ release_id=$(curl -sS -H "$auth" "${api}/tags/${tag}" 2>/dev/null | jq -r '.id // empty')
--arg name "secrets-mcp ${version}" \
--arg body "$body" \
'{tag_name: $tag, name: $name, body: $body, draft: true}')
http_code=$(curl -sS -o /tmp/create-release.json -w '%{http_code}' \
-H "Authorization: token $RELEASE_TOKEN" \
-H "Content-Type: application/json" \
-X POST "$release_api" \
-d "$payload")
if [ "$http_code" = "201" ] || [ "$http_code" = "200" ]; then
release_id=$(jq -r '.id // empty' /tmp/create-release.json)
fi
if [ -n "$release_id" ]; then if [ -n "$release_id" ]; then
echo "已创建草稿 Release: ${release_id}" curl -sS -o /dev/null -H "$auth" -H "Content-Type: application/json" \
echo "release_id=${release_id}" >> "$GITHUB_OUTPUT" -X PATCH "${api}/${release_id}" \
-d "$(jq -n --arg n "secrets-mcp ${version}" --arg b "$body" '{name:$n,body:$b,draft:false}')"
curl -sS -H "$auth" "${api}/${release_id}/assets" | \
jq -r '.[].id' | xargs -I{} curl -sS -o /dev/null -H "$auth" -X DELETE "${api}/${release_id}/assets/{}"
echo "已更新 Release ${release_id}"
else else
echo "⚠ 创建 Release 失败 (HTTP ${http_code}),跳过产物上传" release_id=$(curl -fsS -H "$auth" -H "Content-Type: application/json" \
cat /tmp/create-release.json 2>/dev/null || true -X POST "$api" \
echo "release_id=" >> "$GITHUB_OUTPUT" -d "$(jq -n --arg t "$tag" --arg n "secrets-mcp ${version}" --arg b "$body" \
'{tag_name:$t,name:$n,body:$b,draft:false}')" | jq -r '.id')
echo "已创建 Release ${release_id}"
fi fi
check: bin="target/${MUSL_TARGET}/release/${MCP_BINARY}"
name: 质量检查 (fmt / clippy / test) archive="${MCP_BINARY}-${tag}-x86_64-linux-musl.tar.gz"
needs: [version]
runs-on: debian
timeout-minutes: 15
steps:
- name: 安装 Rust
run: |
if ! command -v rustup >/dev/null 2>&1; then
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain "${RUST_TOOLCHAIN}"
echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
fi
source "$HOME/.cargo/env" 2>/dev/null || true
rustup toolchain install "${RUST_TOOLCHAIN}" --profile minimal --component rustfmt --component clippy
rustup default "${RUST_TOOLCHAIN}"
rustc -V
cargo -V
- uses: actions/checkout@v4
- name: 缓存 Cargo
uses: actions/cache@v4
with:
path: |
~/.cargo/registry/index
~/.cargo/registry/cache
~/.cargo/git/db
key: cargo-check-${{ env.RUST_TOOLCHAIN }}-${{ hashFiles('Cargo.lock') }}
restore-keys: |
cargo-check-${{ env.RUST_TOOLCHAIN }}-
cargo-check-
- run: cargo fmt -- --check
- run: cargo clippy --locked -- -D warnings
- run: cargo test --locked
build-linux:
name: Build Linux (secrets-mcp, musl)
needs: [version, check]
runs-on: debian
timeout-minutes: 25
steps:
- name: 安装依赖
run: |
sudo apt-get update
sudo apt-get install -y pkg-config musl-tools binutils curl
if ! command -v rustup >/dev/null 2>&1; then
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain "${RUST_TOOLCHAIN}"
echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
fi
source "$HOME/.cargo/env" 2>/dev/null || true
rustup toolchain install "${RUST_TOOLCHAIN}" --profile minimal
rustup default "${RUST_TOOLCHAIN}"
rustup target add x86_64-unknown-linux-musl --toolchain "${RUST_TOOLCHAIN}"
rustc -V
cargo -V
- uses: actions/checkout@v4
- name: 缓存 Cargo
uses: actions/cache@v4
with:
path: |
~/.cargo/registry/index
~/.cargo/registry/cache
~/.cargo/git/db
key: cargo-x86_64-unknown-linux-musl-${{ env.RUST_TOOLCHAIN }}-${{ hashFiles('Cargo.lock') }}
restore-keys: |
cargo-x86_64-unknown-linux-musl-${{ env.RUST_TOOLCHAIN }}-
cargo-x86_64-unknown-linux-musl-
- name: 构建 secrets-mcp (musl)
run: |
cargo build --release --locked --target x86_64-unknown-linux-musl -p secrets-mcp
strip target/x86_64-unknown-linux-musl/release/${{ env.MCP_BINARY }}
- name: 上传 Release 产物
if: needs.version.outputs.release_id != ''
env:
RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }}
run: |
[ -z "$RELEASE_TOKEN" ] && exit 0
tag="${{ needs.version.outputs.tag }}"
bin="target/x86_64-unknown-linux-musl/release/${{ env.MCP_BINARY }}"
archive="${{ env.MCP_BINARY }}-${tag}-x86_64-linux-musl.tar.gz"
tar -czf "$archive" -C "$(dirname "$bin")" "$(basename "$bin")" tar -czf "$archive" -C "$(dirname "$bin")" "$(basename "$bin")"
sha256sum "$archive" > "${archive}.sha256" sha256sum "$archive" > "${archive}.sha256"
release_url="${{ github.server_url }}/api/v1/repos/${{ github.repository }}/releases/${{ needs.version.outputs.release_id }}/assets" curl -fsS -H "$auth" -F "attachment=@${archive}" "${api}/${release_id}/assets"
curl -fsS -H "Authorization: token $RELEASE_TOKEN" \ curl -fsS -H "$auth" -F "attachment=@${archive}.sha256" "${api}/${release_id}/assets"
-F "attachment=@${archive}" "$release_url" echo "Release ${tag} 已发布"
curl -fsS -H "Authorization: token $RELEASE_TOKEN" \
-F "attachment=@${archive}.sha256" "$release_url"
# ── 飞书汇总通知 ─────────────────────────────────────────────────────
- name: 飞书通知 - name: 飞书通知
if: always() if: always()
env: env:
WEBHOOK_URL: ${{ vars.WEBHOOK_URL }} WEBHOOK_URL: ${{ vars.WEBHOOK_URL }}
run: | run: |
[ -z "$WEBHOOK_URL" ] && exit 0 [ -z "$WEBHOOK_URL" ] && exit 0
command -v jq >/dev/null 2>&1 || (sudo apt-get update -qq && sudo apt-get install -y -qq jq) tag="${{ steps.ver.outputs.tag }}"
tag="${{ needs.version.outputs.tag }}" commit="${{ github.event.head_commit.message }}"
commit=$(git log -1 --pretty=format:"%s" 2>/dev/null || echo "N/A") [ -z "$commit" ] && commit="${{ github.sha }}"
url="${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_number }}" url="${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_number }}"
result="${{ job.status }}" result="${{ job.status }}"
if [ "$result" = "success" ]; then icon="✅"; else icon="❌"; fi if [ "$result" = "success" ]; then icon="✅"; else icon="❌"; fi
msg="secrets-mcp linux 构建${icon} msg="secrets-mcp 构建&发版 ${icon}
版本:${tag} 版本:${tag}
提交:${commit} 提交:${commit}
作者:${{ github.actor }} 作者:${{ github.actor }}
@@ -251,50 +187,21 @@ jobs:
payload=$(jq -n --arg text "$msg" '{msg_type: "text", content: {text: $text}}') payload=$(jq -n --arg text "$msg" '{msg_type: "text", content: {text: $text}}')
curl -sS -H "Content-Type: application/json" -X POST -d "$payload" "$WEBHOOK_URL" curl -sS -H "Content-Type: application/json" -X POST -d "$payload" "$WEBHOOK_URL"
deploy-mcp: deploy:
name: 部署 secrets-mcp name: 部署 secrets-mcp
needs: [version, build-linux] needs: [ci]
# 部署目标由仓库 Actions 配置vars.DEPLOY_HOST / vars.DEPLOY_USER私钥 secrets.DEPLOY_SSH_KEYPEM 原文,勿 base64 if: |
# (可用 scripts/setup-gitea-actions.sh 或 Gitea API 写入,勿写进本文件) github.ref == 'refs/heads/main' ||
# Google OAuth / SERVER_MASTER_KEY / SECRETS_DATABASE_URL 等勿写入 CI请在 ECS 上 github.ref == 'refs/heads/feat/mcp' ||
# /opt/secrets-mcp/.env 配置(见 deploy/.env.example github.ref == 'refs/heads/mcp'
# 若仓库 main 仍为纯 CLI、仅 feat/mcp 含本 workflow请去掉条件里的 main避免误部署。
if: needs.build-linux.result == 'success' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/feat/mcp' || github.ref == 'refs/heads/mcp')
runs-on: debian runs-on: debian
timeout-minutes: 10 timeout-minutes: 10
steps: steps:
- uses: actions/checkout@v4 - name: 下载构建产物
uses: actions/download-artifact@v3
- name: 安装 Rust
run: |
if ! command -v rustup >/dev/null 2>&1; then
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain "${RUST_TOOLCHAIN}"
echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
fi
source "$HOME/.cargo/env" 2>/dev/null || true
sudo apt-get update -qq && sudo apt-get install -y -qq pkg-config musl-tools
rustup toolchain install "${RUST_TOOLCHAIN}" --profile minimal
rustup default "${RUST_TOOLCHAIN}"
rustup target add x86_64-unknown-linux-musl --toolchain "${RUST_TOOLCHAIN}"
rustc -V
cargo -V
- name: 缓存 Cargo
uses: actions/cache@v4
with: with:
path: | name: ${{ env.MCP_BINARY }}-linux-musl
~/.cargo/registry/index path: /tmp/artifact
~/.cargo/registry/cache
~/.cargo/git/db
key: cargo-x86_64-unknown-linux-musl-${{ env.RUST_TOOLCHAIN }}-${{ hashFiles('Cargo.lock') }}
restore-keys: |
cargo-x86_64-unknown-linux-musl-${{ env.RUST_TOOLCHAIN }}-
cargo-x86_64-unknown-linux-musl-
- name: 构建 secrets-mcp
run: |
cargo build --release --locked --target x86_64-unknown-linux-musl -p secrets-mcp
strip target/x86_64-unknown-linux-musl/release/${{ env.MCP_BINARY }}
- name: 部署到阿里云 ECS - name: 部署到阿里云 ECS
env: env:
@@ -303,16 +210,15 @@ jobs:
DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }} DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
run: | run: |
if [ -z "$DEPLOY_HOST" ] || [ -z "$DEPLOY_USER" ] || [ -z "$DEPLOY_SSH_KEY" ]; then if [ -z "$DEPLOY_HOST" ] || [ -z "$DEPLOY_USER" ] || [ -z "$DEPLOY_SSH_KEY" ]; then
echo "部署跳过:请在仓库 Actions 中配置 vars.DEPLOY_HOST、vars.DEPLOY_USER 与 secrets.DEPLOY_SSH_KEY" echo "部署跳过:请配置 vars.DEPLOY_HOST、vars.DEPLOY_USER 与 secrets.DEPLOY_SSH_KEY"
exit 0 exit 0
fi fi
echo "$DEPLOY_SSH_KEY" > /tmp/deploy_key echo "$DEPLOY_SSH_KEY" > /tmp/deploy_key
chmod 600 /tmp/deploy_key chmod 600 /tmp/deploy_key
SCP="scp -i /tmp/deploy_key -o StrictHostKeyChecking=no" scp -i /tmp/deploy_key -o StrictHostKeyChecking=no \
"/tmp/artifact/${MCP_BINARY}" \
$SCP target/x86_64-unknown-linux-musl/release/${{ env.MCP_BINARY }} \
"${DEPLOY_USER}@${DEPLOY_HOST}:/tmp/secrets-mcp.new" "${DEPLOY_USER}@${DEPLOY_HOST}:/tmp/secrets-mcp.new"
ssh -i /tmp/deploy_key -o StrictHostKeyChecking=no "${DEPLOY_USER}@${DEPLOY_HOST}" " ssh -i /tmp/deploy_key -o StrictHostKeyChecking=no "${DEPLOY_USER}@${DEPLOY_HOST}" "
@@ -322,7 +228,6 @@ jobs:
sleep 2 sleep 2
sudo systemctl is-active secrets-mcp && echo '服务启动成功' || (sudo journalctl -u secrets-mcp -n 20 && exit 1) sudo systemctl is-active secrets-mcp && echo '服务启动成功' || (sudo journalctl -u secrets-mcp -n 20 && exit 1)
" "
rm -f /tmp/deploy_key rm -f /tmp/deploy_key
- name: 飞书通知 - name: 飞书通知
@@ -331,94 +236,13 @@ jobs:
WEBHOOK_URL: ${{ vars.WEBHOOK_URL }} WEBHOOK_URL: ${{ vars.WEBHOOK_URL }}
run: | run: |
[ -z "$WEBHOOK_URL" ] && exit 0 [ -z "$WEBHOOK_URL" ] && exit 0
command -v jq >/dev/null 2>&1 || (sudo apt-get update -qq && sudo apt-get install -y -qq jq) tag="${{ needs.ci.outputs.tag }}"
tag="${{ needs.version.outputs.tag }}"
commit=$(git log -1 --pretty=format:"%s" 2>/dev/null || echo "N/A")
url="${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_number }}" url="${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_number }}"
result="${{ job.status }}" result="${{ job.status }}"
if [ "$result" = "success" ]; then icon="✅"; else icon="❌"; fi if [ "$result" = "success" ]; then icon="✅"; else icon="❌"; fi
msg="secrets-mcp 部署${icon} msg="secrets-mcp 部署 ${icon}
版本:${tag} 版本:${tag}
提交:${commit}
作者:${{ github.actor }} 作者:${{ github.actor }}
详情:${url}" 详情:${url}"
payload=$(jq -n --arg text "$msg" '{msg_type: "text", content: {text: $text}}') payload=$(jq -n --arg text "$msg" '{msg_type: "text", content: {text: $text}}')
curl -sS -H "Content-Type: application/json" -X POST -d "$payload" "$WEBHOOK_URL" curl -sS -H "Content-Type: application/json" -X POST -d "$payload" "$WEBHOOK_URL"
publish-release:
name: 发布草稿 Release
needs: [version, build-linux]
if: always() && needs.version.outputs.release_id != ''
runs-on: debian
timeout-minutes: 5
steps:
- uses: actions/checkout@v4
- name: 发布草稿
env:
RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }}
run: |
[ -z "$RELEASE_TOKEN" ] && exit 0
linux_r="${{ needs.build-linux.result }}"
if [ "$linux_r" != "success" ]; then
echo "linux 构建未成功,保留草稿 Release"
exit 0
fi
release_api="${{ github.server_url }}/api/v1/repos/${{ github.repository }}/releases/${{ needs.version.outputs.release_id }}"
http_code=$(curl -sS -o /tmp/publish-release.json -w '%{http_code}' \
-H "Authorization: token $RELEASE_TOKEN" \
-H "Content-Type: application/json" \
-X PATCH "$release_api" \
-d '{"draft":false}')
if [ "$http_code" != "200" ]; then
echo "发布草稿 Release 失败 (HTTP ${http_code})"
cat /tmp/publish-release.json 2>/dev/null || true
exit 1
fi
echo "Release 已发布"
- name: 飞书汇总通知
if: always()
env:
WEBHOOK_URL: ${{ vars.WEBHOOK_URL }}
run: |
[ -z "$WEBHOOK_URL" ] && exit 0
command -v jq >/dev/null 2>&1 || (sudo apt-get update -qq && sudo apt-get install -y -qq jq)
tag="${{ needs.version.outputs.tag }}"
tag_exists="${{ needs.version.outputs.tag_exists }}"
commit="${{ github.event.head_commit.message }}"
[ -z "$commit" ] && commit="${{ github.sha }}"
url="${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_number }}"
linux_r="${{ needs.build-linux.result }}"
publish_r="${{ job.status }}"
icon() { case "$1" in success) echo "✅";; skipped) echo "⏭";; *) echo "❌";; esac; }
if [ "$linux_r" = "success" ] && [ "$publish_r" = "success" ]; then
status="发布成功 ✅"
elif [ "$linux_r" != "success" ]; then
status="构建失败 ❌"
else
status="发布失败 ❌"
fi
if [ "$tag_exists" = "false" ]; then
version_line="🆕 新版本 ${tag}"
else
version_line="🔄 重复构建 ${tag}"
fi
msg="secrets-mcp ${status}
${version_line}
linux $(icon "$linux_r") | Release $(icon "$publish_r")
提交:${commit}
作者:${{ github.actor }}
详情:${url}"
payload=$(jq -n --arg text "$msg" '{msg_type: "text", content: {text: $text}}')
curl -sS -H "Content-Type: application/json" -X POST -d "$payload" "$WEBHOOK_URL"

View File

@@ -2,12 +2,13 @@
本仓库为 **MCP SaaS**`secrets-core`(业务与持久化)+ `secrets-mcp`Streamable HTTP MCP、Web、OAuth、API Key。对外入口见 `crates/secrets-mcp` 本仓库为 **MCP SaaS**`secrets-core`(业务与持久化)+ `secrets-mcp`Streamable HTTP MCP、Web、OAuth、API Key。对外入口见 `crates/secrets-mcp`
## 提交 / 发版硬规则(优先于下文) ## 提交 / 推送硬规则(优先于下文)
**每次提交和推送前必须执行以下检查,无论是否明确「发版」:**
1. 涉及 `crates/**`、根目录 `Cargo.toml`/`Cargo.lock``secrets-mcp` 行为变更的提交,默认视为**需要发版**,除非明确说明「本次不发版」。 1. 涉及 `crates/**`、根目录 `Cargo.toml`/`Cargo.lock``secrets-mcp` 行为变更的提交,默认视为**需要发版**,除非明确说明「本次不发版」。
2. 发版前检查 `crates/secrets-mcp/Cargo.toml``version`,再查 tag`git tag -l 'secrets-mcp-*'` 2. 提交前检查 `crates/secrets-mcp/Cargo.toml``version`,再查 tag`git tag -l 'secrets-mcp-*'`若当前版本对应 tag 已存在且有代码变更,**必须 bump 版本号**并 `cargo build` 同步 `Cargo.lock`
3. 若当前版本对应 tag 已存在,须先 bump `version`,再 `cargo build` 同步 `Cargo.lock` 后提交 3. 提交前运行 `./scripts/release-check.sh`(版本/tag + `fmt` + `clippy --locked` + `test --locked`)。若脚本不存在或不可用,至少运行 `cargo fmt -- --check && cargo clippy --locked -- -D warnings && cargo test --locked`
4. 提交前优先运行 `./scripts/release-check.sh`(版本/tag + `fmt` + `clippy --locked` + `test --locked`)。
## 项目结构 ## 项目结构
@@ -28,7 +29,7 @@ secrets/
- **建议库名**`secrets-mcp`(专用实例,与历史库名区分)。 - **建议库名**`secrets-mcp`(专用实例,与历史库名区分)。
- **连接**:环境变量 **`SECRETS_DATABASE_URL`**(本分支无本地配置文件路径)。 - **连接**:环境变量 **`SECRETS_DATABASE_URL`**(本分支无本地配置文件路径)。
- **表**`entries`(含 `user_id`)、`secrets``entries_history``secrets_history``audit_log``users``oauth_accounts``api_keys`,首次连接 **auto-migrate** - **表**`entries`(含 `user_id`)、`secrets``entries_history``secrets_history``audit_log``users``oauth_accounts`,首次连接 **auto-migrate**
### 表结构(摘录) ### 表结构(摘录)
@@ -60,7 +61,7 @@ secrets (
) )
``` ```
### users / oauth_accounts / api_keys ### users / oauth_accounts
```sql ```sql
users ( users (
@@ -71,6 +72,7 @@ users (
key_salt BYTEA, -- PBKDF2 salt32B首次设置密码短语时写入 key_salt BYTEA, -- PBKDF2 salt32B首次设置密码短语时写入
key_check BYTEA, -- 派生密钥加密已知常量,用于验证密码短语 key_check BYTEA, -- 派生密钥加密已知常量,用于验证密码短语
key_params JSONB, -- 算法参数,如 {"alg":"pbkdf2-sha256","iterations":600000} key_params JSONB, -- 算法参数,如 {"alg":"pbkdf2-sha256","iterations":600000}
api_key TEXT UNIQUE, -- MCP Bearer token当前实现为明文存储
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
) )
@@ -83,21 +85,11 @@ oauth_accounts (
... ...
UNIQUE(provider, provider_id) UNIQUE(provider, provider_id)
) )
api_keys (
id UUID PRIMARY KEY DEFAULT uuidv7(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name VARCHAR(256) NOT NULL,
key_hash VARCHAR(64) NOT NULL UNIQUE,
key_prefix VARCHAR(12) NOT NULL,
last_used_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
``` ```
### audit_log / history ### audit_log / history
与迁移脚本一致:`audit_log``entries_history``secrets_history` 用于审计与时间旅行恢复;字段定义见 `crates/secrets-core/src/db.rs``migrate` SQL。 与迁移脚本一致:`audit_log``entries_history``secrets_history` 用于审计与时间旅行恢复;字段定义见 `crates/secrets-core/src/db.rs``migrate` SQL。`audit_log` 中普通业务事件的 `namespace/kind/name` 对应 entry 坐标;登录类事件固定使用 `namespace='auth'`,此时 `kind/name` 表示认证目标而非 entry 身份。
### 字段职责 ### 字段职责
@@ -120,7 +112,7 @@ api_keys (
- 错误:业务层 `anyhow::Result`,避免生产路径 `unwrap()` - 错误:业务层 `anyhow::Result`,避免生产路径 `unwrap()`
- 异步:`tokio` + `sqlx` async。 - 异步:`tokio` + `sqlx` async。
- SQL`sqlx::query` / `query_as` 参数绑定;动态 WHERE 仍须用占位符绑定。 - SQL`sqlx::query` / `query_as` 参数绑定;动态 WHERE 仍须用占位符绑定。
- 日志:运维用 `tracing`;面向用户的 Web 响应走 axum handler。 - 日志:运维用 `tracing`;面向用户的 Web 响应走 axum handler。tracing 字段风格:变量名即字段名时用简写(`%var``?var``var`),否则用显式形式(`field = %expr`)。
- 审计:写操作成功后尽量 `audit::log_tx`;失败可 `warn`,不掩盖主错误。 - 审计:写操作成功后尽量 `audit::log_tx`;失败可 `warn`,不掩盖主错误。
- 加密:密钥由用户密码短语通过 **PBKDF2-SHA256600k 次)** 在客户端派生,服务端只存 `key_salt`/`key_check`/`key_params`不持有原始密钥。Web 客户端在浏览器本地完成加解密MCP 客户端通过 `X-Encryption-Key` 请求头传递密钥,服务端临时解密后返回明文。 - 加密:密钥由用户密码短语通过 **PBKDF2-SHA256600k 次)** 在客户端派生,服务端只存 `key_salt`/`key_check`/`key_params`不持有原始密钥。Web 客户端在浏览器本地完成加解密MCP 客户端通过 `X-Encryption-Key` 请求头传递密钥,服务端临时解密后返回明文。
- MCPtools 参数与 JSON Schema`schemars`)保持同步,鉴权以请求扩展中的用户上下文为准。 - MCPtools 参数与 JSON Schema`schemars`)保持同步,鉴权以请求扩展中的用户上下文为准。
@@ -148,10 +140,10 @@ git tag -l 'secrets-mcp-*'
## CI/CD ## CI/CD
- **触发**:任意分支 `push`,且路径含 `crates/**``deploy/**`、根目录 `Cargo.toml``Cargo.lock`(见 `.gitea/workflows/secrets.yml`)。 - **触发**:任意分支 `push`,且路径含 `crates/**``deploy/**`、根目录 `Cargo.toml``Cargo.lock``.gitea/workflows/**`(见 `.gitea/workflows/secrets.yml`)。
- **版本与 tag**:从 `crates/secrets-mcp/Cargo.toml` 读版本;若远程已存在同名 `secrets-mcp-<version>` tag**工作流失败**(须先 bump 版本并 `cargo build` 同步 `Cargo.lock`);否则由 CI 创建并推送该 tag - **版本与 tag**:从 `crates/secrets-mcp/Cargo.toml` 读版本;构建成功后打 `secrets-mcp-<version>`:若远端已存在同名 tagCI 会先删后于**当前提交**重建并推送(覆盖式发版)
- **质量与构建**`fmt` / `clippy --locked` / `test --locked``x86_64-unknown-linux-musl` 发布构建 `secrets-mcp` - **质量与构建**`fmt` / `clippy --locked` / `test --locked``x86_64-unknown-linux-musl` 发布构建 `secrets-mcp`
- **Release可选**`secrets.RELEASE_TOKEN`Gitea PAT用于创建草稿 Release、上传 `tar.gz` + `.sha256`、构建成功后发布;未配置则跳过 API Release仅 tag + 构建。 - **Release可选**`secrets.RELEASE_TOKEN`Gitea PAT用于通过 API **创建或更新**该 tag 的 Release非 draft、上传 `tar.gz` + `.sha256`;未配置则跳过 API Release仅 tag + 构建。
- **部署(可选)**:仅 `main``feat/mcp``mcp` 分支在构建成功时跑 `deploy-mcp`;需 `vars.DEPLOY_HOST``vars.DEPLOY_USER``secrets.DEPLOY_SSH_KEY`。勿把 OAuth/DB 等写进 workflow`deploy/.env.example` 在目标机配置。 - **部署(可选)**:仅 `main``feat/mcp``mcp` 分支在构建成功时跑 `deploy-mcp`;需 `vars.DEPLOY_HOST``vars.DEPLOY_USER``secrets.DEPLOY_SSH_KEY`。勿把 OAuth/DB 等写进 workflow`deploy/.env.example` 在目标机配置。
- **Secrets 写法**Actions **secrets 须为原始值**PEM、PAT 明文),**勿** base64否则 SSH/Release 会失败。**勿**在 CI 中保存 `GOOGLE_CLIENT_SECRET`、DB 密码。 - **Secrets 写法**Actions **secrets 须为原始值**PEM、PAT 明文),**勿** base64否则 SSH/Release 会失败。**勿**在 CI 中保存 `GOOGLE_CLIENT_SECRET`、DB 密码。
- **通知**`vars.WEBHOOK_URL`(可选,飞书)。 - **通知**`vars.WEBHOOK_URL`(可选,飞书)。
@@ -162,9 +154,8 @@ git tag -l 'secrets-mcp-*'
|------|------| |------|------|
| `SECRETS_DATABASE_URL` | **必填**。PostgreSQL URL。 | | `SECRETS_DATABASE_URL` | **必填**。PostgreSQL URL。 |
| `BASE_URL` | 对外基址OAuth 回调 `${BASE_URL}/auth/google/callback`。 | | `BASE_URL` | 对外基址OAuth 回调 `${BASE_URL}/auth/google/callback`。 |
| `SECRETS_MCP_BIND` | 监听地址,默认 `0.0.0.0:9315`。 | | `SECRETS_MCP_BIND` | 监听地址,默认 `127.0.0.1:9315`(容器/远程直接暴露时需改为 `0.0.0.0:9315`。 |
| `GOOGLE_CLIENT_ID` / `GOOGLE_CLIENT_SECRET` | 可选;仅运行时配置。 | | `GOOGLE_CLIENT_ID` / `GOOGLE_CLIENT_SECRET` | 可选;仅运行时配置。 |
| `RUST_LOG` | 如 `secrets_mcp=debug`。 | | `RUST_LOG` | 如 `secrets_mcp=debug`。 |
| `USER` | 若写入审计 `actor`,由运行环境提供。 |
> `SERVER_MASTER_KEY` 已不再需要。新架构下密钥由用户密码短语在客户端派生,服务端不持有。 > `SERVER_MASTER_KEY` 已不再需要。新架构下密钥由用户密码短语在客户端派生,服务端不持有。

40
Cargo.lock generated
View File

@@ -1809,6 +1809,25 @@ dependencies = [
"syn", "syn",
] ]
[[package]]
name = "rmp"
version = "0.8.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ba8be72d372b2c9b35542551678538b562e7cf86c3315773cae48dfbfe7790c"
dependencies = [
"num-traits",
]
[[package]]
name = "rmp-serde"
version = "1.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72f81bee8c8ef9b577d1681a70ebbc962c232461e397b22c208c43c04b67a155"
dependencies = [
"rmp",
"serde",
]
[[package]] [[package]]
name = "rsa" name = "rsa"
version = "0.9.10" version = "0.9.10"
@@ -1949,7 +1968,7 @@ dependencies = [
[[package]] [[package]]
name = "secrets-mcp" name = "secrets-mcp"
version = "0.1.6" version = "0.2.2"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"askama", "askama",
@@ -1967,10 +1986,12 @@ dependencies = [
"serde_json", "serde_json",
"sha2", "sha2",
"sqlx", "sqlx",
"time",
"tokio", "tokio",
"tower", "tower",
"tower-http", "tower-http",
"tower-sessions", "tower-sessions",
"tower-sessions-sqlx-store-chrono",
"tracing", "tracing",
"tracing-subscriber", "tracing-subscriber",
"urlencoding", "urlencoding",
@@ -2700,6 +2721,7 @@ dependencies = [
"tower", "tower",
"tower-layer", "tower-layer",
"tower-service", "tower-service",
"tracing",
] ]
[[package]] [[package]]
@@ -2765,6 +2787,22 @@ dependencies = [
"tower-sessions-core", "tower-sessions-core",
] ]
[[package]]
name = "tower-sessions-sqlx-store-chrono"
version = "0.14.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b295c8fc08db03246e92773c5e10119b72db6bc4240112135bebb0e49670804f"
dependencies = [
"async-trait",
"axum",
"chrono",
"rmp-serde",
"sqlx",
"thiserror",
"time",
"tower-sessions-core",
]
[[package]] [[package]]
name = "tracing" name = "tracing"
version = "0.1.44" version = "0.1.44"

View File

@@ -19,8 +19,9 @@ cargo build --release -p secrets-mcp
|------|------| |------|------|
| `SECRETS_DATABASE_URL` | **必填**。PostgreSQL 连接串(建议专用库,如 `secrets-mcp`)。 | | `SECRETS_DATABASE_URL` | **必填**。PostgreSQL 连接串(建议专用库,如 `secrets-mcp`)。 |
| `BASE_URL` | 对外访问基址OAuth 回调为 `{BASE_URL}/auth/google/callback`。默认 `http://localhost:9315`。 | | `BASE_URL` | 对外访问基址OAuth 回调为 `{BASE_URL}/auth/google/callback`。默认 `http://localhost:9315`。 |
| `SECRETS_MCP_BIND` | 监听地址,默认 `0.0.0.0:9315`反代时常为 `127.0.0.1:9315`。 | | `SECRETS_MCP_BIND` | 监听地址,默认 `127.0.0.1:9315`。容器内或直接对外暴露端口时请改为 `0.0.0.0:9315`反代时常为 `127.0.0.1:9315`。 |
| `GOOGLE_CLIENT_ID` / `GOOGLE_CLIENT_SECRET` | 可选;不配置则无 Google 登录入口。运行时从环境读取,勿写入 CI、勿打入二进制。 | | `GOOGLE_CLIENT_ID` / `GOOGLE_CLIENT_SECRET` | 可选;不配置则无 Google 登录入口。运行时从环境读取,勿写入 CI、勿打入二进制。 |
| `RUST_LOG` | 可选;日志级别,如 `secrets_mcp=debug`。 |
```bash ```bash
cargo run -p secrets-mcp cargo run -p secrets-mcp
@@ -77,7 +78,7 @@ flowchart LR
### 敏感数据传输 ### 敏感数据传输
- **OAuth `client_secret`** 只存服务端环境变量,不发给浏览器 - **OAuth `client_secret`** 只存服务端环境变量,不发给浏览器
- **API Key** 创建时原始 key 仅展示一次,库中只存 SHA-256 哈希 - **API Key** 当前存放在 `users.api_key`Dashboard 会明文展示并可重置
- **X-Encryption-Key** 随 MCP 请求经 TLS 传输,服务端仅在请求处理期间持有(不持久化) - **X-Encryption-Key** 随 MCP 请求经 TLS 传输,服务端仅在请求处理期间持有(不持久化)
- **生产环境必须走 HTTPS/TLS** - **生产环境必须走 HTTPS/TLS**
@@ -121,7 +122,7 @@ flowchart LR
## 数据模型 ## 数据模型
主表 **`entries`**`namespace``kind``name``tags``metadata`,多租户时带 `user_id`+ 子表 **`secrets`**(每行一个加密字段:`field_name``encrypted`)。另有 `entries_history``secrets_history``audit_log`,以及 **`users`**(含 `key_salt``key_check``key_params`)、**`oauth_accounts`**、**`api_keys`**。首次连库自动迁移建表。 主表 **`entries`**`namespace``kind``name``tags``metadata`,多租户时带 `user_id`+ 子表 **`secrets`**(每行一个加密字段:`field_name``encrypted`)。另有 `entries_history``secrets_history``audit_log`,以及 **`users`**(含 `key_salt``key_check``key_params``api_key`)、**`oauth_accounts`**。首次连库自动迁移建表。
| 位置 | 字段 | 说明 | | 位置 | 字段 | 说明 |
|------|------|------| |------|------|------|
@@ -142,9 +143,10 @@ flowchart LR
## 审计日志 ## 审计日志
`add``update``delete` 等写操作写入 **`audit_log`**(操作类型、对象、摘要,不含 secret 明文)。 `add``update``delete` 等写操作写入 **`audit_log`**(操作类型、对象、摘要,不含 secret 明文)。
其中业务条目事件使用 `[namespace/kind] name` 语义;登录类事件使用 `namespace='auth'`,此时 `kind/name` 表示认证目标(例如 `oauth/google`),不表示某条 secrets entry。
```sql ```sql
SELECT action, namespace, kind, name, actor, detail, created_at SELECT action, namespace, kind, name, detail, created_at
FROM audit_log FROM audit_log
ORDER BY created_at DESC ORDER BY created_at DESC
LIMIT 20; LIMIT 20;
@@ -164,9 +166,9 @@ deploy/ # systemd、.env 示例
见 [`.gitea/workflows/secrets.yml`](.gitea/workflows/secrets.yml)。 见 [`.gitea/workflows/secrets.yml`](.gitea/workflows/secrets.yml)。
- **触发**:任意分支 `push`,且变更路径包含 `crates/**``deploy/**`、根目录 `Cargo.toml` / `Cargo.lock` - **触发**:任意分支 `push`,且变更路径包含 `crates/**``deploy/**`、根目录 `Cargo.toml` / `Cargo.lock``.gitea/workflows/**`
- **流水线**:解析 `crates/secrets-mcp/Cargo.toml` 版本 → **若 `secrets-mcp-<version>` 的 tag 已存在则整次运行失败**(避免重复发版)→ 否则自动打 tag → `cargo fmt` / `clippy --locked` / `test --locked` → 交叉编译 `x86_64-unknown-linux-musl``secrets-mcp` - **流水线**:解析 `crates/secrets-mcp/Cargo.toml` 版本 → `cargo fmt` / `clippy --locked` / `test --locked` → 交叉编译 `x86_64-unknown-linux-musl``secrets-mcp` → 构建成功后打 tag `secrets-mcp-<version>`(若远端已存在同名 tag会先删除再于**当前提交**重建并推送,覆盖式发版)
- **Release可选**:配置仓库 Secret `RELEASE_TOKEN`Gitea PAT明文勿 base64会通过 API 创建**草稿** Release、在 Linux 构建成功后上传 `tar.gz``.sha256`,再自动将草稿**正式发布**;未配置则跳过创建 Release 与产物上传,仅保留 tag 构建结果。 - **Release可选**:配置仓库 Secret `RELEASE_TOKEN`Gitea PAT明文勿 base64会通过 API **创建或更新**已指向该 tag 的 Release非 draft上传 `tar.gz``.sha256`;未配置则跳过 API Release,仅 tag + 构建结果。
- **部署(可选)**:仅在 `main``feat/mcp``mcp` 分支且构建成功时,若已配置 `vars.DEPLOY_HOST``vars.DEPLOY_USER``secrets.DEPLOY_SSH_KEY`,则 `deploy-mcp` 通过 SCP/SSH 更新目标机二进制并 `systemctl restart secrets-mcp` - **部署(可选)**:仅在 `main``feat/mcp``mcp` 分支且构建成功时,若已配置 `vars.DEPLOY_HOST``vars.DEPLOY_USER``secrets.DEPLOY_SSH_KEY`,则 `deploy-mcp` 通过 SCP/SSH 更新目标机二进制并 `systemctl restart secrets-mcp`
- **通知(可选)**`vars.WEBHOOK_URL` 为飞书 Webhook 时,构建/部署/发布节点会推送简要状态。 - **通知(可选)**`vars.WEBHOOK_URL` 为飞书 Webhook 时,构建/部署/发布节点会推送简要状态。

View File

@@ -5,19 +5,8 @@ use uuid::Uuid;
pub const ACTION_LOGIN: &str = "login"; pub const ACTION_LOGIN: &str = "login";
pub const NAMESPACE_AUTH: &str = "auth"; pub const NAMESPACE_AUTH: &str = "auth";
/// Return the current OS user as the audit actor (falls back to empty string). fn login_detail(provider: &str, client_ip: Option<&str>, user_agent: Option<&str>) -> Value {
pub fn current_actor() -> String {
std::env::var("USER").unwrap_or_default()
}
fn login_detail(
user_id: Uuid,
provider: &str,
client_ip: Option<&str>,
user_agent: Option<&str>,
) -> Value {
json!({ json!({
"user_id": user_id,
"provider": provider, "provider": provider,
"client_ip": client_ip, "client_ip": client_ip,
"user_agent": user_agent, "user_agent": user_agent,
@@ -33,55 +22,54 @@ pub async fn log_login(
client_ip: Option<&str>, client_ip: Option<&str>,
user_agent: Option<&str>, user_agent: Option<&str>,
) { ) {
let actor = current_actor(); let detail = login_detail(provider, client_ip, user_agent);
let detail = login_detail(user_id, provider, client_ip, user_agent);
let result: Result<_, sqlx::Error> = sqlx::query( let result: Result<_, sqlx::Error> = sqlx::query(
"INSERT INTO audit_log (action, namespace, kind, name, detail, actor) \ "INSERT INTO audit_log (user_id, action, namespace, kind, name, detail) \
VALUES ($1, $2, $3, $4, $5, $6)", VALUES ($1, $2, $3, $4, $5, $6)",
) )
.bind(user_id)
.bind(ACTION_LOGIN) .bind(ACTION_LOGIN)
.bind(NAMESPACE_AUTH) .bind(NAMESPACE_AUTH)
.bind(kind) .bind(kind)
.bind(provider) .bind(provider)
.bind(&detail) .bind(&detail)
.bind(&actor)
.execute(pool) .execute(pool)
.await; .await;
if let Err(e) = result { if let Err(e) = result {
tracing::warn!(error = %e, kind, provider, "failed to write login audit log"); tracing::warn!(error = %e, kind, provider, "failed to write login audit log");
} else { } else {
tracing::debug!(kind, provider, ?user_id, actor, "login audit logged"); tracing::debug!(kind, provider, ?user_id, "login audit logged");
} }
} }
/// Write an audit entry within an existing transaction. /// Write an audit entry within an existing transaction.
pub async fn log_tx( pub async fn log_tx(
tx: &mut Transaction<'_, Postgres>, tx: &mut Transaction<'_, Postgres>,
user_id: Option<Uuid>,
action: &str, action: &str,
namespace: &str, namespace: &str,
kind: &str, kind: &str,
name: &str, name: &str,
detail: Value, detail: Value,
) { ) {
let actor = current_actor();
let result: Result<_, sqlx::Error> = sqlx::query( let result: Result<_, sqlx::Error> = sqlx::query(
"INSERT INTO audit_log (action, namespace, kind, name, detail, actor) \ "INSERT INTO audit_log (user_id, action, namespace, kind, name, detail) \
VALUES ($1, $2, $3, $4, $5, $6)", VALUES ($1, $2, $3, $4, $5, $6)",
) )
.bind(user_id)
.bind(action) .bind(action)
.bind(namespace) .bind(namespace)
.bind(kind) .bind(kind)
.bind(name) .bind(name)
.bind(&detail) .bind(&detail)
.bind(&actor)
.execute(&mut **tx) .execute(&mut **tx)
.await; .await;
if let Err(e) = result { if let Err(e) = result {
tracing::warn!(error = %e, "failed to write audit log"); tracing::warn!(error = %e, "failed to write audit log");
} else { } else {
tracing::debug!(action, namespace, kind, name, actor, "audit logged"); tracing::debug!(action, namespace, kind, name, "audit logged");
} }
} }
@@ -91,10 +79,8 @@ mod tests {
#[test] #[test]
fn login_detail_includes_expected_fields() { fn login_detail_includes_expected_fields() {
let user_id = Uuid::nil(); let detail = login_detail("google", Some("127.0.0.1"), Some("Mozilla/5.0"));
let detail = login_detail(user_id, "google", Some("127.0.0.1"), Some("Mozilla/5.0"));
assert_eq!(detail["user_id"], json!(user_id));
assert_eq!(detail["provider"], "google"); assert_eq!(detail["provider"], "google");
assert_eq!(detail["client_ip"], "127.0.0.1"); assert_eq!(detail["client_ip"], "127.0.0.1");
assert_eq!(detail["user_agent"], "Mozilla/5.0"); assert_eq!(detail["user_agent"], "Mozilla/5.0");

View File

@@ -55,35 +55,6 @@ pub fn decrypt_json(master_key: &[u8; 32], data: &[u8]) -> Result<Value> {
serde_json::from_slice(&bytes).context("deserialize decrypted JSON") serde_json::from_slice(&bytes).context("deserialize decrypted JSON")
} }
// ─── Per-user key management (DEPRECATED — kept only for migration) ───────────
/// Generate a new random 32-byte per-user encryption key.
#[allow(dead_code)]
pub fn generate_user_key() -> [u8; 32] {
use aes_gcm::aead::rand_core::RngCore;
let mut key = [0u8; 32];
OsRng.fill_bytes(&mut key);
key
}
/// Wrap a per-user key with the server master key using AES-256-GCM.
#[allow(dead_code)]
pub fn wrap_user_key(server_master_key: &[u8; 32], user_key: &[u8; 32]) -> Result<Vec<u8>> {
encrypt(server_master_key, user_key.as_ref())
}
/// Unwrap a per-user key using the server master key.
#[allow(dead_code)]
pub fn unwrap_user_key(server_master_key: &[u8; 32], wrapped: &[u8]) -> Result<[u8; 32]> {
let bytes = decrypt(server_master_key, wrapped)?;
if bytes.len() != 32 {
bail!("unwrapped user key has unexpected length {}", bytes.len());
}
let mut key = [0u8; 32];
key.copy_from_slice(&bytes);
Ok(key)
}
// ─── Client-supplied key extraction ────────────────────────────────────────── // ─── Client-supplied key extraction ──────────────────────────────────────────
/// Parse a 64-char hex string (from X-Encryption-Key header) into a 32-byte key. /// Parse a 64-char hex string (from X-Encryption-Key header) into a 32-byte key.
@@ -100,33 +71,6 @@ pub fn extract_key_from_hex(hex_str: &str) -> Result<[u8; 32]> {
Ok(key) Ok(key)
} }
// ─── Server master key ────────────────────────────────────────────────────────
/// Load the server master key from `SERVER_MASTER_KEY` environment variable (64 hex chars).
pub fn load_master_key_auto() -> Result<[u8; 32]> {
let hex_str = std::env::var("SERVER_MASTER_KEY").map_err(|_| {
anyhow::anyhow!(
"SERVER_MASTER_KEY is not set. \
Generate one with: openssl rand -hex 32"
)
})?;
if hex_str.is_empty() {
bail!("SERVER_MASTER_KEY is set but empty");
}
let bytes = hex::decode_hex(hex_str.trim())?;
if bytes.len() != 32 {
bail!(
"SERVER_MASTER_KEY must be 64 hex chars (32 bytes), got {} bytes",
bytes.len()
);
}
let mut key = [0u8; 32];
key.copy_from_slice(&bytes);
Ok(key)
}
// ─── Public hex helpers ─────────────────────────────────────────────────────── // ─── Public hex helpers ───────────────────────────────────────────────────────
pub mod hex { pub mod hex {
@@ -186,22 +130,4 @@ mod tests {
let dec = decrypt_json(&key, &enc).unwrap(); let dec = decrypt_json(&key, &enc).unwrap();
assert_eq!(dec, value); assert_eq!(dec, value);
} }
#[test]
fn user_key_wrap_unwrap_roundtrip() {
let server_key = [0xABu8; 32];
let user_key = [0xCDu8; 32];
let wrapped = wrap_user_key(&server_key, &user_key).unwrap();
let unwrapped = unwrap_user_key(&server_key, &wrapped).unwrap();
assert_eq!(unwrapped, user_key);
}
#[test]
fn user_key_wrap_wrong_server_key_fails() {
let server_key1 = [0xABu8; 32];
let server_key2 = [0xEFu8; 32];
let user_key = [0xCDu8; 32];
let wrapped = wrap_user_key(&server_key1, &user_key).unwrap();
assert!(unwrap_user_key(&server_key2, &wrapped).is_err());
}
} }

View File

@@ -3,8 +3,6 @@ use serde_json::Value;
use sqlx::PgPool; use sqlx::PgPool;
use sqlx::postgres::PgPoolOptions; use sqlx::postgres::PgPoolOptions;
use crate::audit::current_actor;
pub async fn create_pool(database_url: &str) -> Result<PgPool> { pub async fn create_pool(database_url: &str) -> Result<PgPool> {
tracing::debug!("connecting to database"); tracing::debug!("connecting to database");
let pool = PgPoolOptions::new() let pool = PgPoolOptions::new()
@@ -67,17 +65,18 @@ pub async fn migrate(pool: &PgPool) -> Result<()> {
-- ── audit_log: append-only operation log ───────────────────────────────── -- ── audit_log: append-only operation log ─────────────────────────────────
CREATE TABLE IF NOT EXISTS audit_log ( CREATE TABLE IF NOT EXISTS audit_log (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
user_id UUID,
action VARCHAR(32) NOT NULL, action VARCHAR(32) NOT NULL,
namespace VARCHAR(64) NOT NULL, namespace VARCHAR(64) NOT NULL,
kind VARCHAR(64) NOT NULL, kind VARCHAR(64) NOT NULL,
name VARCHAR(256) NOT NULL, name VARCHAR(256) NOT NULL,
detail JSONB NOT NULL DEFAULT '{}', detail JSONB NOT NULL DEFAULT '{}',
actor VARCHAR(128) NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
); );
CREATE INDEX IF NOT EXISTS idx_audit_log_created ON audit_log(created_at DESC); CREATE INDEX IF NOT EXISTS idx_audit_log_created ON audit_log(created_at DESC);
CREATE INDEX IF NOT EXISTS idx_audit_log_ns_kind ON audit_log(namespace, kind); CREATE INDEX IF NOT EXISTS idx_audit_log_ns_kind ON audit_log(namespace, kind);
CREATE INDEX IF NOT EXISTS idx_audit_log_user_id ON audit_log(user_id) WHERE user_id IS NOT NULL;
-- ── entries_history ─────────────────────────────────────────────────────── -- ── entries_history ───────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS entries_history ( CREATE TABLE IF NOT EXISTS entries_history (
@@ -90,7 +89,6 @@ pub async fn migrate(pool: &PgPool) -> Result<()> {
action VARCHAR(16) NOT NULL, action VARCHAR(16) NOT NULL,
tags TEXT[] NOT NULL DEFAULT '{}', tags TEXT[] NOT NULL DEFAULT '{}',
metadata JSONB NOT NULL DEFAULT '{}', metadata JSONB NOT NULL DEFAULT '{}',
actor VARCHAR(128) NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
); );
@@ -103,6 +101,7 @@ pub async fn migrate(pool: &PgPool) -> Result<()> {
ALTER TABLE entries_history ADD COLUMN IF NOT EXISTS user_id UUID; ALTER TABLE entries_history ADD COLUMN IF NOT EXISTS user_id UUID;
CREATE INDEX IF NOT EXISTS idx_entries_history_user_id CREATE INDEX IF NOT EXISTS idx_entries_history_user_id
ON entries_history(user_id) WHERE user_id IS NOT NULL; ON entries_history(user_id) WHERE user_id IS NOT NULL;
ALTER TABLE entries_history DROP COLUMN IF EXISTS actor;
-- ── secrets_history: field-level snapshot ──────────────────────────────── -- ── secrets_history: field-level snapshot ────────────────────────────────
CREATE TABLE IF NOT EXISTS secrets_history ( CREATE TABLE IF NOT EXISTS secrets_history (
@@ -113,7 +112,6 @@ pub async fn migrate(pool: &PgPool) -> Result<()> {
field_name VARCHAR(256) NOT NULL, field_name VARCHAR(256) NOT NULL,
encrypted BYTEA NOT NULL DEFAULT '\x', encrypted BYTEA NOT NULL DEFAULT '\x',
action VARCHAR(16) NOT NULL, action VARCHAR(16) NOT NULL,
actor VARCHAR(128) NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
); );
@@ -122,6 +120,12 @@ pub async fn migrate(pool: &PgPool) -> Result<()> {
CREATE INDEX IF NOT EXISTS idx_secrets_history_secret_id CREATE INDEX IF NOT EXISTS idx_secrets_history_secret_id
ON secrets_history(secret_id); ON secrets_history(secret_id);
-- Drop redundant actor column (derivable via entries_history JOIN)
ALTER TABLE secrets_history DROP COLUMN IF EXISTS actor;
-- Drop redundant actor column; user_id already identifies the business user
ALTER TABLE audit_log DROP COLUMN IF EXISTS actor;
-- ── users ───────────────────────────────────────────────────────────────── -- ── users ─────────────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS users ( CREATE TABLE IF NOT EXISTS users (
id UUID PRIMARY KEY DEFAULT uuidv7(), id UUID PRIMARY KEY DEFAULT uuidv7(),
@@ -152,14 +156,110 @@ pub async fn migrate(pool: &PgPool) -> Result<()> {
CREATE INDEX IF NOT EXISTS idx_oauth_accounts_user ON oauth_accounts(user_id); CREATE INDEX IF NOT EXISTS idx_oauth_accounts_user ON oauth_accounts(user_id);
CREATE UNIQUE INDEX IF NOT EXISTS idx_oauth_accounts_user_provider CREATE UNIQUE INDEX IF NOT EXISTS idx_oauth_accounts_user_provider
ON oauth_accounts(user_id, provider); ON oauth_accounts(user_id, provider);
-- FK: user_id columns -> users(id) (nullable = legacy rows; ON DELETE SET NULL)
DO $$ BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'fk_entries_user_id'
) THEN
ALTER TABLE entries
ADD CONSTRAINT fk_entries_user_id
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL;
END IF;
END $$;
DO $$ BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'fk_entries_history_user_id'
) THEN
ALTER TABLE entries_history
ADD CONSTRAINT fk_entries_history_user_id
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL;
END IF;
END $$;
DO $$ BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'fk_audit_log_user_id'
) THEN
ALTER TABLE audit_log
ADD CONSTRAINT fk_audit_log_user_id
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL;
END IF;
END $$;
"#, "#,
) )
.execute(pool) .execute(pool)
.await?; .await?;
restore_plaintext_api_keys(pool).await?;
tracing::debug!("migrations complete"); tracing::debug!("migrations complete");
Ok(()) Ok(())
} }
async fn restore_plaintext_api_keys(pool: &PgPool) -> Result<()> {
let has_users_api_key: bool = sqlx::query_scalar(
"SELECT EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'users'
AND column_name = 'api_key'
)",
)
.fetch_one(pool)
.await?;
if !has_users_api_key {
sqlx::query("ALTER TABLE users ADD COLUMN api_key TEXT")
.execute(pool)
.await?;
sqlx::query("CREATE UNIQUE INDEX IF NOT EXISTS idx_users_api_key ON users(api_key) WHERE api_key IS NOT NULL")
.execute(pool)
.await?;
}
let has_api_keys_table: bool = sqlx::query_scalar(
"SELECT EXISTS (
SELECT 1
FROM information_schema.tables
WHERE table_schema = 'public'
AND table_name = 'api_keys'
)",
)
.fetch_one(pool)
.await?;
if !has_api_keys_table {
return Ok(());
}
#[derive(sqlx::FromRow)]
struct UserWithoutKey {
id: uuid::Uuid,
}
let users_without_key: Vec<UserWithoutKey> =
sqlx::query_as("SELECT DISTINCT user_id AS id FROM api_keys WHERE user_id NOT IN (SELECT id FROM users WHERE api_key IS NOT NULL)")
.fetch_all(pool)
.await?;
for user in users_without_key {
let new_key = crate::service::api_key::generate_api_key();
sqlx::query("UPDATE users SET api_key = $1 WHERE id = $2")
.bind(&new_key)
.bind(user.id)
.execute(pool)
.await?;
}
sqlx::query("DROP TABLE IF EXISTS api_keys")
.execute(pool)
.await?;
Ok(())
}
// ── Entry-level history snapshot ───────────────────────────────────────────── // ── Entry-level history snapshot ─────────────────────────────────────────────
pub struct EntrySnapshotParams<'a> { pub struct EntrySnapshotParams<'a> {
@@ -178,11 +278,10 @@ pub async fn snapshot_entry_history(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
p: EntrySnapshotParams<'_>, p: EntrySnapshotParams<'_>,
) -> Result<()> { ) -> Result<()> {
let actor = current_actor();
sqlx::query( sqlx::query(
"INSERT INTO entries_history \ "INSERT INTO entries_history \
(entry_id, namespace, kind, name, version, action, tags, metadata, actor, user_id) \ (entry_id, namespace, kind, name, version, action, tags, metadata, user_id) \
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)", VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)",
) )
.bind(p.entry_id) .bind(p.entry_id)
.bind(p.namespace) .bind(p.namespace)
@@ -192,7 +291,6 @@ pub async fn snapshot_entry_history(
.bind(p.action) .bind(p.action)
.bind(p.tags) .bind(p.tags)
.bind(p.metadata) .bind(p.metadata)
.bind(&actor)
.bind(p.user_id) .bind(p.user_id)
.execute(&mut **tx) .execute(&mut **tx)
.await?; .await?;
@@ -214,11 +312,10 @@ pub async fn snapshot_secret_history(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
p: SecretSnapshotParams<'_>, p: SecretSnapshotParams<'_>,
) -> Result<()> { ) -> Result<()> {
let actor = current_actor();
sqlx::query( sqlx::query(
"INSERT INTO secrets_history \ "INSERT INTO secrets_history \
(entry_id, secret_id, entry_version, field_name, encrypted, action, actor) \ (entry_id, secret_id, entry_version, field_name, encrypted, action) \
VALUES ($1, $2, $3, $4, $5, $6, $7)", VALUES ($1, $2, $3, $4, $5, $6)",
) )
.bind(p.entry_id) .bind(p.entry_id)
.bind(p.secret_id) .bind(p.secret_id)
@@ -226,7 +323,6 @@ pub async fn snapshot_secret_history(
.bind(p.field_name) .bind(p.field_name)
.bind(p.encrypted) .bind(p.encrypted)
.bind(p.action) .bind(p.action)
.bind(&actor)
.execute(&mut **tx) .execute(&mut **tx)
.await?; .await?;
Ok(()) Ok(())

View File

@@ -9,6 +9,7 @@ use uuid::Uuid;
#[derive(Debug, Serialize, Deserialize, sqlx::FromRow)] #[derive(Debug, Serialize, Deserialize, sqlx::FromRow)]
pub struct Entry { pub struct Entry {
pub id: Uuid, pub id: Uuid,
pub user_id: Option<Uuid>,
pub namespace: String, pub namespace: String,
pub kind: String, pub kind: String,
pub name: String, pub name: String,
@@ -174,6 +175,19 @@ pub struct OauthAccount {
pub created_at: DateTime<Utc>, pub created_at: DateTime<Utc>,
} }
/// A single audit log row, optionally scoped to a business user.
#[derive(Debug, Serialize, Deserialize, sqlx::FromRow)]
pub struct AuditLogEntry {
pub id: i64,
pub user_id: Option<Uuid>,
pub action: String,
pub namespace: String,
pub kind: String,
pub name: String,
pub detail: Value,
pub created_at: DateTime<Utc>,
}
// ── TOML ↔ JSON value conversion ────────────────────────────────────────────── // ── TOML ↔ JSON value conversion ──────────────────────────────────────────────
/// Convert a serde_json Value to a toml Value. /// Convert a serde_json Value to a toml Value.

View File

@@ -346,6 +346,7 @@ pub async fn run(pool: &PgPool, params: AddParams<'_>, master_key: &[u8; 32]) ->
crate::audit::log_tx( crate::audit::log_tx(
&mut tx, &mut tx,
params.user_id,
"add", "add",
params.namespace, params.namespace,
params.kind, params.kind,

View File

@@ -0,0 +1,23 @@
use anyhow::Result;
use sqlx::PgPool;
use uuid::Uuid;
use crate::models::AuditLogEntry;
pub async fn list_for_user(pool: &PgPool, user_id: Uuid, limit: i64) -> Result<Vec<AuditLogEntry>> {
let limit = limit.clamp(1, 200);
let rows = sqlx::query_as(
"SELECT id, user_id, action, namespace, kind, name, detail, created_at \
FROM audit_log \
WHERE user_id = $1 \
ORDER BY created_at DESC, id DESC \
LIMIT $2",
)
.bind(user_id)
.bind(limit)
.fetch_all(pool)
.await?;
Ok(rows)
}

View File

@@ -137,7 +137,7 @@ async fn delete_one(
}; };
snapshot_and_delete(&mut tx, namespace, kind, name, &row, user_id).await?; snapshot_and_delete(&mut tx, namespace, kind, name, &row, user_id).await?;
crate::audit::log_tx(&mut tx, "delete", namespace, kind, name, json!({})).await; crate::audit::log_tx(&mut tx, user_id, "delete", namespace, kind, name, json!({})).await;
tx.commit().await?; tx.commit().await?;
Ok(DeleteResult { Ok(DeleteResult {
@@ -240,6 +240,7 @@ async fn delete_bulk(
.await?; .await?;
crate::audit::log_tx( crate::audit::log_tx(
&mut tx, &mut tx,
user_id,
"delete", "delete",
namespace, namespace,
&row.kind, &row.kind,

View File

@@ -7,7 +7,6 @@ use uuid::Uuid;
pub struct HistoryEntry { pub struct HistoryEntry {
pub version: i64, pub version: i64,
pub action: String, pub action: String,
pub actor: String,
pub created_at: String, pub created_at: String,
} }
@@ -23,13 +22,12 @@ pub async fn run(
struct Row { struct Row {
version: i64, version: i64,
action: String, action: String,
actor: String,
created_at: chrono::DateTime<chrono::Utc>, created_at: chrono::DateTime<chrono::Utc>,
} }
let rows: Vec<Row> = if let Some(uid) = user_id { let rows: Vec<Row> = if let Some(uid) = user_id {
sqlx::query_as( sqlx::query_as(
"SELECT version, action, actor, created_at FROM entries_history \ "SELECT version, action, created_at FROM entries_history \
WHERE namespace = $1 AND kind = $2 AND name = $3 AND user_id = $4 \ WHERE namespace = $1 AND kind = $2 AND name = $3 AND user_id = $4 \
ORDER BY id DESC LIMIT $5", ORDER BY id DESC LIMIT $5",
) )
@@ -42,7 +40,7 @@ pub async fn run(
.await? .await?
} else { } else {
sqlx::query_as( sqlx::query_as(
"SELECT version, action, actor, created_at FROM entries_history \ "SELECT version, action, created_at FROM entries_history \
WHERE namespace = $1 AND kind = $2 AND name = $3 AND user_id IS NULL \ WHERE namespace = $1 AND kind = $2 AND name = $3 AND user_id IS NULL \
ORDER BY id DESC LIMIT $4", ORDER BY id DESC LIMIT $4",
) )
@@ -59,7 +57,6 @@ pub async fn run(
.map(|r| HistoryEntry { .map(|r| HistoryEntry {
version: r.version, version: r.version,
action: r.action, action: r.action,
actor: r.actor,
created_at: r.created_at.format("%Y-%m-%dT%H:%M:%SZ").to_string(), created_at: r.created_at.format("%Y-%m-%dT%H:%M:%SZ").to_string(),
}) })
.collect()) .collect())

View File

@@ -1,5 +1,6 @@
pub mod add; pub mod add;
pub mod api_key; pub mod api_key;
pub mod audit_log;
pub mod delete; pub mod delete;
pub mod env_map; pub mod env_map;
pub mod export; pub mod export;

View File

@@ -274,6 +274,7 @@ pub async fn run(
crate::audit::log_tx( crate::audit::log_tx(
&mut tx, &mut tx,
user_id,
"rollback", "rollback",
namespace, namespace,
kind, kind,

View File

@@ -131,7 +131,7 @@ async fn fetch_entries_paged(pool: &PgPool, a: &SearchParams<'_>) -> Result<Vec<
}; };
let sql = format!( let sql = format!(
"SELECT id, COALESCE(user_id, '00000000-0000-0000-0000-000000000000'::uuid) AS user_id, \ "SELECT id, user_id, \
namespace, kind, name, tags, metadata, version, created_at, updated_at \ namespace, kind, name, tags, metadata, version, created_at, updated_at \
FROM entries {where_clause} ORDER BY {order} LIMIT ${limit_idx} OFFSET ${offset_idx}" FROM entries {where_clause} ORDER BY {order} LIMIT ${limit_idx} OFFSET ${offset_idx}"
); );
@@ -212,8 +212,7 @@ pub async fn fetch_secrets_for_entries(
#[derive(sqlx::FromRow)] #[derive(sqlx::FromRow)]
struct EntryRaw { struct EntryRaw {
id: Uuid, id: Uuid,
#[allow(dead_code)] // Selected for row shape; Entry model has no user_id field user_id: Option<Uuid>,
user_id: Uuid,
namespace: String, namespace: String,
kind: String, kind: String,
name: String, name: String,
@@ -228,6 +227,7 @@ impl From<EntryRaw> for Entry {
fn from(r: EntryRaw) -> Self { fn from(r: EntryRaw) -> Self {
Entry { Entry {
id: r.id, id: r.id,
user_id: r.user_id,
namespace: r.namespace, namespace: r.namespace,
kind: r.kind, kind: r.kind,
name: r.name, name: r.name,

View File

@@ -241,6 +241,7 @@ pub async fn run(
crate::audit::log_tx( crate::audit::log_tx(
&mut tx, &mut tx,
params.user_id,
"update", "update",
params.namespace, params.namespace,
params.kind, params.kind,

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "secrets-mcp" name = "secrets-mcp"
version = "0.1.6" version = "0.2.2"
edition.workspace = true edition.workspace = true
[[bin]] [[bin]]
@@ -17,8 +17,10 @@ rmcp = { version = "1", features = ["server", "macros", "transport-streamable-ht
axum = "0.8" axum = "0.8"
axum-extra = { version = "0.10", features = ["typed-header"] } axum-extra = { version = "0.10", features = ["typed-header"] }
tower = "0.5" tower = "0.5"
tower-http = { version = "0.6", features = ["cors"] } tower-http = { version = "0.6", features = ["cors", "trace"] }
tower-sessions = "0.14" tower-sessions = "0.14"
tower-sessions-sqlx-store-chrono = { version = "0.14", features = ["postgres"] }
time = "0.3"
# OAuth (manual token exchange via reqwest) # OAuth (manual token exchange via reqwest)
reqwest.workspace = true reqwest.workspace = true

View File

@@ -9,7 +9,6 @@ use axum::{
use sqlx::PgPool; use sqlx::PgPool;
use uuid::Uuid; use uuid::Uuid;
use secrets_core::audit::log_login;
use secrets_core::service::api_key::validate_api_key; use secrets_core::service::api_key::validate_api_key;
/// Injected into request extensions after Bearer token validation. /// Injected into request extensions after Bearer token validation.
@@ -35,15 +34,6 @@ fn log_client_ip(req: &Request) -> Option<String> {
.map(|c| c.ip().to_string()) .map(|c| c.ip().to_string())
} }
fn log_user_agent(req: &Request) -> Option<String> {
req.headers()
.get(axum::http::header::USER_AGENT)
.and_then(|v| v.to_str().ok())
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
}
/// Axum middleware that validates Bearer API keys for the /mcp route. /// Axum middleware that validates Bearer API keys for the /mcp route.
/// Passes all non-MCP paths through without authentication. /// Passes all non-MCP paths through without authentication.
pub async fn bearer_auth_middleware( pub async fn bearer_auth_middleware(
@@ -54,7 +44,6 @@ pub async fn bearer_auth_middleware(
let path = req.uri().path(); let path = req.uri().path();
let method = req.method().as_str(); let method = req.method().as_str();
let client_ip = log_client_ip(&req); let client_ip = log_client_ip(&req);
let user_agent = log_user_agent(&req);
// Only authenticate /mcp paths // Only authenticate /mcp paths
if !path.starts_with("/mcp") { if !path.starts_with("/mcp") {
@@ -95,15 +84,6 @@ pub async fn bearer_auth_middleware(
match validate_api_key(&pool, raw_key).await { match validate_api_key(&pool, raw_key).await {
Ok(Some(user_id)) => { Ok(Some(user_id)) => {
log_login(
&pool,
"api_key",
"bearer",
user_id,
client_ip.as_deref(),
user_agent.as_deref(),
)
.await;
tracing::debug!(?user_id, "api key authenticated"); tracing::debug!(?user_id, "api key authenticated");
let mut req = req; let mut req = req;
req.extensions_mut().insert(AuthUser { user_id }); req.extensions_mut().insert(AuthUser { user_id });

View File

@@ -0,0 +1,262 @@
use std::net::SocketAddr;
use std::time::Instant;
use axum::{
body::{Body, Bytes, to_bytes},
extract::{ConnectInfo, Request},
http::{
HeaderMap, Method, StatusCode,
header::{CONTENT_LENGTH, CONTENT_TYPE, USER_AGENT},
},
middleware::Next,
response::{IntoResponse, Response},
};
/// Axum middleware that logs structured info for every HTTP request.
///
/// All requests: method, path, status, latency_ms, client_ip, user_agent.
/// POST /mcp requests: additionally parses JSON-RPC body for jsonrpc_method,
/// tool_name, jsonrpc_id, mcp_session, batch_size.
///
/// Sensitive headers (Authorization, X-Encryption-Key) and secret values
/// are never logged.
pub async fn request_logging_middleware(req: Request, next: Next) -> Response {
let method = req.method().clone();
let path = req.uri().path().to_string();
let ip = client_ip(&req);
let ua = header_str(req.headers(), USER_AGENT);
let content_len = header_str(req.headers(), CONTENT_LENGTH).and_then(|v| v.parse::<u64>().ok());
let mcp_session = req
.headers()
.get("mcp-session-id")
.or_else(|| req.headers().get("x-mcp-session"))
.and_then(|v| v.to_str().ok())
.map(|s| s.to_string());
let is_mcp_post = path.starts_with("/mcp") && method == Method::POST;
let is_json = header_str(req.headers(), CONTENT_TYPE)
.map(|ct| ct.contains("application/json"))
.unwrap_or(false);
let start = Instant::now();
// For MCP JSON-RPC POST requests, buffer body to extract JSON-RPC metadata.
// We cap at 512 KiB to avoid buffering large payloads.
if is_mcp_post && is_json {
let cap = content_len.unwrap_or(0);
if cap <= 512 * 1024 {
let (parts, body) = req.into_parts();
match to_bytes(body, 512 * 1024).await {
Ok(bytes) => {
let rpc = parse_jsonrpc_meta(&bytes);
let req = Request::from_parts(parts, Body::from(bytes));
let resp = next.run(req).await;
let status = resp.status().as_u16();
let elapsed = start.elapsed().as_millis();
log_mcp_request(
&method,
&path,
status,
elapsed,
ip.as_deref(),
ua.as_deref(),
content_len,
mcp_session.as_deref(),
&rpc,
);
return resp;
}
Err(e) => {
tracing::warn!(path, error = %e, "failed to buffer MCP request body for logging");
let elapsed = start.elapsed().as_millis();
tracing::info!(
method = method.as_str(),
path,
status = StatusCode::INTERNAL_SERVER_ERROR.as_u16(),
elapsed_ms = elapsed,
client_ip = ip.as_deref(),
ua = ua.as_deref(),
content_length = content_len,
mcp_session = mcp_session.as_deref(),
"mcp request",
);
return (
StatusCode::INTERNAL_SERVER_ERROR,
"failed to read request body",
)
.into_response();
}
}
}
}
let resp = next.run(req).await;
let status = resp.status().as_u16();
let elapsed = start.elapsed().as_millis();
// Known client probe patterns that legitimately 404 — downgrade to debug to
// avoid noise in production logs. These are:
// • GET /.well-known/* — OAuth/OIDC discovery by MCP clients (RFC 8414 / RFC 9728)
// • GET /mcp → 404 — old SSE-transport compatibility probe by clients
let is_expected_probe_404 = status == 404
&& (path.starts_with("/.well-known/")
|| (method == Method::GET && path.starts_with("/mcp")));
if is_expected_probe_404 {
tracing::debug!(
method = method.as_str(),
path,
status,
elapsed_ms = elapsed,
client_ip = ip.as_deref(),
ua = ua.as_deref(),
"probe request (not found — expected)",
);
} else {
log_http_request(
&method,
&path,
status,
elapsed,
ip.as_deref(),
ua.as_deref(),
content_len,
);
}
resp
}
// ── Logging helpers ───────────────────────────────────────────────────────────
fn log_http_request(
method: &Method,
path: &str,
status: u16,
elapsed_ms: u128,
client_ip: Option<&str>,
ua: Option<&str>,
content_length: Option<u64>,
) {
tracing::info!(
method = method.as_str(),
path,
status,
elapsed_ms,
client_ip,
ua,
content_length,
"http request",
);
}
#[allow(clippy::too_many_arguments)]
fn log_mcp_request(
method: &Method,
path: &str,
status: u16,
elapsed_ms: u128,
client_ip: Option<&str>,
ua: Option<&str>,
content_length: Option<u64>,
mcp_session: Option<&str>,
rpc: &JsonRpcMeta,
) {
tracing::info!(
method = method.as_str(),
path,
status,
elapsed_ms,
client_ip,
ua,
content_length,
mcp_session,
jsonrpc = rpc.rpc_method.as_deref(),
tool = rpc.tool_name.as_deref(),
jsonrpc_id = rpc.request_id.as_deref(),
batch_size = rpc.batch_size,
"mcp request",
);
}
// ── JSON-RPC body parsing ─────────────────────────────────────────────────────
#[derive(Debug, Default)]
struct JsonRpcMeta {
request_id: Option<String>,
rpc_method: Option<String>,
tool_name: Option<String>,
batch_size: Option<usize>,
}
fn parse_jsonrpc_meta(bytes: &Bytes) -> JsonRpcMeta {
let Ok(value) = serde_json::from_slice::<serde_json::Value>(bytes) else {
return JsonRpcMeta::default();
};
if let Some(arr) = value.as_array() {
// Batch request: summarise method(s) from first element only
let first = arr.first().map(parse_single).unwrap_or_default();
return JsonRpcMeta {
batch_size: Some(arr.len()),
..first
};
}
parse_single(&value)
}
fn parse_single(value: &serde_json::Value) -> JsonRpcMeta {
let request_id = value.get("id").and_then(json_to_string);
let rpc_method = value
.get("method")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let tool_name = value
.pointer("/params/name")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
JsonRpcMeta {
request_id,
rpc_method,
tool_name,
batch_size: None,
}
}
fn json_to_string(value: &serde_json::Value) -> Option<String> {
match value {
serde_json::Value::Null => None,
serde_json::Value::String(s) => Some(s.clone()),
serde_json::Value::Number(n) => Some(n.to_string()),
serde_json::Value::Bool(b) => Some(b.to_string()),
other => Some(other.to_string()),
}
}
// ── Header helpers ────────────────────────────────────────────────────────────
fn header_str(headers: &HeaderMap, name: impl axum::http::header::AsHeaderName) -> Option<String> {
headers
.get(name)
.and_then(|v| v.to_str().ok())
.map(|s| s.to_string())
}
fn client_ip(req: &Request) -> Option<String> {
if let Some(first) = req
.headers()
.get("x-forwarded-for")
.and_then(|v| v.to_str().ok())
.and_then(|s| s.split(',').next())
{
let s = first.trim();
if !s.is_empty() {
return Some(s.to_string());
}
}
req.extensions()
.get::<ConnectInfo<SocketAddr>>()
.map(|c| c.ip().to_string())
}

View File

@@ -1,4 +1,5 @@
mod auth; mod auth;
mod logging;
mod oauth; mod oauth;
mod tools; mod tools;
mod web; mod web;
@@ -14,8 +15,11 @@ use rmcp::transport::streamable_http_server::{
use sqlx::PgPool; use sqlx::PgPool;
use tower_http::cors::{Any, CorsLayer}; use tower_http::cors::{Any, CorsLayer};
use tower_sessions::cookie::SameSite; use tower_sessions::cookie::SameSite;
use tower_sessions::{MemoryStore, SessionManagerLayer}; use tower_sessions::session_store::ExpiredDeletion;
use tower_sessions::{Expiry, SessionManagerLayer};
use tower_sessions_sqlx_store_chrono::PostgresStore;
use tracing_subscriber::EnvFilter; use tracing_subscriber::EnvFilter;
use tracing_subscriber::fmt::time::FormatTime;
use secrets_core::config::resolve_db_url; use secrets_core::config::resolve_db_url;
use secrets_core::db::{create_pool, migrate}; use secrets_core::db::{create_pool, migrate};
@@ -46,14 +50,30 @@ fn load_oauth_config(prefix: &str, base_url: &str, path: &str) -> Option<OAuthCo
}) })
} }
/// Log line timestamps in the process local timezone (honors `TZ` / system zone).
#[derive(Clone, Copy, Default)]
struct LocalRfc3339Time;
impl FormatTime for LocalRfc3339Time {
fn format_time(&self, w: &mut tracing_subscriber::fmt::format::Writer<'_>) -> std::fmt::Result {
write!(
w,
"{}",
chrono::Local::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, false)
)
}
}
#[tokio::main] #[tokio::main]
async fn main() -> Result<()> { async fn main() -> Result<()> {
// Load .env if present // Load .env if present
let _ = dotenvy::dotenv(); let _ = dotenvy::dotenv();
tracing_subscriber::fmt() tracing_subscriber::fmt()
.with_timer(LocalRfc3339Time)
.with_env_filter( .with_env_filter(
EnvFilter::try_from_default_env().unwrap_or_else(|_| "secrets_mcp=info".into()), EnvFilter::try_from_default_env()
.unwrap_or_else(|_| "secrets_mcp=info,tower_http=info".into()),
) )
.init(); .init();
@@ -70,7 +90,8 @@ async fn main() -> Result<()> {
// ── Configuration ───────────────────────────────────────────────────────── // ── Configuration ─────────────────────────────────────────────────────────
let base_url = load_env_var("BASE_URL").unwrap_or_else(|| "http://localhost:9315".to_string()); let base_url = load_env_var("BASE_URL").unwrap_or_else(|| "http://localhost:9315".to_string());
let bind_addr = load_env_var("SECRETS_MCP_BIND").unwrap_or_else(|| "0.0.0.0:9315".to_string()); let bind_addr =
load_env_var("SECRETS_MCP_BIND").unwrap_or_else(|| "127.0.0.1:9315".to_string());
// ── OAuth providers ─────────────────────────────────────────────────────── // ── OAuth providers ───────────────────────────────────────────────────────
let google_config = load_oauth_config("GOOGLE", &base_url, "/auth/google/callback"); let google_config = load_oauth_config("GOOGLE", &base_url, "/auth/google/callback");
@@ -81,12 +102,23 @@ async fn main() -> Result<()> {
); );
} }
// ── Session store ───────────────────────────────────────────────────────── // ── Session store (PostgreSQL-backed) ─────────────────────────────────────
let session_store = MemoryStore::default(); let session_store = PostgresStore::new(pool.clone());
session_store
.migrate()
.await
.context("failed to run session table migration")?;
// Prune expired rows every hour; task is aborted when the server shuts down.
let session_cleanup = tokio::spawn(
session_store
.clone()
.continuously_delete_expired(tokio::time::Duration::from_secs(3600)),
);
// Strict would drop the session cookie on redirect from Google → our origin (cross-site nav). // Strict would drop the session cookie on redirect from Google → our origin (cross-site nav).
let session_layer = SessionManagerLayer::new(session_store) let session_layer = SessionManagerLayer::new(session_store)
.with_secure(base_url.starts_with("https://")) .with_secure(base_url.starts_with("https://"))
.with_same_site(SameSite::Lax); .with_same_site(SameSite::Lax)
.with_expiry(Expiry::OnInactivity(time::Duration::days(14)));
// ── App state ───────────────────────────────────────────────────────────── // ── App state ─────────────────────────────────────────────────────────────
let app_state = AppState { let app_state = AppState {
@@ -120,6 +152,9 @@ async fn main() -> Result<()> {
let router = Router::new() let router = Router::new()
.merge(web::web_router()) .merge(web::web_router())
.nest_service("/mcp", mcp_service) .nest_service("/mcp", mcp_service)
.layer(axum::middleware::from_fn(
logging::request_logging_middleware,
))
.layer(axum::middleware::from_fn_with_state( .layer(axum::middleware::from_fn_with_state(
pool, pool,
auth::bearer_auth_middleware, auth::bearer_auth_middleware,
@@ -144,6 +179,7 @@ async fn main() -> Result<()> {
.await .await
.context("server error")?; .context("server error")?;
session_cleanup.abort();
Ok(()) Ok(())
} }

View File

@@ -1,4 +1,5 @@
use std::sync::Arc; use std::sync::Arc;
use std::time::Instant;
use anyhow::Result; use anyhow::Result;
use rmcp::{ use rmcp::{
@@ -16,6 +17,7 @@ use serde::Deserialize;
use sqlx::PgPool; use sqlx::PgPool;
use uuid::Uuid; use uuid::Uuid;
use secrets_core::models::ExportFormat;
use secrets_core::service::{ use secrets_core::service::{
add::{AddParams, run as svc_add}, add::{AddParams, run as svc_add},
delete::{DeleteParams, run as svc_delete}, delete::{DeleteParams, run as svc_delete},
@@ -29,6 +31,32 @@ use secrets_core::service::{
use crate::auth::AuthUser; use crate::auth::AuthUser;
// ── MCP client-facing errors (no internal details) ───────────────────────────
fn mcp_err_missing_http_parts() -> rmcp::ErrorData {
rmcp::ErrorData::internal_error("Invalid MCP request context.", None)
}
fn mcp_err_internal_logged(
tool: &'static str,
user_id: Option<Uuid>,
err: impl std::fmt::Display,
) -> rmcp::ErrorData {
tracing::warn!(tool, ?user_id, error = %err, "tool call failed");
rmcp::ErrorData::internal_error(
"Request failed due to a server error. Check service logs if you need details.",
None,
)
}
fn mcp_err_invalid_encryption_key_logged(err: impl std::fmt::Display) -> rmcp::ErrorData {
tracing::warn!(error = %err, "invalid X-Encryption-Key");
rmcp::ErrorData::invalid_request(
"Invalid X-Encryption-Key: must be exactly 64 hexadecimal characters (32-byte key).",
None,
)
}
// ── Shared state ────────────────────────────────────────────────────────────── // ── Shared state ──────────────────────────────────────────────────────────────
#[derive(Clone)] #[derive(Clone)]
@@ -50,7 +78,7 @@ impl SecretsService {
let parts = ctx let parts = ctx
.extensions .extensions
.get::<http::request::Parts>() .get::<http::request::Parts>()
.ok_or_else(|| rmcp::ErrorData::internal_error("Missing HTTP parts", None))?; .ok_or_else(mcp_err_missing_http_parts)?;
Ok(parts.extensions.get::<AuthUser>().map(|a| a.user_id)) Ok(parts.extensions.get::<AuthUser>().map(|a| a.user_id))
} }
@@ -59,7 +87,7 @@ impl SecretsService {
let parts = ctx let parts = ctx
.extensions .extensions
.get::<http::request::Parts>() .get::<http::request::Parts>()
.ok_or_else(|| rmcp::ErrorData::internal_error("Missing HTTP parts", None))?; .ok_or_else(mcp_err_missing_http_parts)?;
parts parts
.extensions .extensions
.get::<AuthUser>() .get::<AuthUser>()
@@ -73,7 +101,7 @@ impl SecretsService {
let parts = ctx let parts = ctx
.extensions .extensions
.get::<http::request::Parts>() .get::<http::request::Parts>()
.ok_or_else(|| rmcp::ErrorData::internal_error("Missing HTTP parts", None))?; .ok_or_else(mcp_err_missing_http_parts)?;
let hex_str = parts let hex_str = parts
.headers .headers
.get("x-encryption-key") .get("x-encryption-key")
@@ -88,8 +116,29 @@ impl SecretsService {
.map_err(|_| { .map_err(|_| {
rmcp::ErrorData::invalid_request("Invalid X-Encryption-Key header value", None) rmcp::ErrorData::invalid_request("Invalid X-Encryption-Key header value", None)
})?; })?;
let trimmed = hex_str.trim();
if trimmed.len() != 64 {
tracing::warn!(
got_len = trimmed.len(),
"X-Encryption-Key has wrong length after trim"
);
return Err(rmcp::ErrorData::invalid_request(
format!(
"X-Encryption-Key must be exactly 64 hex characters (32-byte key), got {} characters.",
trimmed.len()
),
None,
));
}
if !trimmed.chars().all(|c| c.is_ascii_hexdigit()) {
tracing::warn!("X-Encryption-Key contains non-hexadecimal characters");
return Err(rmcp::ErrorData::invalid_request(
"X-Encryption-Key contains non-hexadecimal characters.",
None,
));
}
secrets_core::crypto::extract_key_from_hex(hex_str) secrets_core::crypto::extract_key_from_hex(hex_str)
.map_err(|e| rmcp::ErrorData::invalid_request(e.to_string(), None)) .map_err(mcp_err_invalid_encryption_key_logged)
} }
/// Require both user_id and encryption key. /// Require both user_id and encryption key.
@@ -249,15 +298,30 @@ struct EnvMapInput {
#[tool_router] #[tool_router]
impl SecretsService { impl SecretsService {
#[tool( #[tool(
description = "Search entries in the secrets store. Returns entries with metadata and \ description = "Search entries in the secrets store. Requires Bearer API key. Returns \
secret field names (not values). Use secrets_get to decrypt secret values." entries with metadata and secret field names (not values). Use secrets_get to decrypt secret values.",
annotations(
title = "Search Secrets",
read_only_hint = true,
idempotent_hint = true
)
)] )]
async fn secrets_search( async fn secrets_search(
&self, &self,
Parameters(input): Parameters<SearchInput>, Parameters(input): Parameters<SearchInput>,
ctx: RequestContext<RoleServer>, ctx: RequestContext<RoleServer>,
) -> Result<CallToolResult, rmcp::ErrorData> { ) -> Result<CallToolResult, rmcp::ErrorData> {
let user_id = Self::user_id_from_ctx(&ctx)?; let t = Instant::now();
let user_id = Self::require_user_id(&ctx)?;
tracing::info!(
tool = "secrets_search",
?user_id,
namespace = input.namespace.as_deref(),
kind = input.kind.as_deref(),
name = input.name.as_deref(),
query = input.query.as_deref(),
"tool call start",
);
let tags = input.tags.unwrap_or_default(); let tags = input.tags.unwrap_or_default();
let result = svc_search( let result = svc_search(
&self.pool, &self.pool,
@@ -270,11 +334,11 @@ impl SecretsService {
sort: input.sort.as_deref().unwrap_or("name"), sort: input.sort.as_deref().unwrap_or("name"),
limit: input.limit.unwrap_or(20), limit: input.limit.unwrap_or(20),
offset: input.offset.unwrap_or(0), offset: input.offset.unwrap_or(0),
user_id, user_id: Some(user_id),
}, },
) )
.await .await
.map_err(|e| rmcp::ErrorData::internal_error(e.to_string(), None))?; .map_err(|e| mcp_err_internal_logged("secrets_search", Some(user_id), e))?;
let summary = input.summary.unwrap_or(false); let summary = input.summary.unwrap_or(false);
let entries: Vec<serde_json::Value> = result let entries: Vec<serde_json::Value> = result
@@ -312,6 +376,14 @@ impl SecretsService {
}) })
.collect(); .collect();
let count = entries.len();
tracing::info!(
tool = "secrets_search",
?user_id,
result_count = count,
elapsed_ms = t.elapsed().as_millis(),
"tool call ok",
);
let json = serde_json::to_string_pretty(&entries).unwrap_or_else(|_| "[]".to_string()); let json = serde_json::to_string_pretty(&entries).unwrap_or_else(|_| "[]".to_string());
Ok(CallToolResult::success(vec![Content::text(json)])) Ok(CallToolResult::success(vec![Content::text(json)]))
} }
@@ -319,14 +391,29 @@ impl SecretsService {
#[tool( #[tool(
description = "Get decrypted secret field values for an entry. Requires your \ description = "Get decrypted secret field values for an entry. Requires your \
encryption key via X-Encryption-Key header (64 hex chars, PBKDF2-derived). \ encryption key via X-Encryption-Key header (64 hex chars, PBKDF2-derived). \
Returns all fields, or a specific field if 'field' is provided." Returns all fields, or a specific field if 'field' is provided.",
annotations(
title = "Get Secret Values",
read_only_hint = true,
idempotent_hint = true
)
)] )]
async fn secrets_get( async fn secrets_get(
&self, &self,
Parameters(input): Parameters<GetSecretInput>, Parameters(input): Parameters<GetSecretInput>,
ctx: RequestContext<RoleServer>, ctx: RequestContext<RoleServer>,
) -> Result<CallToolResult, rmcp::ErrorData> { ) -> Result<CallToolResult, rmcp::ErrorData> {
let t = Instant::now();
let (user_id, user_key) = Self::require_user_and_key(&ctx)?; let (user_id, user_key) = Self::require_user_and_key(&ctx)?;
tracing::info!(
tool = "secrets_get",
?user_id,
namespace = %input.namespace,
kind = %input.kind,
name = %input.name,
field = input.field.as_deref(),
"tool call start",
);
if let Some(field_name) = &input.field { if let Some(field_name) = &input.field {
let value = get_secret_field( let value = get_secret_field(
@@ -339,8 +426,14 @@ impl SecretsService {
Some(user_id), Some(user_id),
) )
.await .await
.map_err(|e| rmcp::ErrorData::internal_error(e.to_string(), None))?; .map_err(|e| mcp_err_internal_logged("secrets_get", Some(user_id), e))?;
tracing::info!(
tool = "secrets_get",
?user_id,
elapsed_ms = t.elapsed().as_millis(),
"tool call ok",
);
let result = serde_json::json!({ field_name: value }); let result = serde_json::json!({ field_name: value });
let json = serde_json::to_string_pretty(&result).unwrap_or_default(); let json = serde_json::to_string_pretty(&result).unwrap_or_default();
Ok(CallToolResult::success(vec![Content::text(json)])) Ok(CallToolResult::success(vec![Content::text(json)]))
@@ -354,8 +447,16 @@ impl SecretsService {
Some(user_id), Some(user_id),
) )
.await .await
.map_err(|e| rmcp::ErrorData::internal_error(e.to_string(), None))?; .map_err(|e| mcp_err_internal_logged("secrets_get", Some(user_id), e))?;
let count = secrets.len();
tracing::info!(
tool = "secrets_get",
?user_id,
field_count = count,
elapsed_ms = t.elapsed().as_millis(),
"tool call ok",
);
let json = serde_json::to_string_pretty(&secrets).unwrap_or_default(); let json = serde_json::to_string_pretty(&secrets).unwrap_or_default();
Ok(CallToolResult::success(vec![Content::text(json)])) Ok(CallToolResult::success(vec![Content::text(json)]))
} }
@@ -364,14 +465,24 @@ impl SecretsService {
#[tool( #[tool(
description = "Add or upsert an entry with metadata and encrypted secret fields. \ description = "Add or upsert an entry with metadata and encrypted secret fields. \
Requires X-Encryption-Key header. \ Requires X-Encryption-Key header. \
Meta and secret values use 'key=value', 'key=@file', or 'key:=<json>' format." Meta and secret values use 'key=value', 'key=@file', or 'key:=<json>' format.",
annotations(title = "Add Secret Entry")
)] )]
async fn secrets_add( async fn secrets_add(
&self, &self,
Parameters(input): Parameters<AddInput>, Parameters(input): Parameters<AddInput>,
ctx: RequestContext<RoleServer>, ctx: RequestContext<RoleServer>,
) -> Result<CallToolResult, rmcp::ErrorData> { ) -> Result<CallToolResult, rmcp::ErrorData> {
let t = Instant::now();
let (user_id, user_key) = Self::require_user_and_key(&ctx)?; let (user_id, user_key) = Self::require_user_and_key(&ctx)?;
tracing::info!(
tool = "secrets_add",
?user_id,
namespace = %input.namespace,
kind = %input.kind,
name = %input.name,
"tool call start",
);
let tags = input.tags.unwrap_or_default(); let tags = input.tags.unwrap_or_default();
let meta = input.meta.unwrap_or_default(); let meta = input.meta.unwrap_or_default();
@@ -391,22 +502,41 @@ impl SecretsService {
&user_key, &user_key,
) )
.await .await
.map_err(|e| rmcp::ErrorData::internal_error(e.to_string(), None))?; .map_err(|e| mcp_err_internal_logged("secrets_add", Some(user_id), e))?;
tracing::info!(
tool = "secrets_add",
?user_id,
namespace = %input.namespace,
kind = %input.kind,
name = %input.name,
elapsed_ms = t.elapsed().as_millis(),
"tool call ok",
);
let json = serde_json::to_string_pretty(&result).unwrap_or_default(); let json = serde_json::to_string_pretty(&result).unwrap_or_default();
Ok(CallToolResult::success(vec![Content::text(json)])) Ok(CallToolResult::success(vec![Content::text(json)]))
} }
#[tool( #[tool(
description = "Incrementally update an existing entry. Requires X-Encryption-Key header. \ description = "Incrementally update an existing entry. Requires X-Encryption-Key header. \
Only the fields you specify are changed; everything else is preserved." Only the fields you specify are changed; everything else is preserved.",
annotations(title = "Update Secret Entry")
)] )]
async fn secrets_update( async fn secrets_update(
&self, &self,
Parameters(input): Parameters<UpdateInput>, Parameters(input): Parameters<UpdateInput>,
ctx: RequestContext<RoleServer>, ctx: RequestContext<RoleServer>,
) -> Result<CallToolResult, rmcp::ErrorData> { ) -> Result<CallToolResult, rmcp::ErrorData> {
let t = Instant::now();
let (user_id, user_key) = Self::require_user_and_key(&ctx)?; let (user_id, user_key) = Self::require_user_and_key(&ctx)?;
tracing::info!(
tool = "secrets_update",
?user_id,
namespace = %input.namespace,
kind = %input.kind,
name = %input.name,
"tool call start",
);
let add_tags = input.add_tags.unwrap_or_default(); let add_tags = input.add_tags.unwrap_or_default();
let remove_tags = input.remove_tags.unwrap_or_default(); let remove_tags = input.remove_tags.unwrap_or_default();
@@ -432,22 +562,42 @@ impl SecretsService {
&user_key, &user_key,
) )
.await .await
.map_err(|e| rmcp::ErrorData::internal_error(e.to_string(), None))?; .map_err(|e| mcp_err_internal_logged("secrets_update", Some(user_id), e))?;
tracing::info!(
tool = "secrets_update",
?user_id,
namespace = %input.namespace,
kind = %input.kind,
name = %input.name,
elapsed_ms = t.elapsed().as_millis(),
"tool call ok",
);
let json = serde_json::to_string_pretty(&result).unwrap_or_default(); let json = serde_json::to_string_pretty(&result).unwrap_or_default();
Ok(CallToolResult::success(vec![Content::text(json)])) Ok(CallToolResult::success(vec![Content::text(json)]))
} }
#[tool( #[tool(
description = "Delete one entry (specify namespace+kind+name) or bulk delete all \ description = "Delete one entry (specify namespace+kind+name) or bulk delete all \
entries matching namespace+kind. Use dry_run=true to preview." entries matching namespace+kind. Use dry_run=true to preview.",
annotations(title = "Delete Secret Entry", destructive_hint = true)
)] )]
async fn secrets_delete( async fn secrets_delete(
&self, &self,
Parameters(input): Parameters<DeleteInput>, Parameters(input): Parameters<DeleteInput>,
ctx: RequestContext<RoleServer>, ctx: RequestContext<RoleServer>,
) -> Result<CallToolResult, rmcp::ErrorData> { ) -> Result<CallToolResult, rmcp::ErrorData> {
let t = Instant::now();
let user_id = Self::user_id_from_ctx(&ctx)?; let user_id = Self::user_id_from_ctx(&ctx)?;
tracing::info!(
tool = "secrets_delete",
?user_id,
namespace = %input.namespace,
kind = input.kind.as_deref(),
name = input.name.as_deref(),
dry_run = input.dry_run.unwrap_or(false),
"tool call start",
);
let result = svc_delete( let result = svc_delete(
&self.pool, &self.pool,
@@ -460,46 +610,86 @@ impl SecretsService {
}, },
) )
.await .await
.map_err(|e| rmcp::ErrorData::internal_error(e.to_string(), None))?; .map_err(|e| mcp_err_internal_logged("secrets_delete", user_id, e))?;
tracing::info!(
tool = "secrets_delete",
?user_id,
namespace = %input.namespace,
elapsed_ms = t.elapsed().as_millis(),
"tool call ok",
);
let json = serde_json::to_string_pretty(&result).unwrap_or_default(); let json = serde_json::to_string_pretty(&result).unwrap_or_default();
Ok(CallToolResult::success(vec![Content::text(json)])) Ok(CallToolResult::success(vec![Content::text(json)]))
} }
#[tool( #[tool(
description = "View change history for an entry. Returns a list of versions with \ description = "View change history for an entry. Returns a list of versions with \
actions and timestamps." actions and timestamps.",
annotations(
title = "View Secret History",
read_only_hint = true,
idempotent_hint = true
)
)] )]
async fn secrets_history( async fn secrets_history(
&self, &self,
Parameters(input): Parameters<HistoryInput>, Parameters(input): Parameters<HistoryInput>,
_ctx: RequestContext<RoleServer>, ctx: RequestContext<RoleServer>,
) -> Result<CallToolResult, rmcp::ErrorData> { ) -> Result<CallToolResult, rmcp::ErrorData> {
let t = Instant::now();
let user_id = Self::user_id_from_ctx(&ctx)?;
tracing::info!(
tool = "secrets_history",
?user_id,
namespace = %input.namespace,
kind = %input.kind,
name = %input.name,
"tool call start",
);
let result = svc_history( let result = svc_history(
&self.pool, &self.pool,
&input.namespace, &input.namespace,
&input.kind, &input.kind,
&input.name, &input.name,
input.limit.unwrap_or(20), input.limit.unwrap_or(20),
None, user_id,
) )
.await .await
.map_err(|e| rmcp::ErrorData::internal_error(e.to_string(), None))?; .map_err(|e| mcp_err_internal_logged("secrets_history", user_id, e))?;
tracing::info!(
tool = "secrets_history",
?user_id,
elapsed_ms = t.elapsed().as_millis(),
"tool call ok",
);
let json = serde_json::to_string_pretty(&result).unwrap_or_default(); let json = serde_json::to_string_pretty(&result).unwrap_or_default();
Ok(CallToolResult::success(vec![Content::text(json)])) Ok(CallToolResult::success(vec![Content::text(json)]))
} }
#[tool( #[tool(
description = "Rollback an entry to a previous version. Requires X-Encryption-Key header. \ description = "Rollback an entry to a previous version. Requires X-Encryption-Key header. \
Omit to_version to restore the most recent snapshot." Omit to_version to restore the most recent snapshot.",
annotations(title = "Rollback Secret Entry", destructive_hint = true)
)] )]
async fn secrets_rollback( async fn secrets_rollback(
&self, &self,
Parameters(input): Parameters<RollbackInput>, Parameters(input): Parameters<RollbackInput>,
ctx: RequestContext<RoleServer>, ctx: RequestContext<RoleServer>,
) -> Result<CallToolResult, rmcp::ErrorData> { ) -> Result<CallToolResult, rmcp::ErrorData> {
let t = Instant::now();
let (user_id, user_key) = Self::require_user_and_key(&ctx)?; let (user_id, user_key) = Self::require_user_and_key(&ctx)?;
tracing::info!(
tool = "secrets_rollback",
?user_id,
namespace = %input.namespace,
kind = %input.kind,
name = %input.name,
to_version = input.to_version,
"tool call start",
);
let result = svc_rollback( let result = svc_rollback(
&self.pool, &self.pool,
@@ -511,24 +701,44 @@ impl SecretsService {
Some(user_id), Some(user_id),
) )
.await .await
.map_err(|e| rmcp::ErrorData::internal_error(e.to_string(), None))?; .map_err(|e| mcp_err_internal_logged("secrets_rollback", Some(user_id), e))?;
tracing::info!(
tool = "secrets_rollback",
?user_id,
elapsed_ms = t.elapsed().as_millis(),
"tool call ok",
);
let json = serde_json::to_string_pretty(&result).unwrap_or_default(); let json = serde_json::to_string_pretty(&result).unwrap_or_default();
Ok(CallToolResult::success(vec![Content::text(json)])) Ok(CallToolResult::success(vec![Content::text(json)]))
} }
#[tool( #[tool(
description = "Export matching entries with decrypted secrets as JSON/TOML/YAML string. \ description = "Export matching entries with decrypted secrets as JSON/TOML/YAML string. \
Requires X-Encryption-Key header. Useful for backup or data migration." Requires X-Encryption-Key header. Useful for backup or data migration.",
annotations(
title = "Export Secrets",
read_only_hint = true,
idempotent_hint = true
)
)] )]
async fn secrets_export( async fn secrets_export(
&self, &self,
Parameters(input): Parameters<ExportInput>, Parameters(input): Parameters<ExportInput>,
ctx: RequestContext<RoleServer>, ctx: RequestContext<RoleServer>,
) -> Result<CallToolResult, rmcp::ErrorData> { ) -> Result<CallToolResult, rmcp::ErrorData> {
let t = Instant::now();
let (user_id, user_key) = Self::require_user_and_key(&ctx)?; let (user_id, user_key) = Self::require_user_and_key(&ctx)?;
let tags = input.tags.unwrap_or_default(); let tags = input.tags.unwrap_or_default();
let format = input.format.as_deref().unwrap_or("json"); let format = input.format.as_deref().unwrap_or("json");
tracing::info!(
tool = "secrets_export",
?user_id,
namespace = input.namespace.as_deref(),
kind = input.kind.as_deref(),
format,
"tool call start",
);
let data = svc_export( let data = svc_export(
&self.pool, &self.pool,
@@ -544,29 +754,57 @@ impl SecretsService {
Some(&user_key), Some(&user_key),
) )
.await .await
.map_err(|e| rmcp::ErrorData::internal_error(e.to_string(), None))?; .map_err(|e| mcp_err_internal_logged("secrets_export", Some(user_id), e))?;
let serialized = format let fmt = format.parse::<ExportFormat>().map_err(|e| {
.parse::<secrets_core::models::ExportFormat>() tracing::warn!(
.and_then(|fmt| fmt.serialize(&data)) tool = "secrets_export",
.map_err(|e| rmcp::ErrorData::internal_error(e.to_string(), None))?; ?user_id,
error = %e,
"invalid export format"
);
rmcp::ErrorData::invalid_request(
"Invalid export format. Use json, toml, or yaml.",
None,
)
})?;
let serialized = fmt
.serialize(&data)
.map_err(|e| mcp_err_internal_logged("secrets_export", Some(user_id), e))?;
tracing::info!(
tool = "secrets_export",
?user_id,
entry_count = data.entries.len(),
elapsed_ms = t.elapsed().as_millis(),
"tool call ok",
);
Ok(CallToolResult::success(vec![Content::text(serialized)])) Ok(CallToolResult::success(vec![Content::text(serialized)]))
} }
#[tool( #[tool(
description = "Build the environment variable map from entry secrets with decrypted \ description = "Build the environment variable map from entry secrets with decrypted \
plaintext values. Requires X-Encryption-Key header. \ plaintext values. Requires X-Encryption-Key header. \
Returns a JSON object of VAR_NAME -> plaintext_value ready for injection." Returns a JSON object of VAR_NAME -> plaintext_value ready for injection.",
annotations(title = "Build Env Map", read_only_hint = true, idempotent_hint = true)
)] )]
async fn secrets_env_map( async fn secrets_env_map(
&self, &self,
Parameters(input): Parameters<EnvMapInput>, Parameters(input): Parameters<EnvMapInput>,
ctx: RequestContext<RoleServer>, ctx: RequestContext<RoleServer>,
) -> Result<CallToolResult, rmcp::ErrorData> { ) -> Result<CallToolResult, rmcp::ErrorData> {
let t = Instant::now();
let (user_id, user_key) = Self::require_user_and_key(&ctx)?; let (user_id, user_key) = Self::require_user_and_key(&ctx)?;
let tags = input.tags.unwrap_or_default(); let tags = input.tags.unwrap_or_default();
let only_fields = input.only_fields.unwrap_or_default(); let only_fields = input.only_fields.unwrap_or_default();
tracing::info!(
tool = "secrets_env_map",
?user_id,
namespace = input.namespace.as_deref(),
kind = input.kind.as_deref(),
prefix = input.prefix.as_deref().unwrap_or(""),
"tool call start",
);
let env_map = secrets_core::service::env_map::build_env_map( let env_map = secrets_core::service::env_map::build_env_map(
&self.pool, &self.pool,
@@ -580,8 +818,16 @@ impl SecretsService {
Some(user_id), Some(user_id),
) )
.await .await
.map_err(|e| rmcp::ErrorData::internal_error(e.to_string(), None))?; .map_err(|e| mcp_err_internal_logged("secrets_env_map", Some(user_id), e))?;
let entry_count = env_map.len();
tracing::info!(
tool = "secrets_env_map",
?user_id,
entry_count,
elapsed_ms = t.elapsed().as_millis(),
"tool call ok",
);
let json = serde_json::to_string_pretty(&env_map).unwrap_or_default(); let json = serde_json::to_string_pretty(&env_map).unwrap_or_default();
Ok(CallToolResult::success(vec![Content::text(json)])) Ok(CallToolResult::success(vec![Content::text(json)]))
} }
@@ -593,13 +839,17 @@ impl SecretsService {
impl ServerHandler for SecretsService { impl ServerHandler for SecretsService {
fn get_info(&self) -> InitializeResult { fn get_info(&self) -> InitializeResult {
let mut info = InitializeResult::new(ServerCapabilities::builder().enable_tools().build()); let mut info = InitializeResult::new(ServerCapabilities::builder().enable_tools().build());
info.server_info = Implementation::new("secrets-mcp", env!("CARGO_PKG_VERSION")); info.server_info = Implementation::new("secrets-mcp", env!("CARGO_PKG_VERSION"))
info.protocol_version = ProtocolVersion::V_2025_03_26; .with_title("Secrets MCP")
.with_description(
"Secure cross-device secrets and configuration management with encrypted secret fields.",
);
info.protocol_version = ProtocolVersion::V_2025_06_18;
info.instructions = Some( info.instructions = Some(
"Manage cross-device secrets and configuration securely. \ "Manage cross-device secrets and configuration securely. \
Data is encrypted with your passphrase-derived key. \ Data is encrypted with your passphrase-derived key. \
Include your 64-char hex key in the X-Encryption-Key header for all read/write operations. \ Include your 64-char hex key in the X-Encryption-Key header for all read/write operations. \
Use secrets_search to discover entries (no key needed), \ Use secrets_search to discover entries (Bearer token required; encryption key not needed), \
secrets_get to decrypt secret values, \ secrets_get to decrypt secret values, \
and secrets_add/secrets_update to write encrypted secrets." and secrets_add/secrets_update to write encrypted secrets."
.to_string(), .to_string(),

View File

@@ -1,4 +1,5 @@
use askama::Template; use askama::Template;
use chrono::SecondsFormat;
use std::net::SocketAddr; use std::net::SocketAddr;
use axum::{ use axum::{
@@ -17,6 +18,7 @@ use secrets_core::audit::log_login;
use secrets_core::crypto::hex; use secrets_core::crypto::hex;
use secrets_core::service::{ use secrets_core::service::{
api_key::{ensure_api_key, regenerate_api_key}, api_key::{ensure_api_key, regenerate_api_key},
audit_log::list_for_user,
user::{ user::{
OAuthProfile, bind_oauth_account, find_or_create_user, get_user_by_id, OAuthProfile, bind_oauth_account, find_or_create_user, get_user_by_id,
unbind_oauth_account, update_user_key_setup, unbind_oauth_account, update_user_key_setup,
@@ -37,6 +39,15 @@ const SESSION_LOGIN_PROVIDER: &str = "login_provider";
#[template(path = "login.html")] #[template(path = "login.html")]
struct LoginTemplate { struct LoginTemplate {
has_google: bool, has_google: bool,
base_url: String,
version: &'static str,
}
#[derive(Template)]
#[template(path = "home.html")]
struct HomeTemplate {
is_logged_in: bool,
base_url: String,
version: &'static str, version: &'static str,
} }
@@ -50,6 +61,23 @@ struct DashboardTemplate {
version: &'static str, version: &'static str,
} }
#[derive(Template)]
#[template(path = "audit.html")]
struct AuditPageTemplate {
user_name: String,
user_email: String,
entries: Vec<AuditEntryView>,
version: &'static str,
}
struct AuditEntryView {
/// RFC3339 UTC for `<time datetime>`; rendered as browser-local in audit.html.
created_at_iso: String,
action: String,
target: String,
detail: String,
}
// ── App state helpers ───────────────────────────────────────────────────────── // ── App state helpers ─────────────────────────────────────────────────────────
fn google_cfg(state: &AppState) -> Option<&OAuthConfig> { fn google_cfg(state: &AppState) -> Option<&OAuthConfig> {
@@ -57,12 +85,22 @@ fn google_cfg(state: &AppState) -> Option<&OAuthConfig> {
} }
async fn current_user_id(session: &Session) -> Option<Uuid> { async fn current_user_id(session: &Session) -> Option<Uuid> {
session match session.get::<String>(SESSION_USER_ID).await {
.get::<String>(SESSION_USER_ID) Ok(opt) => match opt {
.await Some(s) => match Uuid::parse_str(&s) {
.ok() Ok(id) => Some(id),
.flatten() Err(e) => {
.and_then(|s| Uuid::parse_str(&s).ok()) tracing::warn!(error = %e, user_id_str = %s, "invalid user_id UUID in session");
None
}
},
None => None,
},
Err(e) => {
tracing::warn!(error = %e, "failed to read user_id from session");
None
}
}
} }
fn request_client_ip(headers: &HeaderMap, connect_info: ConnectInfo<SocketAddr>) -> Option<String> { fn request_client_ip(headers: &HeaderMap, connect_info: ConnectInfo<SocketAddr>) -> Option<String> {
@@ -93,16 +131,25 @@ fn request_user_agent(headers: &HeaderMap) -> Option<String> {
pub fn web_router() -> Router<AppState> { pub fn web_router() -> Router<AppState> {
Router::new() Router::new()
.route("/robots.txt", get(robots_txt))
.route("/llms.txt", get(llms_txt))
.route("/ai.txt", get(ai_txt))
.route("/favicon.svg", get(favicon_svg)) .route("/favicon.svg", get(favicon_svg))
.route( .route(
"/favicon.ico", "/favicon.ico",
get(|| async { Redirect::permanent("/favicon.svg") }), get(|| async { Redirect::permanent("/favicon.svg") }),
) )
.route("/", get(login_page)) .route(
"/.well-known/oauth-protected-resource",
get(oauth_protected_resource_metadata),
)
.route("/", get(home_page))
.route("/login", get(login_page))
.route("/auth/google", get(auth_google)) .route("/auth/google", get(auth_google))
.route("/auth/google/callback", get(auth_google_callback)) .route("/auth/google/callback", get(auth_google_callback))
.route("/auth/logout", post(auth_logout)) .route("/auth/logout", post(auth_logout))
.route("/dashboard", get(dashboard)) .route("/dashboard", get(dashboard))
.route("/audit", get(audit_page))
.route("/account/bind/google", get(account_bind_google)) .route("/account/bind/google", get(account_bind_google))
.route( .route(
"/account/bind/google/callback", "/account/bind/google/callback",
@@ -115,6 +162,33 @@ pub fn web_router() -> Router<AppState> {
.route("/api/apikey/regenerate", post(api_apikey_regenerate)) .route("/api/apikey/regenerate", post(api_apikey_regenerate))
} }
fn text_asset_response(content: &'static str, content_type: &'static str) -> Response {
Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, content_type)
.header(header::CACHE_CONTROL, "public, max-age=86400")
.body(Body::from(content))
.expect("text asset response")
}
async fn robots_txt() -> Response {
text_asset_response(
include_str!("../static/robots.txt"),
"text/plain; charset=utf-8",
)
}
async fn llms_txt() -> Response {
text_asset_response(
include_str!("../static/llms.txt"),
"text/markdown; charset=utf-8",
)
}
async fn ai_txt() -> Response {
llms_txt().await
}
async fn favicon_svg() -> Response { async fn favicon_svg() -> Response {
Response::builder() Response::builder()
.status(StatusCode::OK) .status(StatusCode::OK)
@@ -124,6 +198,21 @@ async fn favicon_svg() -> Response {
.expect("favicon response") .expect("favicon response")
} }
// ── Home page (public) ───────────────────────────────────────────────────────
async fn home_page(
State(state): State<AppState>,
session: Session,
) -> Result<Response, StatusCode> {
let is_logged_in = current_user_id(&session).await.is_some();
let tmpl = HomeTemplate {
is_logged_in,
base_url: state.base_url.clone(),
version: env!("CARGO_PKG_VERSION"),
};
render_template(tmpl)
}
// ── Login page ──────────────────────────────────────────────────────────────── // ── Login page ────────────────────────────────────────────────────────────────
async fn login_page( async fn login_page(
@@ -136,6 +225,7 @@ async fn login_page(
let tmpl = LoginTemplate { let tmpl = LoginTemplate {
has_google: state.google_config.is_some(), has_google: state.google_config.is_some(),
base_url: state.base_url.clone(),
version: env!("CARGO_PKG_VERSION"), version: env!("CARGO_PKG_VERSION"),
}; };
render_template(tmpl) render_template(tmpl)
@@ -153,7 +243,10 @@ async fn auth_google(
session session
.insert(SESSION_OAUTH_STATE, &oauth_state) .insert(SESSION_OAUTH_STATE, &oauth_state)
.await .await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; .map_err(|e| {
tracing::error!(error = %e, "failed to insert oauth_state into session");
StatusCode::INTERNAL_SERVER_ERROR
})?;
let url = google_auth_url(config, &oauth_state); let url = google_auth_url(config, &oauth_state);
Ok(Redirect::to(&url).into_response()) Ok(Redirect::to(&url).into_response())
@@ -215,31 +308,33 @@ where
{ {
if let Some(err) = params.error { if let Some(err) = params.error {
tracing::warn!(provider, error = %err, "OAuth error"); tracing::warn!(provider, error = %err, "OAuth error");
return Ok(Redirect::to("/?error=oauth_error").into_response()); return Ok(Redirect::to("/login?error=oauth_error").into_response());
} }
let Some(code) = params.code else { let Some(code) = params.code else {
tracing::warn!(provider, "OAuth callback missing code"); tracing::warn!(provider, "OAuth callback missing code");
return Ok(Redirect::to("/?error=oauth_missing_code").into_response()); return Ok(Redirect::to("/login?error=oauth_missing_code").into_response());
}; };
let Some(returned_state) = params.state.as_deref() else { let Some(returned_state) = params.state.as_deref() else {
tracing::warn!(provider, "OAuth callback missing state"); tracing::warn!(provider, "OAuth callback missing state");
return Ok(Redirect::to("/?error=oauth_missing_state").into_response()); return Ok(Redirect::to("/login?error=oauth_missing_state").into_response());
}; };
let expected_state: Option<String> = session let expected_state: Option<String> = session.get(SESSION_OAUTH_STATE).await.map_err(|e| {
.get(SESSION_OAUTH_STATE) tracing::error!(provider, error = %e, "failed to read oauth_state from session");
.await StatusCode::INTERNAL_SERVER_ERROR
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; })?;
if expected_state.as_deref() != Some(returned_state) { if expected_state.as_deref() != Some(returned_state) {
tracing::warn!( tracing::warn!(
provider, provider,
expected_present = expected_state.is_some(), expected_present = expected_state.is_some(),
"OAuth state mismatch (empty session often means SameSite=Strict or server restart)" "OAuth state mismatch (empty session often means SameSite=Strict or server restart)"
); );
return Ok(Redirect::to("/?error=oauth_state").into_response()); return Ok(Redirect::to("/login?error=oauth_state").into_response());
}
if let Err(e) = session.remove::<String>(SESSION_OAUTH_STATE).await {
tracing::warn!(provider, error = %e, "failed to remove oauth_state from session");
} }
session.remove::<String>(SESSION_OAUTH_STATE).await.ok();
let config = match provider { let config = match provider {
"google" => state "google" => state
@@ -256,17 +351,25 @@ where
StatusCode::INTERNAL_SERVER_ERROR StatusCode::INTERNAL_SERVER_ERROR
})?; })?;
let bind_mode: bool = session let bind_mode: bool = match session.get::<bool>(SESSION_OAUTH_BIND_MODE).await {
.get(SESSION_OAUTH_BIND_MODE) Ok(v) => v.unwrap_or(false),
.await Err(e) => {
.unwrap_or(None) tracing::error!(
.unwrap_or(false); provider,
error = %e,
"failed to read oauth_bind_mode from session"
);
return Err(StatusCode::INTERNAL_SERVER_ERROR);
}
};
if bind_mode { if bind_mode {
let user_id = current_user_id(session) let user_id = current_user_id(session)
.await .await
.ok_or(StatusCode::UNAUTHORIZED)?; .ok_or(StatusCode::UNAUTHORIZED)?;
session.remove::<bool>(SESSION_OAUTH_BIND_MODE).await.ok(); if let Err(e) = session.remove::<bool>(SESSION_OAUTH_BIND_MODE).await {
tracing::warn!(provider, error = %e, "failed to remove oauth_bind_mode from session after bind");
}
let profile = OAuthProfile { let profile = OAuthProfile {
provider: user_info.provider, provider: user_info.provider,
@@ -301,19 +404,28 @@ where
StatusCode::INTERNAL_SERVER_ERROR StatusCode::INTERNAL_SERVER_ERROR
})?; })?;
// Ensure the user has an API key (auto-creates on first login).
if let Err(e) = ensure_api_key(&state.pool, user.id).await {
tracing::warn!(error = %e, "failed to ensure api key for user");
}
session session
.insert(SESSION_USER_ID, user.id.to_string()) .insert(SESSION_USER_ID, user.id.to_string())
.await .await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; .map_err(|e| {
tracing::error!(
error = %e,
user_id = %user.id,
"failed to insert user_id into session after OAuth"
);
StatusCode::INTERNAL_SERVER_ERROR
})?;
session session
.insert(SESSION_LOGIN_PROVIDER, &provider) .insert(SESSION_LOGIN_PROVIDER, &provider)
.await .await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; .map_err(|e| {
tracing::error!(
provider,
error = %e,
"failed to insert login_provider into session after OAuth"
);
StatusCode::INTERNAL_SERVER_ERROR
})?;
log_login( log_login(
&state.pool, &state.pool,
@@ -331,7 +443,9 @@ where
// ── Logout ──────────────────────────────────────────────────────────────────── // ── Logout ────────────────────────────────────────────────────────────────────
async fn auth_logout(session: Session) -> impl IntoResponse { async fn auth_logout(session: Session) -> impl IntoResponse {
session.flush().await.ok(); if let Err(e) = session.flush().await {
tracing::warn!(error = %e, "failed to flush session on logout");
}
Redirect::to("/") Redirect::to("/")
} }
@@ -342,15 +456,15 @@ async fn dashboard(
session: Session, session: Session,
) -> Result<Response, StatusCode> { ) -> Result<Response, StatusCode> {
let Some(user_id) = current_user_id(&session).await else { let Some(user_id) = current_user_id(&session).await else {
return Ok(Redirect::to("/").into_response()); return Ok(Redirect::to("/login").into_response());
}; };
let user = match get_user_by_id(&state.pool, user_id) let user = match get_user_by_id(&state.pool, user_id).await.map_err(|e| {
.await tracing::error!(error = %e, %user_id, "failed to load user for dashboard");
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? StatusCode::INTERNAL_SERVER_ERROR
{ })? {
Some(u) => u, Some(u) => u,
None => return Ok(Redirect::to("/").into_response()), None => return Ok(Redirect::to("/login").into_response()),
}; };
let tmpl = DashboardTemplate { let tmpl = DashboardTemplate {
@@ -364,6 +478,49 @@ async fn dashboard(
render_template(tmpl) render_template(tmpl)
} }
async fn audit_page(
State(state): State<AppState>,
session: Session,
) -> Result<Response, StatusCode> {
let Some(user_id) = current_user_id(&session).await else {
return Ok(Redirect::to("/login").into_response());
};
let user = match get_user_by_id(&state.pool, user_id).await.map_err(|e| {
tracing::error!(error = %e, %user_id, "failed to load user for audit page");
StatusCode::INTERNAL_SERVER_ERROR
})? {
Some(u) => u,
None => return Ok(Redirect::to("/login").into_response()),
};
let rows = list_for_user(&state.pool, user_id, 100)
.await
.map_err(|e| {
tracing::error!(error = %e, "failed to load audit log for user");
StatusCode::INTERNAL_SERVER_ERROR
})?;
let entries = rows
.into_iter()
.map(|row| AuditEntryView {
created_at_iso: row.created_at.to_rfc3339_opts(SecondsFormat::Secs, true),
action: row.action,
target: format_audit_target(&row.namespace, &row.kind, &row.name),
detail: serde_json::to_string_pretty(&row.detail).unwrap_or_else(|_| "{}".to_string()),
})
.collect();
let tmpl = AuditPageTemplate {
user_name: user.name.clone(),
user_email: user.email.clone().unwrap_or_default(),
entries,
version: env!("CARGO_PKG_VERSION"),
};
render_template(tmpl)
}
// ── Account bind/unbind ─────────────────────────────────────────────────────── // ── Account bind/unbind ───────────────────────────────────────────────────────
async fn account_bind_google( async fn account_bind_google(
@@ -377,7 +534,10 @@ async fn account_bind_google(
session session
.insert(SESSION_OAUTH_BIND_MODE, true) .insert(SESSION_OAUTH_BIND_MODE, true)
.await .await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; .map_err(|e| {
tracing::error!(error = %e, "failed to insert oauth_bind_mode into session");
StatusCode::INTERNAL_SERVER_ERROR
})?;
let redirect_uri = format!("{}/account/bind/google/callback", state.base_url); let redirect_uri = format!("{}/account/bind/google/callback", state.base_url);
let mut cfg = state let mut cfg = state
@@ -386,7 +546,13 @@ async fn account_bind_google(
.ok_or(StatusCode::SERVICE_UNAVAILABLE)?; .ok_or(StatusCode::SERVICE_UNAVAILABLE)?;
cfg.redirect_uri = redirect_uri; cfg.redirect_uri = redirect_uri;
let st = random_state(); let st = random_state();
session.insert(SESSION_OAUTH_STATE, &st).await.ok(); if let Err(e) = session.insert(SESSION_OAUTH_STATE, &st).await {
tracing::error!(error = %e, "failed to insert oauth_state for account bind flow");
if let Err(rm) = session.remove::<bool>(SESSION_OAUTH_BIND_MODE).await {
tracing::warn!(error = %rm, "failed to roll back oauth_bind_mode after oauth_state insert failure");
}
return Err(StatusCode::INTERNAL_SERVER_ERROR);
}
Ok(Redirect::to(&google_auth_url(&cfg, &st)).into_response()) Ok(Redirect::to(&google_auth_url(&cfg, &st)).into_response())
} }
@@ -430,7 +596,10 @@ async fn account_unbind(
let current_login_provider = session let current_login_provider = session
.get::<String>(SESSION_LOGIN_PROVIDER) .get::<String>(SESSION_LOGIN_PROVIDER)
.await .await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; .map_err(|e| {
tracing::error!(error = %e, "failed to read login_provider from session");
StatusCode::INTERNAL_SERVER_ERROR
})?;
unbind_oauth_account( unbind_oauth_account(
&state.pool, &state.pool,
@@ -470,7 +639,10 @@ async fn api_key_salt(
let user = get_user_by_id(&state.pool, user_id) let user = get_user_by_id(&state.pool, user_id)
.await .await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? .map_err(|e| {
tracing::error!(error = %e, %user_id, "failed to load user for key-salt API");
StatusCode::INTERNAL_SERVER_ERROR
})?
.ok_or(StatusCode::UNAUTHORIZED)?; .ok_or(StatusCode::UNAUTHORIZED)?;
if user.key_salt.is_none() { if user.key_salt.is_none() {
@@ -514,10 +686,17 @@ async fn api_key_setup(
.await .await
.ok_or(StatusCode::UNAUTHORIZED)?; .ok_or(StatusCode::UNAUTHORIZED)?;
let salt = hex::decode_hex(&body.salt).map_err(|_| StatusCode::BAD_REQUEST)?; let salt = hex::decode_hex(&body.salt).map_err(|e| {
let key_check = hex::decode_hex(&body.key_check).map_err(|_| StatusCode::BAD_REQUEST)?; tracing::warn!(error = %e, "invalid hex in key-setup salt");
StatusCode::BAD_REQUEST
})?;
let key_check = hex::decode_hex(&body.key_check).map_err(|e| {
tracing::warn!(error = %e, "invalid hex in key-setup key_check");
StatusCode::BAD_REQUEST
})?;
if salt.len() != 32 { if salt.len() != 32 {
tracing::warn!(salt_len = salt.len(), "key-setup salt must be 32 bytes");
return Err(StatusCode::BAD_REQUEST); return Err(StatusCode::BAD_REQUEST);
} }
@@ -546,9 +725,10 @@ async fn api_apikey_get(
.await .await
.ok_or(StatusCode::UNAUTHORIZED)?; .ok_or(StatusCode::UNAUTHORIZED)?;
let api_key = ensure_api_key(&state.pool, user_id) let api_key = ensure_api_key(&state.pool, user_id).await.map_err(|e| {
.await tracing::error!(error = %e, %user_id, "ensure_api_key failed");
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; StatusCode::INTERNAL_SERVER_ERROR
})?;
Ok(Json(ApiKeyResponse { api_key })) Ok(Json(ApiKeyResponse { api_key }))
} }
@@ -563,11 +743,36 @@ async fn api_apikey_regenerate(
let api_key = regenerate_api_key(&state.pool, user_id) let api_key = regenerate_api_key(&state.pool, user_id)
.await .await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; .map_err(|e| {
tracing::error!(error = %e, %user_id, "regenerate_api_key failed");
StatusCode::INTERNAL_SERVER_ERROR
})?;
Ok(Json(ApiKeyResponse { api_key })) Ok(Json(ApiKeyResponse { api_key }))
} }
// ── OAuth / Well-known ────────────────────────────────────────────────────────
/// RFC 9728 — OAuth 2.0 Protected Resource Metadata.
///
/// Advertises that this server accepts Bearer tokens in the `Authorization`
/// header. We deliberately omit `authorization_servers` because this service
/// issues its own API keys (no external OAuth AS is involved). MCP clients
/// that probe this endpoint will see the resource identifier and stop looking
/// for a delegated OAuth flow.
async fn oauth_protected_resource_metadata(State(state): State<AppState>) -> impl IntoResponse {
let body = serde_json::json!({
"resource": state.base_url,
"bearer_methods_supported": ["header"],
"resource_documentation": format!("{}/dashboard", state.base_url),
});
(
StatusCode::OK,
[(header::CONTENT_TYPE, "application/json")],
axum::Json(body),
)
}
// ── Helper ──────────────────────────────────────────────────────────────────── // ── Helper ────────────────────────────────────────────────────────────────────
fn render_template<T: Template>(tmpl: T) -> Result<Response, StatusCode> { fn render_template<T: Template>(tmpl: T) -> Result<Response, StatusCode> {
@@ -577,3 +782,12 @@ fn render_template<T: Template>(tmpl: T) -> Result<Response, StatusCode> {
})?; })?;
Ok(Html(html).into_response()) Ok(Html(html).into_response())
} }
fn format_audit_target(namespace: &str, kind: &str, name: &str) -> String {
// Auth events reuse kind/name as a provider-scoped target, not an entry identity.
if namespace == "auth" {
format!("{}/{}", kind, name)
} else {
format!("[{}/{}] {}", namespace, kind, name)
}
}

View File

@@ -0,0 +1,28 @@
# Secrets MCP
> 给 AI 与自动化工具的简要说明。本站是 **secrets-mcp**Streamable HTTP **MCP**Model Context Protocol与 **Web 控制台** 的组合,用于在多租户场景下存储条目元数据与加密后的秘密字段;持久化在 PostgreSQL。用户通过 OAuth如已配置登录 WebMCP 调用使用 API Key 与加密相关请求头。
## 公开页面
- **`/`**:公开首页,说明安全架构(客户端密钥派生、密文存储、多租户与审计等),无需登录。
## 不应抓取或索引的内容
- **`/mcp`**MCP 流式 HTTP 端点JSON-RPC 等),**不是** HTML 文档,也不适合作为公开知识库来源。
- **`/api/*`**:会话或 API Key 相关的 HTTP API。
- **`/login`**:登录入口页(`noindex` / robots 通常 disallow
- **`/dashboard`、`/audit`、`/auth/*`、`/account/*`**:需浏览器会话,属于用户私有界面与 OAuth 流程。
## 给 AI 助手的实用提示
- 向用户说明连接方式时MCP 基址为 `{BASE_URL}/mcp``BASE_URL` 由部署方设置),通常需要 `Authorization: Bearer <api_key>`;读写加密秘密时还需按部署文档传递 `X-Encryption-Key` 等头(与客户端模式有关)。
- **不要编造**本实例的数据库 URL、OAuth 密钥、回调地址或任何凭据;一律以用户环境变量与运维文档为准。
- Web 端在浏览器内用密码短语派生密钥完成端到端加密MCP 路径下服务端可能在请求周期内临时使用客户端提供的密钥处理密文(架构细节见项目 README「加密架构」
## 延伸阅读
- 源码仓库:<https://gitea.refining.dev/refining/secrets>`README.md`、`AGENTS.md` 含环境变量、表结构与运维约定)。
## 关于本文件
- 遵循常见的 **`/llms.txt`** 约定,便于人类与 LLM 快速了解站点性质与抓取边界;同文可在 **`/ai.txt`** 获取。

View File

@@ -0,0 +1,31 @@
# Secrets MCP — robots.txt
# 本站为需登录的私密控制台与 MCP API以下路径请勿抓取以免浪费配额并避免误索引敏感端点。
# This host serves an authenticated dashboard and machine APIs; please skip crawling the paths below.
User-agent: *
Disallow: /mcp
Disallow: /api/
Disallow: /dashboard
Disallow: /audit
Disallow: /auth/
Disallow: /login
Disallow: /account/
# 首页 `/` 为公开安全说明页,允许抓取。
# 面向 AI / LLM 的机器可读站点说明Markdown/llms.txt
# Human & AI-readable site summary: /llms.txt (also /ai.txt)
User-agent: GPTBot
User-agent: Google-Extended
User-agent: anthropic-ai
User-agent: Claude-Web
User-agent: PerplexityBot
User-agent: Bytespider
Disallow: /mcp
Disallow: /api/
Disallow: /dashboard
Disallow: /audit
Disallow: /auth/
Disallow: /login
Disallow: /account/

View File

@@ -0,0 +1,154 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="icon" href="/favicon.svg?v={{ version }}" type="image/svg+xml">
<title>Secrets — Audit</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;600&family=Inter:wght@400;500;600&display=swap');
:root {
--bg: #0d1117; --surface: #161b22; --surface2: #21262d;
--border: #30363d; --text: #e6edf3; --text-muted: #8b949e;
--accent: #58a6ff; --accent-hover: #79b8ff;
}
body { background: var(--bg); color: var(--text); font-family: 'Inter', sans-serif; min-height: 100vh; }
.layout { display: flex; min-height: 100vh; }
.sidebar {
width: 220px; flex-shrink: 0; background: var(--surface); border-right: 1px solid var(--border);
padding: 24px 16px; display: flex; flex-direction: column; gap: 20px;
}
.sidebar-logo { font-family: 'JetBrains Mono', monospace; font-size: 16px; font-weight: 600;
color: var(--text); text-decoration: none; padding: 0 10px; }
.sidebar-logo span { color: var(--accent); }
.sidebar-menu { display: flex; flex-direction: column; gap: 6px; }
.sidebar-link {
padding: 10px 12px; border-radius: 8px; color: var(--text-muted); text-decoration: none;
border: 1px solid transparent; font-size: 13px; font-weight: 500;
}
.sidebar-link:hover { background: var(--surface2); color: var(--text); }
.sidebar-link.active {
background: rgba(88,166,255,0.12); color: var(--text); border-color: rgba(88,166,255,0.35);
}
.content-shell { flex: 1; min-width: 0; display: flex; flex-direction: column; }
.topbar {
background: var(--surface); border-bottom: 1px solid var(--border); padding: 0 24px;
display: flex; align-items: center; gap: 12px; min-height: 52px;
}
.topbar-spacer { flex: 1; }
.nav-user { font-size: 13px; color: var(--text-muted); }
.btn-sign-out {
padding: 5px 12px; border-radius: 6px; border: 1px solid var(--border);
background: none; color: var(--text); font-size: 12px; text-decoration: none; cursor: pointer;
}
.btn-sign-out:hover { background: var(--surface2); }
.main { padding: 32px 24px 40px; flex: 1; }
.card { background: var(--surface); border: 1px solid var(--border); border-radius: 12px;
padding: 24px; width: 100%; max-width: 1180px; margin: 0 auto; }
.card-title { font-size: 20px; font-weight: 600; margin-bottom: 8px; }
.card-subtitle { color: var(--text-muted); font-size: 13px; margin-bottom: 20px; }
.empty { color: var(--text-muted); font-size: 14px; padding: 20px 0; }
table { width: 100%; border-collapse: collapse; }
th, td { text-align: left; vertical-align: top; padding: 12px 10px; border-top: 1px solid var(--border); }
th { color: var(--text-muted); font-size: 12px; font-weight: 600; }
td { font-size: 13px; }
.mono { font-family: 'JetBrains Mono', monospace; }
.detail {
background: var(--bg); border: 1px solid var(--border); border-radius: 8px;
padding: 10px; white-space: pre-wrap; word-break: break-word; font-size: 12px;
max-width: 460px;
}
@media (max-width: 900px) {
.layout { flex-direction: column; }
.sidebar {
width: 100%; border-right: none; border-bottom: 1px solid var(--border);
padding: 16px; gap: 14px;
}
.sidebar-menu { flex-direction: row; }
.sidebar-link { flex: 1; text-align: center; }
.main { padding: 20px 12px 28px; }
.card { padding: 16px; }
.topbar { padding: 12px 16px; flex-wrap: wrap; }
table, thead, tbody, th, td, tr { display: block; }
thead { display: none; }
tr { border-top: 1px solid var(--border); padding: 12px 0; }
td { border-top: none; padding: 6px 0; }
td::before {
display: block; color: var(--text-muted); font-size: 11px;
margin-bottom: 4px; text-transform: uppercase;
}
td.col-time::before { content: "Time"; }
td.col-action::before { content: "Action"; }
td.col-target::before { content: "Target"; }
td.col-detail::before { content: "Detail"; }
.detail { max-width: none; }
}
</style>
</head>
<body>
<div class="layout">
<aside class="sidebar">
<a href="/dashboard" class="sidebar-logo"><span>secrets</span></a>
<nav class="sidebar-menu">
<a href="/dashboard" class="sidebar-link">MCP</a>
<a href="/audit" class="sidebar-link active">审计</a>
</nav>
</aside>
<div class="content-shell">
<div class="topbar">
<span class="topbar-spacer"></span>
<span class="nav-user">{{ user_name }}{% if !user_email.is_empty() %} · {{ user_email }}{% endif %}</span>
<form action="/auth/logout" method="post" style="display:inline">
<button type="submit" class="btn-sign-out">退出</button>
</form>
</div>
<main class="main">
<section class="card">
<div class="card-title">我的审计</div>
<div class="card-subtitle">展示最近 100 条与当前用户相关的新审计记录。时间为浏览器本地时区。</div>
{% if entries.is_empty() %}
<div class="empty">暂无审计记录。</div>
{% else %}
<table>
<thead>
<tr>
<th>时间</th>
<th>动作</th>
<th>目标</th>
<th>详情</th>
</tr>
</thead>
<tbody>
{% for entry in entries %}
<tr>
<td class="col-time mono"><time class="audit-local-time" datetime="{{ entry.created_at_iso }}">{{ entry.created_at_iso }}</time></td>
<td class="col-action mono">{{ entry.action }}</td>
<td class="col-target mono">{{ entry.target }}</td>
<td class="col-detail"><pre class="detail">{{ entry.detail }}</pre></td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
</section>
</main>
</div>
</div>
<script>
(function () {
document.querySelectorAll('time.audit-local-time[datetime]').forEach(function (el) {
var raw = el.getAttribute('datetime');
var d = raw ? new Date(raw) : null;
if (d && !isNaN(d.getTime())) {
el.textContent = d.toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'medium' });
el.title = raw + ' (UTC)';
}
});
})();
</script>
</body>
</html>

View File

@@ -16,13 +16,29 @@
} }
body { background: var(--bg); color: var(--text); font-family: 'Inter', sans-serif; min-height: 100vh; } body { background: var(--bg); color: var(--text); font-family: 'Inter', sans-serif; min-height: 100vh; }
/* Nav */ .layout { display: flex; min-height: 100vh; }
.nav { background: var(--surface); border-bottom: 1px solid var(--border); .sidebar {
padding: 0 24px; display: flex; align-items: center; gap: 12px; height: 52px; } width: 220px; flex-shrink: 0; background: var(--surface); border-right: 1px solid var(--border);
.nav-logo { font-family: 'JetBrains Mono', monospace; font-size: 15px; font-weight: 600; padding: 24px 16px; display: flex; flex-direction: column; gap: 20px;
color: var(--text); text-decoration: none; } }
.nav-logo span { color: var(--accent); } .sidebar-logo { font-family: 'JetBrains Mono', monospace; font-size: 16px; font-weight: 600;
.nav-spacer { flex: 1; } color: var(--text); text-decoration: none; padding: 0 10px; }
.sidebar-logo span { color: var(--accent); }
.sidebar-menu { display: flex; flex-direction: column; gap: 6px; }
.sidebar-link {
padding: 10px 12px; border-radius: 8px; color: var(--text-muted); text-decoration: none;
border: 1px solid transparent; font-size: 13px; font-weight: 500;
}
.sidebar-link:hover { background: var(--surface2); color: var(--text); }
.sidebar-link.active {
background: rgba(88,166,255,0.12); color: var(--text); border-color: rgba(88,166,255,0.35);
}
.content-shell { flex: 1; min-width: 0; display: flex; flex-direction: column; }
.topbar {
background: var(--surface); border-bottom: 1px solid var(--border); padding: 0 24px;
display: flex; align-items: center; gap: 12px; min-height: 52px;
}
.topbar-spacer { flex: 1; }
.nav-user { font-size: 13px; color: var(--text-muted); } .nav-user { font-size: 13px; color: var(--text-muted); }
.lang-bar { display: flex; gap: 2px; background: var(--surface2); border-radius: 6px; padding: 2px; } .lang-bar { display: flex; gap: 2px; background: var(--surface2); border-radius: 6px; padding: 2px; }
.lang-btn { padding: 3px 9px; border: none; background: none; color: var(--text-muted); .lang-btn { padding: 3px 9px; border: none; background: none; color: var(--text-muted);
@@ -32,11 +48,19 @@
background: none; color: var(--text); font-size: 12px; cursor: pointer; } background: none; color: var(--text); font-size: 12px; cursor: pointer; }
.btn-sign-out:hover { background: var(--surface2); } .btn-sign-out:hover { background: var(--surface2); }
/* Main: column so footer can sit at bottom of viewport when content is short */ /* Main content column */
.main { display: flex; flex-direction: column; align-items: center; .main { display: flex; flex-direction: column; align-items: center;
padding: 48px 24px 24px; min-height: calc(100vh - 52px); } padding: 24px 20px 8px; min-height: 0; }
.app-footer {
margin-top: auto;
text-align: center;
padding: 4px 20px 12px;
font-size: 12px;
color: #9da7b3;
font-family: 'JetBrains Mono', monospace;
}
.card { background: var(--surface); border: 1px solid var(--border); border-radius: 12px; .card { background: var(--surface); border: 1px solid var(--border); border-radius: 12px;
padding: 32px; width: 100%; max-width: 980px; } padding: 24px; width: 100%; max-width: 980px; }
.card-title { font-size: 18px; font-weight: 600; margin-bottom: 24px; } .card-title { font-size: 18px; font-weight: 600; margin-bottom: 24px; }
/* Form */ /* Form */
.field { margin-bottom: 12px; } .field { margin-bottom: 12px; }
@@ -123,28 +147,40 @@
background: none; color: var(--text); font-size: 13px; cursor: pointer; } background: none; color: var(--text); font-size: 13px; cursor: pointer; }
.btn-modal-cancel:hover { background: var(--surface2); } .btn-modal-cancel:hover { background: var(--surface2); }
@media (max-width: 720px) { @media (max-width: 900px) {
.config-tabs { grid-template-columns: 1fr; } .layout { flex-direction: column; }
.sidebar {
width: 100%; border-right: none; border-bottom: 1px solid var(--border);
padding: 16px; gap: 14px;
}
.sidebar-menu { flex-direction: row; }
.sidebar-link { flex: 1; text-align: center; }
} }
.app-footer { @media (max-width: 720px) {
margin-top: auto; .config-tabs { grid-template-columns: 1fr; }
width: 100%; .topbar { padding: 12px 16px; flex-wrap: wrap; }
max-width: 980px; .main { padding: 16px 12px 6px; }
flex-shrink: 0; .app-footer { padding: 4px 12px 10px; }
text-align: center; .card { padding: 18px; }
padding-top: 28px;
font-size: 12px;
color: #9da7b3;
font-family: 'JetBrains Mono', monospace;
} }
</style> </style>
</head> </head>
<body data-has-passphrase="{{ has_passphrase }}" data-base-url="{{ base_url }}"> <body data-has-passphrase="{{ has_passphrase }}" data-base-url="{{ base_url }}">
<nav class="nav"> <div class="layout">
<a href="/dashboard" class="nav-logo"><span>secrets</span></a> <aside class="sidebar">
<span class="nav-spacer"></span> <a href="/dashboard" class="sidebar-logo"><span>secrets</span></a>
<nav class="sidebar-menu">
<a href="/dashboard" class="sidebar-link active">MCP</a>
<a href="/audit" class="sidebar-link">审计</a>
</nav>
</aside>
<div class="content-shell">
<div class="topbar">
<span class="topbar-spacer"></span>
<span class="nav-user">{{ user_name }}{% if !user_email.is_empty() %} · {{ user_email }}{% endif %}</span> <span class="nav-user">{{ user_name }}{% if !user_email.is_empty() %} · {{ user_email }}{% endif %}</span>
<div class="lang-bar"> <div class="lang-bar">
<button class="lang-btn" onclick="setLang('zh-CN')"></button> <button class="lang-btn" onclick="setLang('zh-CN')"></button>
@@ -154,7 +190,7 @@
<form action="/auth/logout" method="post" style="display:inline"> <form action="/auth/logout" method="post" style="display:inline">
<button type="submit" class="btn-sign-out" data-i18n="signOut">退出</button> <button type="submit" class="btn-sign-out" data-i18n="signOut">退出</button>
</form> </form>
</nav> </div>
<div class="main"> <div class="main">
<div class="card"> <div class="card">
@@ -258,9 +294,11 @@
</div> </div>
</div> </div>
</div>
<footer class="app-footer">{{ version }}</footer> <footer class="app-footer">{{ version }}</footer>
</div> </div>
</div>
</div>
<!-- ── Change passphrase modal ──────────────────────────────────────────────── --> <!-- ── Change passphrase modal ──────────────────────────────────────────────── -->
<div class="modal-bd" id="change-modal"> <div class="modal-bd" id="change-modal">

View File

@@ -0,0 +1,269 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="description" content="Secrets MCP基于 Model Context Protocol 的密钥与配置管理。密码短语在浏览器本地 PBKDF2 派生,密文 AES-GCM 存储,完整审计与历史版本。">
<meta name="keywords" content="secrets management,MCP,Model Context Protocol,end-to-end encryption,AES-GCM,PBKDF2,API key,密钥管理">
<meta name="robots" content="index, follow">
<link rel="canonical" href="{{ base_url }}/">
<link rel="icon" href="/favicon.svg?v={{ version }}" type="image/svg+xml">
<title>Secrets MCP — 端到端加密的密钥管理</title>
<meta property="og:type" content="website">
<meta property="og:url" content="{{ base_url }}/">
<meta property="og:title" content="Secrets MCP — 端到端加密的密钥管理">
<meta property="og:description" content="密码短语客户端派生密文存储MCP API 与 Web 控制台,多租户与审计。">
<meta name="twitter:card" content="summary">
<meta name="twitter:title" content="Secrets MCP — 端到端加密的密钥管理">
<meta name="twitter:description" content="密码短语客户端派生密文存储MCP API 与 Web 控制台,多租户与审计。">
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@500;600&family=Inter:wght@400;500;600&display=swap');
:root {
--bg: #0d1117;
--surface: #161b22;
--surface2: #21262d;
--border: #30363d;
--text: #e6edf3;
--text-muted: #8b949e;
--accent: #58a6ff;
--accent-hover: #79b8ff;
}
html, body { height: 100%; overflow: hidden; }
@supports (height: 100dvh) {
html, body { height: 100dvh; }
}
body {
background: var(--bg);
color: var(--text);
font-family: 'Inter', sans-serif;
display: flex;
flex-direction: column;
}
.nav {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 24px;
border-bottom: 1px solid var(--border);
background: var(--surface);
}
.brand {
font-family: 'JetBrains Mono', monospace;
font-size: 15px;
font-weight: 600;
color: var(--text);
text-decoration: none;
}
.brand span { color: var(--accent); }
.nav-right { display: flex; align-items: center; gap: 14px; }
.lang-bar { display: flex; gap: 2px; background: rgba(255,255,255,0.04); border-radius: 6px; padding: 2px; }
.lang-btn {
padding: 4px 10px; border: none; background: none; color: var(--text-muted);
font-size: 12px; cursor: pointer; border-radius: 4px;
}
.lang-btn.active { background: var(--border); color: var(--text); }
.cta {
display: inline-flex; align-items: center; justify-content: center;
padding: 8px 18px; border-radius: 8px; font-size: 13px; font-weight: 600;
text-decoration: none; border: 1px solid var(--accent);
background: rgba(88, 166, 255, 0.12); color: var(--accent);
transition: background 0.15s, color 0.15s;
}
.cta:hover { background: var(--accent); color: var(--bg); }
.main {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 16px 24px 12px;
gap: 20px;
}
.hero { text-align: center; max-width: 720px; }
.hero h1 { font-size: clamp(20px, 4vw, 28px); font-weight: 600; margin-bottom: 8px; line-height: 1.25; }
.hero .tagline { color: var(--text-muted); font-size: clamp(13px, 2vw, 15px); line-height: 1.5; }
.grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 12px;
width: 100%;
max-width: 900px;
}
@media (max-width: 900px) {
.grid { grid-template-columns: repeat(2, 1fr); }
}
@media (max-width: 480px) {
.grid { grid-template-columns: 1fr; gap: 8px; }
.main { justify-content: flex-start; padding-top: 12px; }
}
.card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 10px;
padding: 14px 14px 12px;
min-height: 0;
}
.card-icon {
width: 32px; height: 32px; border-radius: 8px;
background: var(--surface2);
display: flex; align-items: center; justify-content: center;
margin-bottom: 10px; color: var(--accent);
}
.card-icon svg { width: 18px; height: 18px; }
.card h2 { font-size: 13px; font-weight: 600; margin-bottom: 6px; line-height: 1.3; }
.card p { font-size: 12px; color: var(--text-muted); line-height: 1.45; }
.foot {
flex-shrink: 0;
text-align: center;
padding: 8px 16px 12px;
font-size: 11px;
color: var(--text-muted);
border-top: 1px solid var(--border);
background: var(--surface);
}
.foot a { color: var(--accent); text-decoration: none; }
.foot a:hover { text-decoration: underline; }
</style>
</head>
<body>
<header class="nav">
<a class="brand" href="/">secrets<span>-mcp</span></a>
<div class="nav-right">
<div class="lang-bar">
<button type="button" class="lang-btn" onclick="setLang('zh-CN')"></button>
<button type="button" class="lang-btn" onclick="setLang('zh-TW')"></button>
<button type="button" class="lang-btn" onclick="setLang('en')">EN</button>
</div>
{% if is_logged_in %}
<a class="cta" href="/dashboard" data-i18n="ctaDashboard">进入控制台</a>
{% else %}
<a class="cta" href="/login" data-i18n="ctaLogin">登录</a>
{% endif %}
</div>
</header>
<main class="main">
<div class="hero">
<h1 data-i18n="heroTitle">端到端加密的密钥与配置管理</h1>
<p class="tagline" data-i18n="heroTagline">Streamable HTTP MCP 与 Web 控制台:元数据与密文分库存储,密钥永不离开你的客户端逻辑。</p>
</div>
<div class="grid">
<article class="card">
<div class="card-icon" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 11c1.66 0 3-1.34 3-3V5c0-1.66-1.34-3-3-3S9 3.34 9 5v3c0 1.66 1.34 3 3 3z"/><path d="M19 10v1a7 7 0 01-14 0v-1"/><path d="M12 14v7M9 18h6"/></svg>
</div>
<h2 data-i18n="c1t">客户端密钥派生</h2>
<p data-i18n="c1d">PBKDF2-SHA256约 60 万次)在浏览器本地从密码短语派生密钥;服务端仅保存盐与校验值,不持有密码或明文主密钥。</p>
</article>
<article class="card">
<div class="card-icon" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0110 0v4"/></svg>
</div>
<h2 data-i18n="c2t">AES-256-GCM 加密</h2>
<p data-i18n="c2d">敏感字段以 AES-GCM 密文落库Web 端在本地加解密,明文默认不经过服务端持久化。</p>
</article>
<article class="card">
<div class="card-icon" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><path d="M14 2v6h6M16 13H8M16 17H8M10 9H8"/></svg>
</div>
<h2 data-i18n="c3t">审计与历史</h2>
<p data-i18n="c3d">操作写入审计日志;条目与密文保留历史版本,支持按版本查看与恢复。</p>
</article>
</div>
</main>
<footer class="foot">
<span data-i18n="versionLabel">版本</span> {{ version }} ·
<a href="/llms.txt">llms.txt</a>
<span data-i18n="sep"> · </span>
<a href="https://gitea.refining.dev/refining/secrets" target="_blank" rel="noopener noreferrer" data-i18n="footRepo">源码仓库</a>
{% if !is_logged_in %}
<span data-i18n="sep"> · </span>
<a href="/login" data-i18n="footLogin">登录</a>
{% endif %}
</footer>
<script>
const T = {
'zh-CN': {
docTitle: 'Secrets MCP — 端到端加密的密钥管理',
ctaDashboard: '进入控制台',
ctaLogin: '登录',
heroTitle: '端到端加密的密钥与配置管理',
heroTagline: 'Streamable HTTP MCP 与 Web 控制台:元数据与密文分库存储,密钥永不离开你的客户端逻辑。',
c1t: '客户端密钥派生',
c1d: 'PBKDF2-SHA256约 60 万次)在浏览器本地从密码短语派生密钥;服务端仅保存盐与校验值,不持有密码或明文主密钥。',
c2t: 'AES-256-GCM 加密',
c2d: '敏感字段以 AES-GCM 密文落库Web 端在本地加解密,明文默认不经过服务端持久化。',
c3t: '审计与历史',
c3d: '操作写入审计日志;条目与密文保留历史版本,支持按版本查看与恢复。',
versionLabel: '版本',
sep: ' · ',
footRepo: '源码仓库',
footLogin: '登录',
},
'zh-TW': {
docTitle: 'Secrets MCP — 端到端加密的金鑰管理',
ctaDashboard: '進入控制台',
ctaLogin: '登入',
heroTitle: '端到端加密的金鑰與設定管理',
heroTagline: 'Streamable HTTP MCP 與 Web 控制台:中繼資料與密文分庫儲存,金鑰不離開你的用戶端邏輯。',
c1t: '用戶端金鑰派生',
c1d: 'PBKDF2-SHA256約 60 萬次)在瀏覽器本地從密碼片語派生金鑰;伺服端僅保存鹽與校驗值,不持有密碼或明文主金鑰。',
c2t: 'AES-256-GCM 加密',
c2d: '敏感欄位以 AES-GCM 密文落庫Web 端在本地加解密,明文預設不經伺服端持久化。',
c3t: '稽核與歷史',
c3d: '操作寫入稽核日誌;條目與密文保留歷史版本,支援依版本檢視與還原。',
versionLabel: '版本',
sep: ' · ',
footRepo: '原始碼倉庫',
footLogin: '登入',
},
'en': {
docTitle: 'Secrets MCP — End-to-end encrypted secrets',
ctaDashboard: 'Open dashboard',
ctaLogin: 'Sign in',
heroTitle: 'End-to-end encrypted secrets and configuration',
heroTagline: 'Streamable HTTP MCP plus web console: metadata and ciphertext stored separately; keys stay on your client.',
c1t: 'Client-side key derivation',
c1d: 'PBKDF2-SHA256 (~600k iterations) derives keys from your passphrase in the browser; the server stores only salt and a verification blob, never your password or raw master key.',
c2t: 'AES-256-GCM',
c2d: 'Secret fields are stored as AES-GCM ciphertext; the web UI encrypts and decrypts locally so plaintext is not persisted server-side by default.',
c3t: 'Audit and history',
c3d: 'Operations are audited; entries and secrets keep version history for review and rollback.',
versionLabel: 'Version',
sep: ' · ',
footRepo: 'Source repository',
footLogin: 'Sign in',
}
};
let currentLang = localStorage.getItem('lang') || 'zh-CN';
function t(key) {
return (T[currentLang] && T[currentLang][key]) || T['en'][key] || key;
}
function applyLang() {
document.documentElement.lang = currentLang;
document.title = t('docTitle');
document.querySelectorAll('[data-i18n]').forEach(el => {
const key = el.getAttribute('data-i18n');
el.textContent = t(key);
});
document.querySelectorAll('.lang-btn').forEach(btn => {
const map = { 'zh-CN': '简', 'zh-TW': '繁', 'en': 'EN' };
btn.classList.toggle('active', btn.textContent === map[currentLang]);
});
}
function setLang(lang) {
currentLang = lang;
localStorage.setItem('lang', lang);
applyLang();
}
applyLang();
</script>
</body>
</html>

View File

@@ -3,8 +3,19 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="robots" content="noindex, follow">
<meta name="description" content="登录 Secrets MCP Web 控制台,安全管理跨设备加密 secrets。">
<meta name="keywords" content="Secrets MCP,登录,OAuth,密钥管理">
<link rel="canonical" href="{{ base_url }}/login">
<link rel="icon" href="/favicon.svg?v={{ version }}" type="image/svg+xml"> <link rel="icon" href="/favicon.svg?v={{ version }}" type="image/svg+xml">
<title>Secrets — Sign In</title> <title>登录 — Secrets MCP</title>
<meta property="og:type" content="website">
<meta property="og:url" content="{{ base_url }}/login">
<meta property="og:title" content="登录 — Secrets MCP">
<meta property="og:description" content="登录 Web 控制台,管理加密存储的密钥与配置。">
<meta name="twitter:card" content="summary">
<meta name="twitter:title" content="登录 — Secrets MCP">
<meta name="twitter:description" content="登录 Web 控制台,管理加密存储的密钥与配置。">
<style> <style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap'); @import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap');
@@ -17,6 +28,7 @@
--accent: #58a6ff; --accent: #58a6ff;
--accent-hover: #79b8ff; --accent-hover: #79b8ff;
--google: #4285f4; --google: #4285f4;
--danger: #f85149;
} }
body { background: var(--bg); color: var(--text); font-family: 'Inter', sans-serif; body { background: var(--bg); color: var(--text); font-family: 'Inter', sans-serif;
min-height: 100vh; display: flex; align-items: center; justify-content: center; } min-height: 100vh; display: flex; align-items: center; justify-content: center; }
@@ -25,11 +37,24 @@
padding: 48px 40px; width: 100%; max-width: 400px; padding: 48px 40px; width: 100%; max-width: 400px;
box-shadow: 0 8px 32px rgba(0,0,0,0.4); box-shadow: 0 8px 32px rgba(0,0,0,0.4);
} }
.topbar { display: flex; justify-content: flex-end; margin-bottom: 20px; } .topbar { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 20px; gap: 12px; }
.lang-bar { display: flex; gap: 2px; background: rgba(255,255,255,0.04); border-radius: 6px; padding: 2px; } .back-home {
font-size: 13px; color: var(--accent); text-decoration: none; white-space: nowrap;
}
.back-home:hover { text-decoration: underline; }
.lang-bar { display: flex; gap: 2px; background: rgba(255,255,255,0.04); border-radius: 6px; padding: 2px; flex-shrink: 0; }
.lang-btn { padding: 3px 9px; border: none; background: none; color: var(--text-muted); .lang-btn { padding: 3px 9px; border: none; background: none; color: var(--text-muted);
font-size: 12px; cursor: pointer; border-radius: 4px; } font-size: 12px; cursor: pointer; border-radius: 4px; }
.lang-btn.active { background: var(--border); color: var(--text); } .lang-btn.active { background: var(--border); color: var(--text); }
.oauth-alert {
display: none;
margin-bottom: 16px; padding: 10px 12px; border-radius: 8px;
font-size: 13px; line-height: 1.4;
background: rgba(248, 81, 73, 0.12);
border: 1px solid rgba(248, 81, 73, 0.35);
color: #ffa198;
}
.oauth-alert.visible { display: block; }
h1 { font-size: 22px; font-weight: 600; margin-bottom: 8px; } h1 { font-size: 22px; font-weight: 600; margin-bottom: 8px; }
.subtitle { color: var(--text-muted); font-size: 14px; margin-bottom: 32px; } .subtitle { color: var(--text-muted); font-size: 14px; margin-bottom: 32px; }
.btn { .btn {
@@ -48,12 +73,14 @@
<body> <body>
<div class="card"> <div class="card">
<div class="topbar"> <div class="topbar">
<a class="back-home" href="/" data-i18n="backHome">返回首页</a>
<div class="lang-bar"> <div class="lang-bar">
<button class="lang-btn" onclick="setLang('zh-CN')"></button> <button type="button" class="lang-btn" onclick="setLang('zh-CN')"></button>
<button class="lang-btn" onclick="setLang('zh-TW')"></button> <button type="button" class="lang-btn" onclick="setLang('zh-TW')"></button>
<button class="lang-btn" onclick="setLang('en')">EN</button> <button type="button" class="lang-btn" onclick="setLang('en')">EN</button>
</div> </div>
</div> </div>
<div id="oauth-alert" class="oauth-alert" role="alert"></div>
<h1 data-i18n="title">登录</h1> <h1 data-i18n="title">登录</h1>
<p class="subtitle" data-i18n="subtitle">安全管理你的跨设备 secrets。</p> <p class="subtitle" data-i18n="subtitle">安全管理你的跨设备 secrets。</p>
@@ -78,22 +105,40 @@
<script> <script>
const T = { const T = {
'zh-CN': { 'zh-CN': {
docTitle: '登录 — Secrets MCP',
backHome: '返回首页',
title: '登录', title: '登录',
subtitle: '安全管理你的跨设备 secrets。', subtitle: '安全管理你的跨设备 secrets。',
google: '使用 Google 登录', google: '使用 Google 登录',
noProviders: '未配置登录方式,请联系管理员。', noProviders: '未配置登录方式,请联系管理员。',
err_oauth_error: '登录失败:授权提供方返回错误,请重试。',
err_oauth_missing_code: '登录失败:未收到授权码,请重试。',
err_oauth_missing_state: '登录失败:缺少安全校验参数,请重试。',
err_oauth_state: '登录失败:会话校验不匹配(可能因 Cookie 策略或服务器重启)。请返回首页再试。',
}, },
'zh-TW': { 'zh-TW': {
docTitle: '登入 — Secrets MCP',
backHome: '返回首頁',
title: '登入', title: '登入',
subtitle: '安全管理你的跨裝置 secrets。', subtitle: '安全管理你的跨裝置 secrets。',
google: '使用 Google 登入', google: '使用 Google 登入',
noProviders: '尚未設定登入方式,請聯絡管理員。', noProviders: '尚未設定登入方式,請聯絡管理員。',
err_oauth_error: '登入失敗:授權方回傳錯誤,請再試一次。',
err_oauth_missing_code: '登入失敗:未取得授權碼,請再試一次。',
err_oauth_missing_state: '登入失敗:缺少安全校驗參數,請再試一次。',
err_oauth_state: '登入失敗:工作階段校驗不符(可能與 Cookie 政策或伺服器重啟有關)。請回到首頁再試。',
}, },
'en': { 'en': {
docTitle: 'Sign in — Secrets MCP',
backHome: 'Back to home',
title: 'Sign in', title: 'Sign in',
subtitle: 'Manage your cross-device secrets securely.', subtitle: 'Manage your cross-device secrets securely.',
google: 'Continue with Google', google: 'Continue with Google',
noProviders: 'No login providers configured. Please contact your administrator.', noProviders: 'No login providers configured. Please contact your administrator.',
err_oauth_error: 'Sign-in failed: the identity provider returned an error. Please try again.',
err_oauth_missing_code: 'Sign-in failed: no authorization code was returned. Please try again.',
err_oauth_missing_state: 'Sign-in failed: missing security state. Please try again.',
err_oauth_state: 'Sign-in failed: session state mismatch (often cookies or server restart). Open the home page and try again.',
} }
}; };
@@ -101,8 +146,23 @@
function t(key) { return T[currentLang][key] || T['en'][key] || key; } function t(key) { return T[currentLang][key] || T['en'][key] || key; }
function showOAuthError() {
const params = new URLSearchParams(window.location.search);
const code = params.get('error');
const el = document.getElementById('oauth-alert');
if (!code || !code.startsWith('oauth_')) {
el.classList.remove('visible');
el.textContent = '';
return;
}
const key = 'err_' + code;
el.textContent = t(key) || t('err_oauth_error');
el.classList.add('visible');
}
function applyLang() { function applyLang() {
document.documentElement.lang = currentLang; document.documentElement.lang = currentLang;
document.title = t('docTitle');
document.querySelectorAll('[data-i18n]').forEach(el => { document.querySelectorAll('[data-i18n]').forEach(el => {
const key = el.getAttribute('data-i18n'); const key = el.getAttribute('data-i18n');
el.textContent = t(key); el.textContent = t(key);
@@ -111,6 +171,7 @@
const map = { 'zh-CN': '简', 'zh-TW': '繁', 'en': 'EN' }; const map = { 'zh-CN': '简', 'zh-TW': '繁', 'en': 'EN' };
btn.classList.toggle('active', btn.textContent === map[currentLang]); btn.classList.toggle('active', btn.textContent === map[currentLang]);
}); });
showOAuthError();
} }
function setLang(lang) { function setLang(lang) {

View File

@@ -2,6 +2,7 @@
# 复制此文件为 .env 并填写真实值 # 复制此文件为 .env 并填写真实值
# ─── 数据库 ─────────────────────────────────────────────────────────── # ─── 数据库 ───────────────────────────────────────────────────────────
# Web 会话tower-sessions与业务数据共用此库启动时会自动 migrate 会话表,无需额外环境变量。
SECRETS_DATABASE_URL=postgres://postgres:PASSWORD@HOST:PORT/secrets-mcp SECRETS_DATABASE_URL=postgres://postgres:PASSWORD@HOST:PORT/secrets-mcp
# ─── 服务地址 ───────────────────────────────────────────────────────── # ─── 服务地址 ─────────────────────────────────────────────────────────
@@ -21,6 +22,9 @@ GOOGLE_CLIENT_SECRET=
# WECHAT_APP_CLIENT_ID= # WECHAT_APP_CLIENT_ID=
# WECHAT_APP_CLIENT_SECRET= # WECHAT_APP_CLIENT_SECRET=
# ─── 日志(可选)──────────────────────────────────────────────────────
# RUST_LOG=secrets_mcp=debug
# ─── 注意 ───────────────────────────────────────────────────────────── # ─── 注意 ─────────────────────────────────────────────────────────────
# SERVER_MASTER_KEY 已不再需要。 # SERVER_MASTER_KEY 已不再需要。
# 新架构E2EE加密密钥由用户密码短语在客户端本地派生服务端不持有原始密钥。 # 新架构E2EE加密密钥由用户密码短语在客户端本地派生服务端不持有原始密钥。

View File

@@ -0,0 +1,22 @@
-- Run against prod BEFORE deploying secrets-mcp with FK migration.
-- Requires: write access to SECRETS_DATABASE_URL.
-- Example: psql "$SECRETS_DATABASE_URL" -v ON_ERROR_STOP=1 -f scripts/cleanup-orphan-user-ids.sql
BEGIN;
UPDATE entries
SET user_id = NULL
WHERE user_id IS NOT NULL
AND NOT EXISTS (SELECT 1 FROM users u WHERE u.id = entries.user_id);
UPDATE entries_history
SET user_id = NULL
WHERE user_id IS NOT NULL
AND NOT EXISTS (SELECT 1 FROM users u WHERE u.id = entries_history.user_id);
UPDATE audit_log
SET user_id = NULL
WHERE user_id IS NOT NULL
AND NOT EXISTS (SELECT 1 FROM users u WHERE u.id = audit_log.user_id);
COMMIT;

View File

@@ -12,12 +12,13 @@ echo "==> 当前 secrets-mcp 版本: ${version}"
echo "==> 检查是否已存在 tag: ${tag}" echo "==> 检查是否已存在 tag: ${tag}"
if git rev-parse "refs/tags/${tag}" >/dev/null 2>&1; then if git rev-parse "refs/tags/${tag}" >/dev/null 2>&1; then
echo "错误: 已存在 tag ${tag}" echo "提示: 已存在 tag ${tag},将按重复构建处理,不阻断检查。"
echo "请先 bump crates/secrets-mcp/Cargo.toml 中的 version,再执行 cargo build 同步 Cargo.lock。" echo "如需创建新的发布版本,请先 bump crates/secrets-mcp/Cargo.toml 中的 version。"
exit 1 else
echo "==> 未发现重复 tag将创建新版本"
fi fi
echo "==> 未发现重复 tag开始执行检查" echo "==> 开始执行检查"
cargo fmt -- --check cargo fmt -- --check
cargo clippy --locked -- -D warnings cargo clippy --locked -- -D warnings
cargo test --locked cargo test --locked