Skip to main content

cac_webhook/
server.rs

1use crate::config::WebhookConfig;
2use crate::handler::{spawn_pr_job, WebhookHandler};
3use crate::payload::ProviderKind;
4use crate::signature::{verify_gitea_signature, verify_github_signature};
5use axum::{
6    body::Bytes,
7    extract::State,
8    http::{HeaderMap, StatusCode},
9    response::IntoResponse,
10    routing::{get, post},
11    Json, Router,
12};
13use serde_json::json;
14use std::sync::Arc;
15use tower_http::trace::TraceLayer;
16use tracing::warn;
17
18#[derive(Clone)]
19struct AppState {
20    handler: Arc<WebhookHandler>,
21    secret: String,
22}
23
24pub async fn run_server(config: WebhookConfig) -> Result<(), std::io::Error> {
25    let bind = config.bind_addr.clone();
26    let secret = config.webhook_secret.clone();
27    let handler = Arc::new(WebhookHandler::new(config));
28
29    let state = AppState { handler, secret };
30
31    let app = Router::new()
32        .route("/health", get(health))
33        .route("/webhook", post(webhook))
34        .route("/webhook/github", post(webhook_github))
35        .route("/webhook/gitea", post(webhook_gitea))
36        .with_state(state)
37        .layer(TraceLayer::new_for_http());
38
39    let listener = tokio::net::TcpListener::bind(&bind).await?;
40    tracing::info!(%bind, "CAC webhook server listening");
41    axum::serve(listener, app).await?;
42    Ok(())
43}
44
45async fn health() -> impl IntoResponse {
46    Json(json!({ "status": "ok", "service": "compliance-as-code-webhook" }))
47}
48
49async fn webhook(
50    State(state): State<AppState>,
51    headers: HeaderMap,
52    body: Bytes,
53) -> impl IntoResponse {
54    let provider = detect_provider(&headers);
55    handle_webhook(state, provider, headers, body).await
56}
57
58async fn webhook_github(
59    State(state): State<AppState>,
60    headers: HeaderMap,
61    body: Bytes,
62) -> impl IntoResponse {
63    handle_webhook(state, ProviderKind::GitHub, headers, body).await
64}
65
66async fn webhook_gitea(
67    State(state): State<AppState>,
68    headers: HeaderMap,
69    body: Bytes,
70) -> impl IntoResponse {
71    handle_webhook(state, ProviderKind::Gitea, headers, body).await
72}
73
74async fn handle_webhook(
75    state: AppState,
76    provider: ProviderKind,
77    headers: HeaderMap,
78    body: Bytes,
79) -> impl IntoResponse {
80    if !verify_request(&state.secret, provider, &headers, &body) {
81        warn!("webhook signature verification failed");
82        return (StatusCode::UNAUTHORIZED, Json(json!({ "error": "invalid signature" })));
83    }
84
85    let event = event_name(provider, &headers);
86    if event != "pull_request" {
87        return (
88            StatusCode::OK,
89            Json(json!({ "status": "ignored", "event": event })),
90        );
91    }
92
93    let ctx = match WebhookHandler::parse_event(provider, &body) {
94        Ok(Some(ctx)) => ctx,
95        Ok(None) => {
96            return (
97                StatusCode::OK,
98                Json(json!({ "status": "ignored", "reason": "unsupported action" })),
99            );
100        }
101        Err(err) => {
102            warn!(error = %err, "failed to parse webhook payload");
103            return (
104                StatusCode::BAD_REQUEST,
105                Json(json!({ "error": err.to_string() })),
106            );
107        }
108    };
109
110    spawn_pr_job(state.handler.clone(), ctx);
111    (
112        StatusCode::ACCEPTED,
113        Json(json!({ "status": "accepted", "message": "compliance scan queued" })),
114    )
115}
116
117fn detect_provider(headers: &HeaderMap) -> ProviderKind {
118    if headers.contains_key("x-github-event") {
119        ProviderKind::GitHub
120    } else {
121        ProviderKind::Gitea
122    }
123}
124
125fn event_name(provider: ProviderKind, headers: &HeaderMap) -> String {
126    match provider {
127        ProviderKind::GitHub => header_value(headers, "x-github-event"),
128        ProviderKind::Gitea => {
129            header_value(headers, "x-gitea-event").or(header_value(headers, "x-gitea-event-type"))
130        }
131    }
132    .unwrap_or_else(|| "unknown".into())
133}
134
135fn verify_request(
136    secret: &str,
137    provider: ProviderKind,
138    headers: &HeaderMap,
139    body: &[u8],
140) -> bool {
141    if secret.is_empty() || secret == "change-me" {
142        return true;
143    }
144    match provider {
145        ProviderKind::GitHub => headers
146            .get("x-hub-signature-256")
147            .and_then(|v| v.to_str().ok())
148            .map(|sig| verify_github_signature(secret, body, sig))
149            .unwrap_or(false),
150        ProviderKind::Gitea => headers
151            .get("x-gitea-signature")
152            .and_then(|v| v.to_str().ok())
153            .map(|sig| verify_gitea_signature(secret, body, sig))
154            .unwrap_or(false),
155    }
156}
157
158fn header_value(headers: &HeaderMap, name: &str) -> Option<String> {
159    headers
160        .get(name)
161        .and_then(|v| v.to_str().ok())
162        .map(str::to_string)
163}