自動化是提升效率的關鍵
--- by Michael Ho
當程式碼推送到 GitHub 或 GitLab 時,我們希望能夠自動觸發 CI/CD 流程、發送通知或執行其他自動化任務。
Webhook 就是實現這種自動化的重要機制。
今天我們將使用 Rust 建立一個 Webhook 接收器,能夠安全地處理來自 GitHub 和 GitLab 的 webhook 事件。
Webhook 是一種 HTTP 回調機制,
當特定事件發生時(如程式碼推送、Pull Request 建立等),
服務提供者會向預設的 URL 發送 HTTP POST 請求,包含事件的詳細資訊。
cargo new webhook_receiver
cd webhook_receiver
[package]
name = "webhook_receiver"
version = "0.1.0"
edition = "2021"
[dependencies]
tokio = { version = "1.0", features = ["full"] }
axum = "0.7"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
hmac = "0.12"
sha2 = "0.10"
hex = "0.4"
tracing = "0.1"
tracing-subscriber = "0.3"
anyhow = "1.0"
tower = "0.4"
tower-http = { version = "0.5", features = ["trace"] }
clap = { version = "4.0", features = ["derive"] }
src/webhook.rs
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct WebhookEvent {
pub provider: WebhookProvider,
pub event_type: String,
pub repository: Repository,
pub payload: serde_json::Value,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub enum WebhookProvider {
GitHub,
GitLab,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct Repository {
pub name: String,
pub full_name: String,
pub clone_url: String,
pub default_branch: String,
}
// GitHub 事件結構
#[derive(Debug, Deserialize)]
pub struct GitHubPushEvent {
#[serde(rename = "ref")]
pub git_ref: String,
pub commits: Vec<GitHubCommit>,
pub repository: GitHubRepository,
pub pusher: GitHubUser,
}
#[derive(Debug, Deserialize)]
pub struct GitHubCommit {
pub id: String,
pub message: String,
pub author: GitHubUser,
pub added: Vec<String>,
pub removed: Vec<String>,
pub modified: Vec<String>,
}
#[derive(Debug, Deserialize)]
pub struct GitHubRepository {
pub name: String,
pub full_name: String,
pub clone_url: String,
pub default_branch: String,
}
#[derive(Debug, Deserialize)]
pub struct GitHubUser {
pub name: String,
pub email: String,
}
// GitLab 事件結構
#[derive(Debug, Deserialize)]
pub struct GitLabPushEvent {
pub object_kind: String,
#[serde(rename = "ref")]
pub git_ref: String,
pub commits: Vec<GitLabCommit>,
pub repository: GitLabRepository,
pub user_name: String,
pub user_email: String,
}
#[derive(Debug, Deserialize)]
pub struct GitLabCommit {
pub id: String,
pub message: String,
pub author: GitLabAuthor,
pub added: Vec<String>,
pub removed: Vec<String>,
pub modified: Vec<String>,
}
#[derive(Debug, Deserialize)]
pub struct GitLabRepository {
pub name: String,
pub homepage: String,
pub git_ssh_url: String,
}
#[derive(Debug, Deserialize)]
pub struct GitLabAuthor {
pub name: String,
pub email: String,
}
src/auth.rs
use hmac::{Hmac, Mac};
use sha2::Sha256;
use anyhow::{Result, anyhow};
type HmacSha256 = Hmac<Sha256>;
pub struct WebhookAuth {
github_secret: Option<String>,
gitlab_token: Option<String>,
}
impl WebhookAuth {
pub fn new(github_secret: Option<String>, gitlab_token: Option<String>) -> Self {
Self {
github_secret,
gitlab_token,
}
}
pub fn verify_github_signature(&self, payload: &[u8], signature: &str) -> Result<bool> {
let secret = self.github_secret.as_ref()
.ok_or_else(|| anyhow!("GitHub secret not configured"))?;
// GitHub 簽名格式: "sha256=<hex>"
let signature = signature.strip_prefix("sha256=")
.ok_or_else(|| anyhow!("Invalid GitHub signature format"))?;
let expected_signature = hex::decode(signature)
.map_err(|_| anyhow!("Invalid hex in signature"))?;
let mut mac = HmacSha256::new_from_slice(secret.as_bytes())
.map_err(|_| anyhow!("Invalid secret key"))?;
mac.update(payload);
match mac.verify_slice(&expected_signature) {
Ok(()) => Ok(true),
Err(_) => Ok(false),
}
}
pub fn verify_gitlab_token(&self, token: &str) -> Result<bool> {
let expected_token = self.gitlab_token.as_ref()
.ok_or_else(|| anyhow!("GitLab token not configured"))?;
Ok(token == expected_token)
}
}
src/server.rs
use axum::{
extract::{State, Query},
http::{StatusCode, HeaderMap},
response::Json,
routing::post,
Router,
body::Bytes,
};
use serde_json::Value;
use std::collections::HashMap;
use std::sync::Arc;
use tracing::{info, warn, error};
use anyhow::Result;
use crate::auth::WebhookAuth;
use crate::webhook::*;
use crate::handler::WebhookHandler;
#[derive(Clone)]
pub struct AppState {
pub auth: Arc<WebhookAuth>,
pub handler: Arc<WebhookHandler>,
}
pub fn create_router(state: AppState) -> Router {
Router::new()
.route("/webhook/github", post(handle_github_webhook))
.route("/webhook/gitlab", post(handle_gitlab_webhook))
.route("/health", axum::routing::get(health_check))
.with_state(state)
}
async fn handle_github_webhook(
State(state): State<AppState>,
headers: HeaderMap,
body: Bytes,
) -> Result<Json<Value>, StatusCode> {
info!("Received GitHub webhook");
// 檢查 Content-Type
let content_type = headers.get("content-type")
.and_then(|v| v.to_str().ok())
.unwrap_or("");
if !content_type.contains("application/json") {
warn!("Invalid content type: {}", content_type);
return Err(StatusCode::BAD_REQUEST);
}
// 驗證簽名
if let Some(signature) = headers.get("x-hub-signature-256")
.and_then(|v| v.to_str().ok()) {
match state.auth.verify_github_signature(&body, signature) {
Ok(true) => info!("GitHub signature verified"),
Ok(false) => {
warn!("GitHub signature verification failed");
return Err(StatusCode::UNAUTHORIZED);
}
Err(e) => {
error!("Error verifying GitHub signature: {}", e);
return Err(StatusCode::INTERNAL_SERVER_ERROR);
}
}
} else {
warn!("Missing GitHub signature header");
return Err(StatusCode::BAD_REQUEST);
}
// 解析事件類型
let event_type = headers.get("x-github-event")
.and_then(|v| v.to_str().ok())
.unwrap_or("unknown");
// 解析 payload
let payload: Value = match serde_json::from_slice(&body) {
Ok(p) => p,
Err(e) => {
error!("Failed to parse JSON payload: {}", e);
return Err(StatusCode::BAD_REQUEST);
}
};
// 處理事件
match process_github_event(event_type, payload, &state.handler).await {
Ok(response) => Ok(Json(response)),
Err(e) => {
error!("Error processing GitHub event: {}", e);
Err(StatusCode::INTERNAL_SERVER_ERROR)
}
}
}
async fn handle_gitlab_webhook(
State(state): State<AppState>,
headers: HeaderMap,
body: Bytes,
) -> Result<Json<Value>, StatusCode> {
info!("Received GitLab webhook");
// 驗證 token
if let Some(token) = headers.get("x-gitlab-token")
.and_then(|v| v.to_str().ok()) {
match state.auth.verify_gitlab_token(token) {
Ok(true) => info!("GitLab token verified"),
Ok(false) => {
warn!("GitLab token verification failed");
return Err(StatusCode::UNAUTHORIZED);
}
Err(e) => {
error!("Error verifying GitLab token: {}", e);
return Err(StatusCode::INTERNAL_SERVER_ERROR);
}
}
} else {
warn!("Missing GitLab token header");
return Err(StatusCode::BAD_REQUEST);
}
// 獲取事件類型
let event_type = headers.get("x-gitlab-event")
.and_then(|v| v.to_str().ok())
.unwrap_or("unknown");
// 解析 payload
let payload: Value = match serde_json::from_slice(&body) {
Ok(p) => p,
Err(e) => {
error!("Failed to parse JSON payload: {}", e);
return Err(StatusCode::BAD_REQUEST);
}
};
// 處理事件
match process_gitlab_event(event_type, payload, &state.handler).await {
Ok(response) => Ok(Json(response)),
Err(e) => {
error!("Error processing GitLab event: {}", e);
Err(StatusCode::INTERNAL_SERVER_ERROR)
}
}
}
async fn health_check() -> Json<Value> {
Json(serde_json::json!({
"status": "healthy",
"timestamp": chrono::Utc::now().to_rfc3339()
}))
}
async fn process_github_event(
event_type: &str,
payload: Value,
handler: &WebhookHandler,
) -> Result<Value> {
match event_type {
"push" => {
let push_event: GitHubPushEvent = serde_json::from_value(payload.clone())?;
let webhook_event = WebhookEvent {
provider: WebhookProvider::GitHub,
event_type: event_type.to_string(),
repository: Repository {
name: push_event.repository.name.clone(),
full_name: push_event.repository.full_name.clone(),
clone_url: push_event.repository.clone_url.clone(),
default_branch: push_event.repository.default_branch.clone(),
},
payload,
};
handler.handle_push_event(&webhook_event, &push_event).await
}
"pull_request" => {
handler.handle_pull_request_event(&payload).await
}
"issues" => {
handler.handle_issues_event(&payload).await
}
_ => {
info!("Received unhandled GitHub event: {}", event_type);
Ok(serde_json::json!({"message": "Event received but not handled"}))
}
}
}
async fn process_gitlab_event(
event_type: &str,
payload: Value,
handler: &WebhookHandler,
) -> Result<Value> {
match event_type {
"Push Hook" => {
let push_event: GitLabPushEvent = serde_json::from_value(payload.clone())?;
let webhook_event = WebhookEvent {
provider: WebhookProvider::GitLab,
event_type: event_type.to_string(),
repository: Repository {
name: push_event.repository.name.clone(),
full_name: push_event.repository.name.clone(),
clone_url: push_event.repository.git_ssh_url.clone(),
default_branch: "main".to_string(), // GitLab API 可能不包含此資訊
},
payload,
};
handler.handle_gitlab_push_event(&webhook_event, &push_event).await
}
"Merge Request Hook" => {
handler.handle_merge_request_event(&payload).await
}
_ => {
info!("Received unhandled GitLab event: {}", event_type);
Ok(serde_json::json!({"message": "Event received but not handled"}))
}
}
}
handler ::
src/handler.rs
use anyhow::Result;
use serde_json::Value;
use tracing::{info, debug};
use crate::webhook::*;
pub struct WebhookHandler {
// 這裡可以添加配置選項,如通知設定、處理器配置等
}
impl WebhookHandler {
pub fn new() -> Self {
Self {}
}
pub async fn handle_push_event(
&self,
event: &WebhookEvent,
push_event: &GitHubPushEvent,
) -> Result<Value> {
info!("Processing push event for repository: {}", event.repository.full_name);
let branch = push_event.git_ref.strip_prefix("refs/heads/").unwrap_or(&push_event.git_ref);
info!("Push to branch: {}", branch);
info!("Number of commits: {}", push_event.commits.len());
// 處理每個 commit
for commit in &push_event.commits {
debug!("Commit {}: {}", &commit.id[..7], commit.message);
debug!(" Files added: {}", commit.added.len());
debug!(" Files modified: {}", commit.modified.len());
debug!(" Files removed: {}", commit.removed.len());
}
// 這裡可以添加具體的處理邏輯,例如:
// - 觸發 CI/CD 流程
// - 發送通知
// - 更新資料庫
// - 同步到其他系統
if branch == "main" || branch == "master" {
self.handle_main_branch_push(event, push_event).await?;
}
Ok(serde_json::json!({
"message": "Push event processed successfully",
"repository": event.repository.full_name,
"branch": branch,
"commits": push_event.commits.len()
}))
}
pub async fn handle_gitlab_push_event(
&self,
event: &WebhookEvent,
push_event: &GitLabPushEvent,
) -> Result<Value> {
info!("Processing GitLab push event for repository: {}", event.repository.full_name);
let branch = push_event.git_ref.strip_prefix("refs/heads/").unwrap_or(&push_event.git_ref);
info!("Push to branch: {} by {}", branch, push_event.user_name);
info!("Number of commits: {}", push_event.commits.len());
// 處理每個 commit
for commit in &push_event.commits {
debug!("Commit {}: {}", &commit.id[..7], commit.message);
}
Ok(serde_json::json!({
"message": "GitLab push event processed successfully",
"repository": event.repository.full_name,
"branch": branch,
"commits": push_event.commits.len()
}))
}
pub async fn handle_pull_request_event(&self, payload: &Value) -> Result<Value> {
let action = payload.get("action")
.and_then(|v| v.as_str())
.unwrap_or("unknown");
info!("Processing pull request event: {}", action);
match action {
"opened" => {
info!("New pull request opened");
// 處理新的 Pull Request
}
"closed" => {
let merged = payload.get("pull_request")
.and_then(|pr| pr.get("merged"))
.and_then(|v| v.as_bool())
.unwrap_or(false);
if merged {
info!("Pull request merged");
// 處理 PR 合併
} else {
info!("Pull request closed without merging");
}
}
"synchronize" => {
info!("Pull request updated");
// 處理 PR 更新
}
_ => {
debug!("Unhandled pull request action: {}", action);
}
}
Ok(serde_json::json!({
"message": "Pull request event processed",
"action": action
}))
}
pub async fn handle_issues_event(&self, payload: &Value) -> Result<Value> {
let action = payload.get("action")
.and_then(|v| v.as_str())
.unwrap_or("unknown");
info!("Processing issues event: {}", action);
Ok(serde_json::json!({
"message": "Issues event processed",
"action": action
}))
}
pub async fn handle_merge_request_event(&self, payload: &Value) -> Result<Value> {
let object_attributes = payload.get("object_attributes");
let action = object_attributes
.and_then(|oa| oa.get("action"))
.and_then(|v| v.as_str())
.unwrap_or("unknown");
info!("Processing merge request event: {}", action);
Ok(serde_json::json!({
"message": "Merge request event processed",
"action": action
}))
}
async fn handle_main_branch_push(
&self,
event: &WebhookEvent,
push_event: &GitHubPushEvent,
) -> Result<()> {
info!("Handling main branch push for {}", event.repository.full_name);
// 這裡可以添加主分支推送的特殊處理邏輯
// 例如:自動部署、運行測試、發送重要通知等
Ok(())
}
}
mod auth;
mod handler;
mod server;
mod webhook;
use anyhow::Result;
use clap::Parser;
use std::net::SocketAddr;
use std::sync::Arc;
use tokio::net::TcpListener;
use tower_http::trace::TraceLayer;
use tracing::{info, Level};
use crate::auth::WebhookAuth;
use crate::handler::WebhookHandler;
use crate::server::{create_router, AppState};
#[derive(Parser, Debug)]
#[command(author, version, about, long_about = None)]
struct Args {
/// Server listening port
#[arg(short, long, default_value = "3000")]
port: u16,
/// Server listening host
#[arg(long, default_value = "0.0.0.0")]
host: String,
/// GitHub webhook secret
#[arg(long, env = "GITHUB_WEBHOOK_SECRET")]
github_secret: Option<String>,
/// GitLab webhook token
#[arg(long, env = "GITLAB_WEBHOOK_TOKEN")]
gitlab_token: Option<String>,
/// Log level
#[arg(long, default_value = "info")]
log_level: String,
}
#[tokio::main]
async fn main() -> Result<()> {
let args = Args::parse();
// 設定日誌
let log_level = match args.log_level.to_lowercase().as_str() {
"trace" => Level::TRACE,
"debug" => Level::DEBUG,
"info" => Level::INFO,
"warn" => Level::WARN,
"error" => Level::ERROR,
_ => Level::INFO,
};
tracing_subscriber::fmt()
.with_max_level(log_level)
.init();
info!("Starting webhook receiver server");
// 檢查配置
if args.github_secret.is_none() && args.gitlab_token.is_none() {
eprintln!("Warning: No GitHub secret or GitLab token configured. All requests will be rejected.");
}
// 建立應用狀態
let auth = Arc::new(WebhookAuth::new(args.github_secret, args.gitlab_token));
let handler = Arc::new(WebhookHandler::new());
let state = AppState { auth, handler };
// 建立路由
let app = create_router(state)
.layer(TraceLayer::new_for_http());
// 啟動服務器
let addr: SocketAddr = format!("{}:{}", args.host, args.port).parse()?;
let listener = TcpListener::bind(addr).await?;
info!("Server listening on {}", addr);
info!("Health check available at: http://{}/health", addr);
info!("GitHub webhook endpoint: http://{}/webhook/github", addr);
info!("GitLab webhook endpoint: http://{}/webhook/gitlab", addr);
axum::serve(listener, app).await?;
Ok(())
}
這裡我蠻先用環境變數搞出自己的 github, gitlab secret
export GITHUB_WEBHOOK_SECRET="your_github_secret"
export GITLAB_WEBHOOK_TOKEN="your_gitlab_token"
github
gitlab
tests/integration_tests.rs
#[cfg(test)]
mod tests {
use super::*;
use axum_test::TestServer;
use serde_json::json;
#[tokio::test]
async fn test_health_endpoint() {
let app = create_test_app().await;
let server = TestServer::new(app).unwrap();
let response = server.get("/health").await;
assert_eq!(response.status_code(), 200);
}
#[tokio::test]
async fn test_github_webhook_without_signature() {
let app = create_test_app().await;
let server = TestServer::new(app).unwrap();
let payload = json!({
"ref": "refs/heads/main",
"commits": [],
"repository": {
"name": "test-repo",
"full_name": "user/test-repo",
"clone_url": "https://github.com/user/test-repo.git",
"default_branch": "main"
}
});
let response = server
.post("/webhook/github")
.json(&payload)
.await;
assert_eq!(response.status_code(), 400);
}
async fn create_test_app() -> axum::Router {
let auth = Arc::new(WebhookAuth::new(
Some("test_secret".to_string()),
Some("test_token".to_string()),
));
let handler = Arc::new(WebhookHandler::new());
let state = AppState { auth, handler };
create_router(state)
}
}