iT邦幫忙

2025 iThome 鐵人賽

DAY 14
0
Rust

Rust 實戰專案集:30 個漸進式專案從工具到服務系列 第 14

Webhook 接收器 - 處理 GitHub/GitLab webhooks

  • 分享至 

  • xImage
  •  

前言

自動化是提升效率的關鍵
 --- by Michael Ho

當程式碼推送到 GitHub 或 GitLab 時,我們希望能夠自動觸發 CI/CD 流程、發送通知或執行其他自動化任務。
Webhook 就是實現這種自動化的重要機制。

今天我們將使用 Rust 建立一個 Webhook 接收器,能夠安全地處理來自 GitHub 和 GitLab 的 webhook 事件。

簡單介紹什麼是 webHook

Webhook 是一種 HTTP 回調機制,
當特定事件發生時(如程式碼推送、Pull Request 建立等),
服務提供者會向預設的 URL 發送 HTTP POST 請求,包含事件的詳細資訊。

專案目標

  • 建立一個 HTTP 服務器接收 webhook 請求
  • 驗證請求的真實性(簽名驗證)
  • 解析 GitHub 和 GitLab 的事件格式
  • 根據事件類型執行相應的處理邏輯
  • 提供日誌記錄和錯誤處理

一樣開始專案

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"] }

webhook 結構

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)
    }
}

建立 server

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(())
    }
}

main.rs

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"

以下是關於 webhook 的配置

github

  1. 進入 Repository Settings → Webhooks
  2. 添加新的 webhook
  3. Payload URL: http://your-server:3000/webhook/github
  4. Content type: application/json
  5. Secret: 設定與環境變數相同的密鑰
  6. 選擇要接收的事件類型

gitlab

  1. 進入 Project Settings → Webhooks
  2. 添加新的 webhook
  3. URL: http://your-server:3000/webhook/gitlab
  4. Secret Token: 設定與環境變數相同的 token
  5. 選擇要接收的觸發條件

我們這裡寫個測試驗證

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)
    }
}


上一篇
網站健康檢查器 - 監控多個網站的可用性
下一篇
圖片壓縮 API - 提供圖片壓縮和格式轉換服務
系列文
Rust 實戰專案集:30 個漸進式專案從工具到服務16
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言