dev-manager-tauri/src-tauri/src/mcp_webhook.rs
lanrtop 3a560f149e feat(resource/broker): 实现受保护凭证代办与项目 readiness
Gitea、webhook 与 API Key 以受保护引用替代常态明文输出;旧值先备份并校验后可恢复迁移,MCP 仅提供项目只读状态。

Rules-Applied: R01,R05,R06,R07,R10

Feature-Confirmed: true
2026-09-23 18:45:30 +09:00

381 lines
12 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

use axum::{
body::Bytes,
extract::Path,
http::{HeaderMap, StatusCode},
response::{IntoResponse, Response},
Json,
};
use serde_json::json;
use crate::{db, db::log_event};
/// push payload 中的一条 commit
#[derive(Debug, PartialEq)]
pub struct PushCommit {
pub sha: String,
pub message: String,
pub timestamp: Option<String>,
}
/// HMAC-SHA256 验签(X-Gitea-Signature 头)
pub fn verify_hmac_sha256(secret: &str, body: &[u8], sig_hex: &str) -> bool {
use hmac::{Hmac, Mac};
use sha2::Sha256;
let mut mac = match Hmac::<Sha256>::new_from_slice(secret.as_bytes()) {
Ok(m) => m,
Err(_) => return false,
};
mac.update(body);
let expected: String = mac
.finalize()
.into_bytes()
.iter()
.map(|b| format!("{b:02x}"))
.collect();
expected.eq_ignore_ascii_case(sig_hex.trim())
}
/// 解析 Gitea push payload 的 commits 数组
pub fn parse_push_payload(body: &[u8]) -> Result<Vec<PushCommit>, String> {
let json: serde_json::Value =
serde_json::from_slice(body).map_err(|e| format!("payload 非法 JSON: {e}"))?;
let commits = json["commits"]
.as_array()
.ok_or("payload 缺少 commits 数组")?;
Ok(commits
.iter()
.filter_map(|c| {
let sha = c["id"].as_str()?.to_string();
let message = c["message"].as_str().unwrap_or("").to_string();
let timestamp = c["timestamp"].as_str().map(|s| s.to_string());
Some(PushCommit {
sha,
message,
timestamp,
})
})
.collect())
}
/// 从请求头提取签名:优先 X-Gitea-Signature,兼容 X-Hub-Signature-256
fn extract_signature(headers: &HeaderMap) -> Option<String> {
if let Some(v) = headers
.get("x-gitea-signature")
.and_then(|v| v.to_str().ok())
{
return Some(v.to_string());
}
headers
.get("x-hub-signature-256")
.and_then(|v| v.to_str().ok())
.map(|v| v.trim_start_matches("sha256=").to_string())
}
pub async fn handle_gitea_webhook(
Path(project_id): Path<String>,
headers: HeaderMap,
body: Bytes,
) -> Response {
let conn = match db::pool().get() {
Ok(conn) => conn,
Err(_) => {
return (
StatusCode::SERVICE_UNAVAILABLE,
Json(json!({"error": "webhook 状态 unavailable"})),
)
.into_response()
}
};
let secret = match webhook_secret_for_conn(
&conn,
&crate::credential_store::WindowsCredentialStore,
&project_id,
) {
Ok(secret) => secret,
Err(error) if error.contains("not_configured") || error.contains("不存在") => {
return (
StatusCode::NOT_FOUND,
Json(json!({"error": "项目未绑定 Gitea 仓库或未设置 webhook"})),
)
.into_response()
}
Err(_) => {
return (
StatusCode::SERVICE_UNAVAILABLE,
Json(json!({"error": "webhook 状态 needs_repair"})),
)
.into_response()
}
};
let sig = extract_signature(&headers);
let verified = sig
.as_deref()
.map(|s| verify_hmac_sha256(&secret, &body, s))
.unwrap_or(false);
if !verified {
log_event(
"gitea_webhook",
"warn",
&format!(
"项目 {project_id} webhook 验签失败(签名{})",
if sig.is_some() { "不匹配" } else { "缺失" }
),
None,
);
return (
StatusCode::UNAUTHORIZED,
Json(json!({"error": "签名验证失败"})),
)
.into_response();
}
let event = headers
.get("x-gitea-event")
.and_then(|v| v.to_str().ok())
.unwrap_or("");
match event {
"push" => handle_push(&project_id, &body).await,
"pull_request" => handle_pull_request(&project_id, &body).await,
other => Json(json!({"ignored": other})).into_response(),
}
}
fn webhook_secret_for_conn(
conn: &rusqlite::Connection,
store: &impl crate::credential_store::CredentialStore,
project_id: &str,
) -> Result<String, String> {
let bytes = crate::credential_store::get_or_migrate_legacy(
conn,
store,
crate::credential_store::LegacyCredentialKind::GiteaWebhookSecret,
project_id,
)?;
String::from_utf8(bytes).map_err(|_| "webhook 状态 needs_repair:凭证编码无效".to_string())
}
async fn handle_push(project_id: &str, body: &Bytes) -> Response {
let commits = match parse_push_payload(body) {
Ok(c) => c,
Err(e) => return (StatusCode::BAD_REQUEST, Json(json!({"error": e}))).into_response(),
};
let conn = match db::pool().get() {
Ok(c) => c,
Err(e) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": e.to_string()})),
)
.into_response()
}
};
// U3:按 project_id 找到本地路径加载规则 ID 集合,剔除 trailer 里的未知 ID
let valid_rules =
crate::commands::commit_metrics::load_rule_ids_by_project_id(&conn, project_id);
let mut received = 0;
for c in &commits {
if crate::commands::commit_metrics::record_commit_validated(
&conn,
project_id,
&c.sha,
&c.message,
c.timestamp.as_deref(),
valid_rules.as_ref(),
)
.is_ok()
{
received += 1;
}
}
if received > 0 {
db::touch_sync_state(&conn, "flywheel");
}
log_event(
"gitea_webhook",
"info",
&format!(
"项目 {project_id} push 事件已接收,写入 {received}/{} 条 commit",
commits.len()
),
None,
);
Json(json!({"received": received})).into_response()
}
async fn handle_pull_request(project_id: &str, body: &Bytes) -> Response {
let payload: serde_json::Value = match serde_json::from_slice(body) {
Ok(v) => v,
Err(e) => {
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": e.to_string()})),
)
.into_response()
}
};
let raw_action = payload["action"].as_str().unwrap_or("");
let pr = &payload["pull_request"];
let pr_number = pr["number"].as_i64().unwrap_or(0);
let action = match raw_action {
"opened" | "reopened" => "opened",
"closed" => {
if pr["merged"].as_bool().unwrap_or(false) {
"merged"
} else {
"closed"
}
}
_ => return Json(json!({"ignored": raw_action})).into_response(),
};
let title = pr["title"].as_str().unwrap_or("").to_string();
let head_branch = pr["head"]["label"].as_str().unwrap_or("").to_string();
let base_branch = pr["base"]["label"].as_str().unwrap_or("").to_string();
let author = pr["user"]["login"].as_str().unwrap_or("").to_string();
let id = uuid::Uuid::new_v4().to_string();
let conn = match db::pool().get() {
Ok(c) => c,
Err(e) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": e.to_string()})),
)
.into_response()
}
};
let result = conn.execute(
"INSERT INTO pr_events (id, project_id, pr_number, title, action, head_branch, base_branch, author)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
rusqlite::params![id, project_id, pr_number, title, action, head_branch, base_branch, author],
);
match result {
Ok(_) => {
db::touch_sync_state(&conn, "flywheel");
log_event(
"gitea_webhook",
"info",
&format!("项目 {project_id} PR #{pr_number} {action}"),
None,
);
Json(json!({"recorded": action, "pr_number": pr_number})).into_response()
}
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": e.to_string()})),
)
.into_response(),
}
}
// ── 测试 ─────────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
fn sign(secret: &str, body: &[u8]) -> String {
use hmac::{Hmac, Mac};
use sha2::Sha256;
let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes()).unwrap();
mac.update(body);
mac.finalize()
.into_bytes()
.iter()
.map(|b| format!("{b:02x}"))
.collect()
}
#[test]
fn hmac_verify_accepts_valid_signature() {
let body = br#"{"commits":[]}"#;
let sig = sign("my-secret", body);
assert!(verify_hmac_sha256("my-secret", body, &sig));
assert!(verify_hmac_sha256("my-secret", body, &sig.to_uppercase()));
}
#[test]
fn hmac_verify_rejects_wrong_secret_or_tampered_body() {
let body = br#"{"commits":[]}"#;
let sig = sign("my-secret", body);
assert!(!verify_hmac_sha256("other-secret", body, &sig));
assert!(!verify_hmac_sha256(
"my-secret",
br#"{"commits":[1]}"#,
&sig
));
assert!(!verify_hmac_sha256("my-secret", body, "deadbeef"));
}
#[test]
fn legacy_webhook_migration_keeps_signature_valid_and_failure_recoverable() {
use crate::credential_store::MemoryCredentialStore;
let conn = crate::db::conn_with_schema();
conn.execute("INSERT INTO gitea_instances (id, name, url, access_token) VALUES ('i', 'test', 'https://example.invalid', 'synthetic-token')", []).unwrap();
conn.execute("INSERT INTO gitea_repos (id, instance_id, project_id, gitea_owner, gitea_repo_name, webhook_secret) VALUES ('r', 'i', 'p', 'owner', 'repo', 'synthetic-webhook-secret')", []).unwrap();
let failing = MemoryCredentialStore::failing();
let error = webhook_secret_for_conn(&conn, &failing, "p").unwrap_err();
assert!(error.contains("needs_repair"));
assert!(!error.contains("synthetic-webhook-secret"));
let legacy: String = conn
.query_row(
"SELECT webhook_secret FROM gitea_repos WHERE project_id='p'",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(legacy, "synthetic-webhook-secret");
let store = MemoryCredentialStore::new();
let secret = webhook_secret_for_conn(&conn, &store, "p").unwrap();
let body = br#"{"commits":[]}"#;
assert!(verify_hmac_sha256(
&secret,
body,
&sign("synthetic-webhook-secret", body)
));
let (legacy, reference): (Option<String>, String) = conn
.query_row(
"SELECT webhook_secret, webhook_secret_ref FROM gitea_repos WHERE project_id='p'",
[],
|r| Ok((r.get(0)?, r.get(1)?)),
)
.unwrap();
assert!(legacy.is_none());
assert!(!reference.is_empty());
assert_eq!(webhook_secret_for_conn(&conn, &store, "p").unwrap(), secret);
}
#[test]
fn parse_push_payload_extracts_commits() {
let body = br#"{
"ref": "refs/heads/master",
"commits": [
{"id": "abc123", "message": "feat(core): add thing", "timestamp": "2026-07-02T10:00:00+08:00"},
{"id": "def456", "message": "fix(ui): repair", "timestamp": null}
]
}"#;
let commits = parse_push_payload(body).unwrap();
assert_eq!(commits.len(), 2);
assert_eq!(commits[0].sha, "abc123");
assert_eq!(commits[0].message, "feat(core): add thing");
assert_eq!(
commits[0].timestamp.as_deref(),
Some("2026-07-02T10:00:00+08:00")
);
assert_eq!(commits[1].timestamp, None);
}
#[test]
fn parse_push_payload_rejects_invalid() {
assert!(parse_push_payload(b"not json").is_err());
assert!(parse_push_payload(br#"{"no_commits": true}"#).is_err());
assert_eq!(parse_push_payload(br#"{"commits":[]}"#).unwrap().len(), 0);
}
}