Compare commits
11 Commits
secrets-mc
...
secrets-mc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a44c8ebf08 | ||
|
|
a595081c4c | ||
|
|
0a8b14211a | ||
|
|
9cebbd7587 | ||
|
|
4d136a5a20 | ||
|
|
7ce4aaf835 | ||
|
|
bce01a0f2b | ||
|
|
8cd4dbf592 | ||
|
|
ad3c8d1672 | ||
|
|
8d6b9f0368 | ||
|
|
ce9e089348 |
@@ -3,13 +3,13 @@ name: Secrets MCP — Build & Release
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [main, feat/mcp, mcp]
|
|
||||||
paths:
|
paths:
|
||||||
- 'crates/**'
|
- 'crates/**'
|
||||||
- 'Cargo.toml'
|
- 'Cargo.toml'
|
||||||
- 'Cargo.lock'
|
- 'Cargo.lock'
|
||||||
# systemd / 部署模板变更也应跑构建(产物无变时可快速跳过 check)
|
# systemd / 部署模板变更也应跑构建(产物无变时可快速跳过 check)
|
||||||
- 'deploy/**'
|
- 'deploy/**'
|
||||||
|
- '.gitea/workflows/**'
|
||||||
|
|
||||||
concurrency:
|
concurrency:
|
||||||
group: ${{ github.workflow }}-${{ github.ref }}
|
group: ${{ github.workflow }}-${{ github.ref }}
|
||||||
@@ -20,6 +20,7 @@ permissions:
|
|||||||
|
|
||||||
env:
|
env:
|
||||||
MCP_BINARY: secrets-mcp
|
MCP_BINARY: secrets-mcp
|
||||||
|
RUST_TOOLCHAIN: 1.94.0
|
||||||
CARGO_INCREMENTAL: 0
|
CARGO_INCREMENTAL: 0
|
||||||
CARGO_NET_RETRY: 10
|
CARGO_NET_RETRY: 10
|
||||||
CARGO_TERM_COLOR: always
|
CARGO_TERM_COLOR: always
|
||||||
@@ -139,17 +140,21 @@ jobs:
|
|||||||
|
|
||||||
check:
|
check:
|
||||||
name: 质量检查 (fmt / clippy / test)
|
name: 质量检查 (fmt / clippy / test)
|
||||||
|
needs: [version]
|
||||||
runs-on: debian
|
runs-on: debian
|
||||||
timeout-minutes: 15
|
timeout-minutes: 15
|
||||||
steps:
|
steps:
|
||||||
- name: 安装 Rust
|
- name: 安装 Rust
|
||||||
run: |
|
run: |
|
||||||
if ! command -v cargo >/dev/null 2>&1; then
|
if ! command -v rustup >/dev/null 2>&1; then
|
||||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable
|
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain "${RUST_TOOLCHAIN}"
|
||||||
echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
|
echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
|
||||||
fi
|
fi
|
||||||
source "$HOME/.cargo/env" 2>/dev/null || true
|
source "$HOME/.cargo/env" 2>/dev/null || true
|
||||||
rustup component add rustfmt clippy
|
rustup toolchain install "${RUST_TOOLCHAIN}" --profile minimal --component rustfmt --component clippy
|
||||||
|
rustup default "${RUST_TOOLCHAIN}"
|
||||||
|
rustc -V
|
||||||
|
cargo -V
|
||||||
|
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
@@ -160,9 +165,9 @@ jobs:
|
|||||||
~/.cargo/registry/index
|
~/.cargo/registry/index
|
||||||
~/.cargo/registry/cache
|
~/.cargo/registry/cache
|
||||||
~/.cargo/git/db
|
~/.cargo/git/db
|
||||||
target
|
key: cargo-check-${{ env.RUST_TOOLCHAIN }}-${{ hashFiles('Cargo.lock') }}
|
||||||
key: cargo-check-${{ hashFiles('Cargo.lock') }}
|
|
||||||
restore-keys: |
|
restore-keys: |
|
||||||
|
cargo-check-${{ env.RUST_TOOLCHAIN }}-
|
||||||
cargo-check-
|
cargo-check-
|
||||||
|
|
||||||
- run: cargo fmt -- --check
|
- run: cargo fmt -- --check
|
||||||
@@ -179,12 +184,16 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
sudo apt-get update
|
sudo apt-get update
|
||||||
sudo apt-get install -y pkg-config musl-tools binutils curl
|
sudo apt-get install -y pkg-config musl-tools binutils curl
|
||||||
if ! command -v cargo >/dev/null 2>&1; then
|
if ! command -v rustup >/dev/null 2>&1; then
|
||||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable
|
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain "${RUST_TOOLCHAIN}"
|
||||||
|
echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
|
||||||
fi
|
fi
|
||||||
source "$HOME/.cargo/env" 2>/dev/null || true
|
source "$HOME/.cargo/env" 2>/dev/null || true
|
||||||
rustup target add x86_64-unknown-linux-musl
|
rustup toolchain install "${RUST_TOOLCHAIN}" --profile minimal
|
||||||
echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
|
rustup default "${RUST_TOOLCHAIN}"
|
||||||
|
rustup target add x86_64-unknown-linux-musl --toolchain "${RUST_TOOLCHAIN}"
|
||||||
|
rustc -V
|
||||||
|
cargo -V
|
||||||
|
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
@@ -195,9 +204,9 @@ jobs:
|
|||||||
~/.cargo/registry/index
|
~/.cargo/registry/index
|
||||||
~/.cargo/registry/cache
|
~/.cargo/registry/cache
|
||||||
~/.cargo/git/db
|
~/.cargo/git/db
|
||||||
target
|
key: cargo-x86_64-unknown-linux-musl-${{ env.RUST_TOOLCHAIN }}-${{ hashFiles('Cargo.lock') }}
|
||||||
key: cargo-x86_64-unknown-linux-musl-${{ hashFiles('Cargo.lock') }}
|
|
||||||
restore-keys: |
|
restore-keys: |
|
||||||
|
cargo-x86_64-unknown-linux-musl-${{ env.RUST_TOOLCHAIN }}-
|
||||||
cargo-x86_64-unknown-linux-musl-
|
cargo-x86_64-unknown-linux-musl-
|
||||||
|
|
||||||
- name: 构建 secrets-mcp (musl)
|
- name: 构建 secrets-mcp (musl)
|
||||||
@@ -258,13 +267,17 @@ jobs:
|
|||||||
|
|
||||||
- name: 安装 Rust
|
- name: 安装 Rust
|
||||||
run: |
|
run: |
|
||||||
if ! command -v cargo >/dev/null 2>&1; then
|
if ! command -v rustup >/dev/null 2>&1; then
|
||||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable
|
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain "${RUST_TOOLCHAIN}"
|
||||||
echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
|
echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
|
||||||
fi
|
fi
|
||||||
source "$HOME/.cargo/env" 2>/dev/null || true
|
source "$HOME/.cargo/env" 2>/dev/null || true
|
||||||
sudo apt-get update -qq && sudo apt-get install -y -qq pkg-config musl-tools
|
sudo apt-get update -qq && sudo apt-get install -y -qq pkg-config musl-tools
|
||||||
rustup target add x86_64-unknown-linux-musl
|
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
|
- name: 缓存 Cargo
|
||||||
uses: actions/cache@v4
|
uses: actions/cache@v4
|
||||||
@@ -273,9 +286,9 @@ jobs:
|
|||||||
~/.cargo/registry/index
|
~/.cargo/registry/index
|
||||||
~/.cargo/registry/cache
|
~/.cargo/registry/cache
|
||||||
~/.cargo/git/db
|
~/.cargo/git/db
|
||||||
target
|
key: cargo-x86_64-unknown-linux-musl-${{ env.RUST_TOOLCHAIN }}-${{ hashFiles('Cargo.lock') }}
|
||||||
key: cargo-x86_64-unknown-linux-musl-${{ hashFiles('Cargo.lock') }}
|
|
||||||
restore-keys: |
|
restore-keys: |
|
||||||
|
cargo-x86_64-unknown-linux-musl-${{ env.RUST_TOOLCHAIN }}-
|
||||||
cargo-x86_64-unknown-linux-musl-
|
cargo-x86_64-unknown-linux-musl-
|
||||||
|
|
||||||
- name: 构建 secrets-mcp
|
- name: 构建 secrets-mcp
|
||||||
|
|||||||
12
AGENTS.md
12
AGENTS.md
@@ -148,11 +148,13 @@ git tag -l 'secrets-mcp-*'
|
|||||||
|
|
||||||
## CI/CD
|
## CI/CD
|
||||||
|
|
||||||
- **触发**:`main` / `feat/mcp`(以仓库 workflow 为准);路径含 `crates/**`、`deploy/**`、`Cargo.toml`、`Cargo.lock`。
|
- **触发**:任意分支 `push`,且路径含 `crates/**`、`deploy/**`、根目录 `Cargo.toml`、`Cargo.lock`(见 `.gitea/workflows/secrets.yml`)。
|
||||||
- **构建**:`x86_64-unknown-linux-musl` → `secrets-mcp`。
|
- **版本与 tag**:从 `crates/secrets-mcp/Cargo.toml` 读版本;若远程已存在同名 `secrets-mcp-<version>` tag,**工作流失败**(须先 bump 版本并 `cargo build` 同步 `Cargo.lock`);否则由 CI 创建并推送该 tag。
|
||||||
- **Release**:tag `secrets-mcp-<version>`,上传 tar.gz + `.sha256`。
|
- **质量与构建**:`fmt` / `clippy --locked` / `test --locked` → `x86_64-unknown-linux-musl` 发布构建 `secrets-mcp`。
|
||||||
- **部署**:可选在仓库 Actions 中配置 `vars.DEPLOY_HOST`、`vars.DEPLOY_USER` 与 `secrets.DEPLOY_SSH_KEY`(勿写进 workflow);可用 `scripts/setup-gitea-actions.sh` 调 Gitea API 写入。Actions **secrets 须为原始值**(如 PEM 全文、PAT 明文),**不要**先 base64 再写入,否则工作流内无法识别(例如 SSH 私钥无效)。**勿**在 CI 中保存 `GOOGLE_CLIENT_SECRET`、DB 密码。
|
- **Release(可选)**:`secrets.RELEASE_TOKEN`(Gitea PAT)用于创建草稿 Release、上传 `tar.gz` + `.sha256`、构建成功后发布;未配置则跳过 API Release,仅 tag + 构建。
|
||||||
- **通知**:`vars.WEBHOOK_URL`(可选)。
|
- **部署(可选)**:仅 `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 密码。
|
||||||
|
- **通知**:`vars.WEBHOOK_URL`(可选,飞书)。
|
||||||
|
|
||||||
## 环境变量(secrets-mcp)
|
## 环境变量(secrets-mcp)
|
||||||
|
|
||||||
|
|||||||
2
Cargo.lock
generated
2
Cargo.lock
generated
@@ -1949,7 +1949,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "secrets-mcp"
|
name = "secrets-mcp"
|
||||||
version = "0.1.0"
|
version = "0.1.6"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"askama",
|
"askama",
|
||||||
|
|||||||
10
README.md
10
README.md
@@ -162,10 +162,16 @@ deploy/ # systemd、.env 示例
|
|||||||
|
|
||||||
## CI/CD(Gitea Actions)
|
## CI/CD(Gitea Actions)
|
||||||
|
|
||||||
见 [`.gitea/workflows/secrets.yml`](.gitea/workflows/secrets.yml)。变更 `crates/**`、`deploy/**`、根目录 `Cargo.toml`/`Cargo.lock` 并推送到配置的分支时:fmt / clippy / test → 构建 `x86_64-unknown-linux-musl` → tag `secrets-mcp-<version>` 与 Release 产物 → 可选 SSH 部署。
|
见 [`.gitea/workflows/secrets.yml`](.gitea/workflows/secrets.yml)。
|
||||||
|
|
||||||
|
- **触发**:任意分支 `push`,且变更路径包含 `crates/**`、`deploy/**`、根目录 `Cargo.toml` / `Cargo.lock`。
|
||||||
|
- **流水线**:解析 `crates/secrets-mcp/Cargo.toml` 版本 → **若 `secrets-mcp-<version>` 的 tag 已存在则整次运行失败**(避免重复发版)→ 否则自动打 tag → `cargo fmt` / `clippy --locked` / `test --locked` → 交叉编译 `x86_64-unknown-linux-musl` 的 `secrets-mcp`。
|
||||||
|
- **Release(可选)**:配置仓库 Secret `RELEASE_TOKEN`(Gitea PAT,明文勿 base64)时,会通过 API 创建**草稿** Release、在 Linux 构建成功后上传 `tar.gz` 与 `.sha256`,再自动将草稿**正式发布**;未配置则跳过创建 Release 与产物上传,仅保留 tag 与构建结果。
|
||||||
|
- **部署(可选)**:仅在 `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 时,构建/部署/发布节点会推送简要状态。
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./scripts/setup-gitea-actions.sh # 配置 Gitea 变量与 Secrets
|
./scripts/setup-gitea-actions.sh # 通过 Gitea API 写入 RELEASE_TOKEN、WEBHOOK_URL、部署相关变量等
|
||||||
```
|
```
|
||||||
|
|
||||||
详见 [AGENTS.md](AGENTS.md)(发版规则、代码规范)。
|
详见 [AGENTS.md](AGENTS.md)(发版规则、代码规范)。
|
||||||
|
|||||||
@@ -1,11 +1,60 @@
|
|||||||
use serde_json::Value;
|
use serde_json::{Value, json};
|
||||||
use sqlx::{Postgres, Transaction};
|
use sqlx::{PgPool, Postgres, Transaction};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
pub const ACTION_LOGIN: &str = "login";
|
||||||
|
pub const NAMESPACE_AUTH: &str = "auth";
|
||||||
|
|
||||||
/// Return the current OS user as the audit actor (falls back to empty string).
|
/// Return the current OS user as the audit actor (falls back to empty string).
|
||||||
pub fn current_actor() -> String {
|
pub fn current_actor() -> String {
|
||||||
std::env::var("USER").unwrap_or_default()
|
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!({
|
||||||
|
"user_id": user_id,
|
||||||
|
"provider": provider,
|
||||||
|
"client_ip": client_ip,
|
||||||
|
"user_agent": user_agent,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write a login audit entry without requiring an explicit transaction.
|
||||||
|
pub async fn log_login(
|
||||||
|
pool: &PgPool,
|
||||||
|
kind: &str,
|
||||||
|
provider: &str,
|
||||||
|
user_id: Uuid,
|
||||||
|
client_ip: Option<&str>,
|
||||||
|
user_agent: Option<&str>,
|
||||||
|
) {
|
||||||
|
let actor = current_actor();
|
||||||
|
let detail = login_detail(user_id, provider, client_ip, user_agent);
|
||||||
|
let result: Result<_, sqlx::Error> = sqlx::query(
|
||||||
|
"INSERT INTO audit_log (action, namespace, kind, name, detail, actor) \
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6)",
|
||||||
|
)
|
||||||
|
.bind(ACTION_LOGIN)
|
||||||
|
.bind(NAMESPACE_AUTH)
|
||||||
|
.bind(kind)
|
||||||
|
.bind(provider)
|
||||||
|
.bind(&detail)
|
||||||
|
.bind(&actor)
|
||||||
|
.execute(pool)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
if let Err(e) = result {
|
||||||
|
tracing::warn!(error = %e, kind, provider, "failed to write login audit log");
|
||||||
|
} else {
|
||||||
|
tracing::debug!(kind, provider, ?user_id, actor, "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>,
|
||||||
@@ -35,3 +84,19 @@ pub async fn log_tx(
|
|||||||
tracing::debug!(action, namespace, kind, name, actor, "audit logged");
|
tracing::debug!(action, namespace, kind, name, actor, "audit logged");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn login_detail_includes_expected_fields() {
|
||||||
|
let user_id = Uuid::nil();
|
||||||
|
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["client_ip"], "127.0.0.1");
|
||||||
|
assert_eq!(detail["user_agent"], "Mozilla/5.0");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "secrets-mcp"
|
name = "secrets-mcp"
|
||||||
version = "0.1.0"
|
version = "0.1.6"
|
||||||
edition.workspace = true
|
edition.workspace = true
|
||||||
|
|
||||||
[[bin]]
|
[[bin]]
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ 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.
|
||||||
@@ -34,6 +35,15 @@ 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(
|
||||||
@@ -44,6 +54,7 @@ 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") {
|
||||||
@@ -84,6 +95,15 @@ 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 });
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
use askama::Template;
|
use askama::Template;
|
||||||
|
use std::net::SocketAddr;
|
||||||
|
|
||||||
use axum::{
|
use axum::{
|
||||||
Json, Router,
|
Json, Router,
|
||||||
body::Body,
|
body::Body,
|
||||||
extract::{Path, Query, State},
|
extract::{ConnectInfo, Path, Query, State},
|
||||||
http::{StatusCode, header},
|
http::{HeaderMap, StatusCode, header},
|
||||||
response::{Html, IntoResponse, Redirect, Response},
|
response::{Html, IntoResponse, Redirect, Response},
|
||||||
routing::{get, post},
|
routing::{get, post},
|
||||||
};
|
};
|
||||||
@@ -11,6 +13,7 @@ use serde::{Deserialize, Serialize};
|
|||||||
use tower_sessions::Session;
|
use tower_sessions::Session;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
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},
|
||||||
@@ -62,6 +65,30 @@ async fn current_user_id(session: &Session) -> Option<Uuid> {
|
|||||||
.and_then(|s| Uuid::parse_str(&s).ok())
|
.and_then(|s| Uuid::parse_str(&s).ok())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn request_client_ip(headers: &HeaderMap, connect_info: ConnectInfo<SocketAddr>) -> Option<String> {
|
||||||
|
if let Some(first) = headers
|
||||||
|
.get("x-forwarded-for")
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
.and_then(|s| s.split(',').next())
|
||||||
|
{
|
||||||
|
let value = first.trim();
|
||||||
|
if !value.is_empty() {
|
||||||
|
return Some(value.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Some(connect_info.ip().to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn request_user_agent(headers: &HeaderMap) -> Option<String> {
|
||||||
|
headers
|
||||||
|
.get(header::USER_AGENT)
|
||||||
|
.and_then(|value| value.to_str().ok())
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.map(ToOwned::to_owned)
|
||||||
|
}
|
||||||
|
|
||||||
// ── Routes ────────────────────────────────────────────────────────────────────
|
// ── Routes ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
pub fn web_router() -> Router<AppState> {
|
pub fn web_router() -> Router<AppState> {
|
||||||
@@ -141,16 +168,28 @@ struct OAuthCallbackQuery {
|
|||||||
|
|
||||||
async fn auth_google_callback(
|
async fn auth_google_callback(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
|
connect_info: ConnectInfo<SocketAddr>,
|
||||||
|
headers: HeaderMap,
|
||||||
session: Session,
|
session: Session,
|
||||||
Query(params): Query<OAuthCallbackQuery>,
|
Query(params): Query<OAuthCallbackQuery>,
|
||||||
) -> Result<Response, StatusCode> {
|
) -> Result<Response, StatusCode> {
|
||||||
handle_oauth_callback(&state, &session, params, "google", |s, cfg, code| {
|
let client_ip = request_client_ip(&headers, connect_info);
|
||||||
Box::pin(crate::oauth::google::exchange_code(
|
let user_agent = request_user_agent(&headers);
|
||||||
&s.http_client,
|
handle_oauth_callback(
|
||||||
cfg,
|
&state,
|
||||||
code,
|
&session,
|
||||||
))
|
params,
|
||||||
})
|
"google",
|
||||||
|
client_ip.as_deref(),
|
||||||
|
user_agent.as_deref(),
|
||||||
|
|s, cfg, code| {
|
||||||
|
Box::pin(crate::oauth::google::exchange_code(
|
||||||
|
&s.http_client,
|
||||||
|
cfg,
|
||||||
|
code,
|
||||||
|
))
|
||||||
|
},
|
||||||
|
)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -161,6 +200,8 @@ async fn handle_oauth_callback<F>(
|
|||||||
session: &Session,
|
session: &Session,
|
||||||
params: OAuthCallbackQuery,
|
params: OAuthCallbackQuery,
|
||||||
provider: &str,
|
provider: &str,
|
||||||
|
client_ip: Option<&str>,
|
||||||
|
user_agent: Option<&str>,
|
||||||
exchange_fn: F,
|
exchange_fn: F,
|
||||||
) -> Result<Response, StatusCode>
|
) -> Result<Response, StatusCode>
|
||||||
where
|
where
|
||||||
@@ -274,6 +315,16 @@ where
|
|||||||
.await
|
.await
|
||||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
|
||||||
|
log_login(
|
||||||
|
&state.pool,
|
||||||
|
"oauth",
|
||||||
|
provider,
|
||||||
|
user.id,
|
||||||
|
client_ip,
|
||||||
|
user_agent,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
Ok(Redirect::to("/dashboard").into_response())
|
Ok(Redirect::to("/dashboard").into_response())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -342,16 +393,28 @@ async fn account_bind_google(
|
|||||||
|
|
||||||
async fn account_bind_google_callback(
|
async fn account_bind_google_callback(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
|
connect_info: ConnectInfo<SocketAddr>,
|
||||||
|
headers: HeaderMap,
|
||||||
session: Session,
|
session: Session,
|
||||||
Query(params): Query<OAuthCallbackQuery>,
|
Query(params): Query<OAuthCallbackQuery>,
|
||||||
) -> Result<Response, StatusCode> {
|
) -> Result<Response, StatusCode> {
|
||||||
handle_oauth_callback(&state, &session, params, "google", |s, cfg, code| {
|
let client_ip = request_client_ip(&headers, connect_info);
|
||||||
Box::pin(crate::oauth::google::exchange_code(
|
let user_agent = request_user_agent(&headers);
|
||||||
&s.http_client,
|
handle_oauth_callback(
|
||||||
cfg,
|
&state,
|
||||||
code,
|
&session,
|
||||||
))
|
params,
|
||||||
})
|
"google",
|
||||||
|
client_ip.as_deref(),
|
||||||
|
user_agent.as_deref(),
|
||||||
|
|s, cfg, code| {
|
||||||
|
Box::pin(crate::oauth::google::exchange_code(
|
||||||
|
&s.http_client,
|
||||||
|
cfg,
|
||||||
|
code,
|
||||||
|
))
|
||||||
|
},
|
||||||
|
)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -37,12 +37,7 @@
|
|||||||
padding: 48px 24px 24px; min-height: calc(100vh - 52px); }
|
padding: 48px 24px 24px; min-height: calc(100vh - 52px); }
|
||||||
.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: 32px; width: 100%; max-width: 980px; }
|
||||||
.card-title { font-size: 18px; font-weight: 600; margin-bottom: 6px; }
|
.card-title { font-size: 18px; font-weight: 600; margin-bottom: 24px; }
|
||||||
.card-sub { font-size: 13px; color: var(--text-muted); line-height: 1.6; margin-bottom: 24px; }
|
|
||||||
.info-box { background: var(--surface2); border: 1px solid var(--border); border-radius: 8px;
|
|
||||||
padding: 12px 14px; margin-bottom: 18px; }
|
|
||||||
.info-title { font-size: 12px; font-weight: 600; color: var(--text); margin-bottom: 8px; }
|
|
||||||
.info-line { font-size: 12px; color: var(--text-muted); line-height: 1.6; }
|
|
||||||
/* Form */
|
/* Form */
|
||||||
.field { margin-bottom: 12px; }
|
.field { margin-bottom: 12px; }
|
||||||
.field label { display: block; font-size: 12px; color: var(--text-muted); margin-bottom: 5px; }
|
.field label { display: block; font-size: 12px; color: var(--text-muted); margin-bottom: 5px; }
|
||||||
@@ -167,11 +162,6 @@
|
|||||||
<!-- ── Locked state ──────────────────────────────────────────────────── -->
|
<!-- ── Locked state ──────────────────────────────────────────────────── -->
|
||||||
<div id="locked-view">
|
<div id="locked-view">
|
||||||
<div class="card-title" data-i18n="lockedTitle">获取 MCP 配置</div>
|
<div class="card-title" data-i18n="lockedTitle">获取 MCP 配置</div>
|
||||||
<div class="card-sub" data-i18n="lockedSub">输入加密密码,派生密钥后生成完整的 MCP 配置,可直接复制到 AI 客户端。</div>
|
|
||||||
<div class="info-box">
|
|
||||||
<div class="info-title" data-i18n="aboutTitle">说明</div>
|
|
||||||
<div class="info-line" data-i18n="aboutApiKey">API Key 用于身份认证,告诉服务端“你是谁”。</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- placeholder config -->
|
<!-- placeholder config -->
|
||||||
<div class="config-wrap">
|
<div class="config-wrap">
|
||||||
@@ -313,9 +303,6 @@ const T = {
|
|||||||
'zh-CN': {
|
'zh-CN': {
|
||||||
signOut: '退出',
|
signOut: '退出',
|
||||||
lockedTitle: '获取 MCP 配置',
|
lockedTitle: '获取 MCP 配置',
|
||||||
lockedSub: '输入加密密码,派生密钥后生成 MCP 配置;请按你所用客户端在解锁后选择对应卡片。',
|
|
||||||
aboutTitle: '说明',
|
|
||||||
aboutApiKey: 'API Key 用于身份认证;X-Encryption-Key 用于加解密密文。二者请仅保存在本机配置中。',
|
|
||||||
labelPassphrase: '加密密码',
|
labelPassphrase: '加密密码',
|
||||||
labelConfirm: '确认密码',
|
labelConfirm: '确认密码',
|
||||||
labelNew: '新密码',
|
labelNew: '新密码',
|
||||||
@@ -350,9 +337,6 @@ const T = {
|
|||||||
'zh-TW': {
|
'zh-TW': {
|
||||||
signOut: '登出',
|
signOut: '登出',
|
||||||
lockedTitle: '取得 MCP 設定',
|
lockedTitle: '取得 MCP 設定',
|
||||||
lockedSub: '輸入加密密碼,派生金鑰後生成 MCP 設定;請依你所用用戶端在解鎖後選擇對應卡片。',
|
|
||||||
aboutTitle: '說明',
|
|
||||||
aboutApiKey: 'API Key 用於身份驗證;X-Encryption-Key 用於加解密密文。二者請僅保存在本機設定中。',
|
|
||||||
labelPassphrase: '加密密碼',
|
labelPassphrase: '加密密碼',
|
||||||
labelConfirm: '確認密碼',
|
labelConfirm: '確認密碼',
|
||||||
labelNew: '新密碼',
|
labelNew: '新密碼',
|
||||||
@@ -387,9 +371,6 @@ const T = {
|
|||||||
'en': {
|
'en': {
|
||||||
signOut: 'Sign out',
|
signOut: 'Sign out',
|
||||||
lockedTitle: 'Get MCP Config',
|
lockedTitle: 'Get MCP Config',
|
||||||
lockedSub: 'Enter your encryption password to derive your key and generate MCP config. After unlock, pick the card that matches your client.',
|
|
||||||
aboutTitle: 'About',
|
|
||||||
aboutApiKey: 'The API key authenticates you; X-Encryption-Key encrypts secret payloads. Keep both only in local client config.',
|
|
||||||
labelPassphrase: 'Encryption password',
|
labelPassphrase: 'Encryption password',
|
||||||
labelConfirm: 'Confirm password',
|
labelConfirm: 'Confirm password',
|
||||||
labelNew: 'New password',
|
labelNew: 'New password',
|
||||||
@@ -554,12 +535,14 @@ function buildOpencodeEntry(apiKey, encKey) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Full OpenCode config: MCP servers live under top-level `mcp`. */
|
||||||
function buildOpencodeConfigText(apiKey, encKey) {
|
function buildOpencodeConfigText(apiKey, encKey) {
|
||||||
return JSON.stringify({ secrets: buildOpencodeEntry(apiKey, encKey) }, null, 2);
|
return JSON.stringify({ mcp: { secrets: buildOpencodeEntry(apiKey, encKey) } }, null, 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Strip outer `{` `}` so user can paste `secrets` under an existing `mcp` object. */
|
||||||
function buildOpencodeMergeSnippet(apiKey, encKey) {
|
function buildOpencodeMergeSnippet(apiKey, encKey) {
|
||||||
const wrapped = buildOpencodeConfigText(apiKey, encKey);
|
const wrapped = JSON.stringify({ secrets: buildOpencodeEntry(apiKey, encKey) }, null, 2);
|
||||||
const lines = wrapped.split('\n');
|
const lines = wrapped.split('\n');
|
||||||
return lines.length < 3 ? wrapped : lines.slice(1, -1).join('\n');
|
return lines.length < 3 ? wrapped : lines.slice(1, -1).join('\n');
|
||||||
}
|
}
|
||||||
|
|||||||
3
rust-toolchain.toml
Normal file
3
rust-toolchain.toml
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
[toolchain]
|
||||||
|
channel = "1.94.0"
|
||||||
|
components = ["rustfmt", "clippy"]
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
# 参考: .gitea/workflows/secrets.yml
|
# 参考: .gitea/workflows/secrets.yml
|
||||||
#
|
#
|
||||||
# 所需配置:
|
# 所需配置:
|
||||||
# - secrets.RELEASE_TOKEN (必选) Release 上传用,值为 Gitea PAT
|
# - secrets.RELEASE_TOKEN (可选,推荐) Gitea PAT;未配置则工作流跳过 Release 创建与产物上传
|
||||||
# - vars.WEBHOOK_URL (可选) 飞书通知
|
# - vars.WEBHOOK_URL (可选) 飞书通知
|
||||||
# - vars.DEPLOY_HOST (可选) 部署目标 SSH 主机(IP 或域名)
|
# - vars.DEPLOY_HOST (可选) 部署目标 SSH 主机(IP 或域名)
|
||||||
# - vars.DEPLOY_USER (可选) SSH 用户名
|
# - vars.DEPLOY_USER (可选) SSH 用户名
|
||||||
@@ -21,7 +21,7 @@
|
|||||||
# 1. 从 ~/.config/gitea/config.env 读取 GITEA_URL, GITEA_TOKEN, GITEA_WEBHOOK_URL
|
# 1. 从 ~/.config/gitea/config.env 读取 GITEA_URL, GITEA_TOKEN, GITEA_WEBHOOK_URL
|
||||||
# 2. 或通过环境变量覆盖: GITEA_TOKEN(作为 RELEASE_TOKEN 的值), WEBHOOK_URL,
|
# 2. 或通过环境变量覆盖: GITEA_TOKEN(作为 RELEASE_TOKEN 的值), WEBHOOK_URL,
|
||||||
# DEPLOY_HOST, DEPLOY_USER, DEPLOY_SSH_KEY_FILE(部署到 ECS)
|
# DEPLOY_HOST, DEPLOY_USER, DEPLOY_SSH_KEY_FILE(部署到 ECS)
|
||||||
# 3. 或使用 secrets CLI 获取: 需 DATABASE_URL,从 refining/service gitea 读取
|
# 3. 凭据勿用 base64;部署私钥路径见 DEPLOY_SSH_KEY_FILE
|
||||||
#
|
#
|
||||||
|
|
||||||
set -e
|
set -e
|
||||||
@@ -30,26 +30,41 @@ OWNER="refining"
|
|||||||
REPO="secrets"
|
REPO="secrets"
|
||||||
|
|
||||||
# 解析参数
|
# 解析参数
|
||||||
USE_SECRETS_CLI=false
|
|
||||||
while [[ $# -gt 0 ]]; do
|
while [[ $# -gt 0 ]]; do
|
||||||
case $1 in
|
case $1 in
|
||||||
--from-secrets) USE_SECRETS_CLI=true; shift ;;
|
--from-secrets)
|
||||||
|
echo "❌ --from-secrets 尚未实现,请使用 ~/.config/gitea/config.env 或环境变量" >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
-h|--help)
|
-h|--help)
|
||||||
echo "用法: $0 [--from-secrets]"
|
echo "用法: $0"
|
||||||
echo ""
|
echo ""
|
||||||
echo " --from-secrets 从 secrets CLI (refining/service gitea) 获取 token 和 webhook_url"
|
echo "从 ~/.config/gitea/config.env 读取,或由环境变量覆盖。"
|
||||||
echo " 否则从 ~/.config/gitea/config.env 读取"
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "环境变量覆盖:"
|
echo "环境变量:"
|
||||||
echo " GITEA_URL Gitea 实例地址"
|
echo " GITEA_URL Gitea 实例根地址(可误带尾部 /api/v1,脚本会规范化后拼接)"
|
||||||
echo " GITEA_TOKEN 用于 Release 上传的 PAT (创建 RELEASE_TOKEN secret)"
|
echo " GITEA_TOKEN 用于 Release 的 PAT → secrets.RELEASE_TOKEN"
|
||||||
echo " WEBHOOK_URL 飞书 Webhook URL (创建 variable,可选)"
|
echo " WEBHOOK_URL 或 GITEA_WEBHOOK_URL → vars.WEBHOOK_URL(可选)"
|
||||||
|
echo " DEPLOY_HOST 部署 SSH 主机(可选,须与下面两项同时设置)"
|
||||||
|
echo " DEPLOY_USER 部署 SSH 用户"
|
||||||
|
echo " DEPLOY_SSH_KEY_FILE 本地 PEM 路径 → secrets.DEPLOY_SSH_KEY(原文上传,勿 base64)"
|
||||||
exit 0
|
exit 0
|
||||||
;;
|
;;
|
||||||
*) shift ;;
|
*)
|
||||||
|
echo "❌ 未知参数: $1" >&2
|
||||||
|
echo " 使用 $0 --help 查看用法" >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
esac
|
esac
|
||||||
done
|
done
|
||||||
|
|
||||||
|
for cmd in curl jq; do
|
||||||
|
if ! command -v "$cmd" &>/dev/null; then
|
||||||
|
echo "❌ 未找到命令: $cmd(本脚本依赖 curl 与 jq)" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
# 加载配置
|
# 加载配置
|
||||||
load_config() {
|
load_config() {
|
||||||
local config="$HOME/.config/gitea/config.env"
|
local config="$HOME/.config/gitea/config.env"
|
||||||
@@ -59,26 +74,6 @@ load_config() {
|
|||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
# 从 secrets CLI 获取 gitea 凭据
|
|
||||||
fetch_from_secrets() {
|
|
||||||
if ! command -v secrets &>/dev/null; then
|
|
||||||
echo "❌ secrets CLI 未找到,请先构建: cargo build --release" >&2
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
# 输出 JSON 格式便于解析;需要 --show-secrets
|
|
||||||
# secrets 当前无 JSON 输出,用简单解析
|
|
||||||
local out
|
|
||||||
out=$(secrets search -n refining --kind service -q gitea --show-secrets 2>/dev/null || true)
|
|
||||||
if [[ -z "$out" ]]; then
|
|
||||||
echo "❌ 未找到 refining/service gitea 记录" >&2
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
# 简化:从 metadata 和 secrets 中提取,实际格式需根据 search 输出调整
|
|
||||||
# 此处仅作占位,实际解析较复杂;建议用户优先用 config.env
|
|
||||||
echo "⚠️ --from-secrets 暂不支持自动解析,请使用 config.env 或环境变量" >&2
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
|
|
||||||
load_config
|
load_config
|
||||||
|
|
||||||
# 优先使用环境变量
|
# 优先使用环境变量
|
||||||
@@ -93,18 +88,17 @@ if [[ -z "$GITEA_URL" ]]; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# 去掉 URL 尾部斜杠
|
# 规范为实例根 URL:去尾部斜杠,并去掉重复的 .../api/v1 后缀(避免拼成 .../api/v1/api/v1)
|
||||||
GITEA_URL="${GITEA_URL%/}"
|
GITEA_URL="${GITEA_URL%/}"
|
||||||
# 确保使用 /api/v1 基础路径(若用户只写了根 URL)
|
while [[ "$GITEA_URL" == */api/v1 ]]; do
|
||||||
[[ "$GITEA_URL" != *"/api/v1"* ]] || true
|
GITEA_URL="${GITEA_URL%/api/v1}"
|
||||||
|
GITEA_URL="${GITEA_URL%/}"
|
||||||
|
done
|
||||||
|
|
||||||
API_BASE="${GITEA_URL}/api/v1"
|
API_BASE="${GITEA_URL}/api/v1"
|
||||||
|
|
||||||
# 获取 GITEA_TOKEN(作为 workflow 中 secrets.RELEASE_TOKEN 的值)
|
# 获取 GITEA_TOKEN(作为 workflow 中 secrets.RELEASE_TOKEN 的值)
|
||||||
if [[ -z "$GITEA_TOKEN" ]]; then
|
if [[ -z "$GITEA_TOKEN" ]]; then
|
||||||
if $USE_SECRETS_CLI; then
|
|
||||||
fetch_from_secrets || exit 1
|
|
||||||
fi
|
|
||||||
echo "❌ GITEA_TOKEN 未配置"
|
echo "❌ GITEA_TOKEN 未配置"
|
||||||
echo " 在 ~/.config/gitea/config.env 中设置,或 export GITEA_TOKEN=xxx" >&2
|
echo " 在 ~/.config/gitea/config.env 中设置,或 export GITEA_TOKEN=xxx" >&2
|
||||||
echo " Token 需具备 repo 写权限(创建 Release、上传附件)" >&2
|
echo " Token 需具备 repo 写权限(创建 Release、上传附件)" >&2
|
||||||
|
|||||||
Reference in New Issue
Block a user