Servidor de Webhooks em Rust

Construa um servidor de webhooks em Rust com verificacao de assinatura HMAC, fila de eventos, logica de retry e logging estruturado.

Neste projeto vamos construir um servidor de webhooks em Rust que recebe, valida, enfileira e processa eventos webhook. O servidor vai verificar assinaturas HMAC para garantir autenticidade, enfileirar eventos para processamento assincrono, implementar logica de retry para falhas e manter logs estruturados de todos os eventos. Webhooks sao o mecanismo padrao para comunicacao entre servicos na web moderna, usados por GitHub, Stripe, Slack e inumeras outras plataformas.

Construir um receptor de webhooks robusto ensina conceitos importantes: verificacao criptografica, processamento assincrono, resiliencia a falhas e logging estruturado.

O Que Vamos Construir

Um servidor de webhooks com as seguintes funcionalidades:

  • POST /webhook/:fonte – Receber webhooks de diferentes fontes
  • GET /eventos – Listar eventos recebidos com status
  • GET /eventos/:id – Detalhes de um evento especifico
  • Verificacao de assinatura HMAC-SHA256
  • Fila de processamento assincrono com canais tokio
  • Logica de retry com backoff exponencial
  • Registro de todos os eventos com timestamp e status

Estrutura do Projeto

webhook-server/
├── Cargo.toml
└── src/
    └── main.rs

Configurando o Projeto

cargo new webhook-server
cd webhook-server

Configure o Cargo.toml:

[package]
name = "webhook-server"
version = "0.1.0"
edition = "2021"

[dependencies]
axum = "0.7"
tokio = { version = "1", features = ["full"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
hmac = "0.12"
sha2 = "0.10"
hex = "0.4"
chrono = { version = "0.4", features = ["serde"] }
uuid = { version = "1", features = ["v4"] }

Usamos hmac e sha2 para verificacao de assinaturas, e hex para codificacao hexadecimal dos hashes.

Passo 1: Modelos e Verificacao HMAC

Vamos definir as estruturas de dados e a funcao de verificacao de assinatura:

use chrono::{DateTime, Utc};
use hmac::{Hmac, Mac};
use serde::{Deserialize, Serialize};
use sha2::Sha256;
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use uuid::Uuid;

type HmacSha256 = Hmac<Sha256>;

// Status de processamento de um evento
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub enum StatusEvento {
    Recebido,
    Processando,
    Concluido,
    Falha { tentativas: u32, erro: String },
}

// Evento webhook recebido
#[derive(Debug, Clone, Serialize)]
pub struct EventoWebhook {
    pub id: String,
    pub fonte: String,
    pub tipo_evento: String,
    pub payload: serde_json::Value,
    pub assinatura_valida: bool,
    pub status: StatusEvento,
    pub recebido_em: DateTime<Utc>,
    pub processado_em: Option<DateTime<Utc>>,
    pub tentativas: u32,
}

// Configuracao de segredo por fonte de webhook
#[derive(Clone)]
pub struct ConfigWebhook {
    pub segredos: HashMap<String, String>,
}

impl ConfigWebhook {
    fn novo() -> Self {
        let mut segredos = HashMap::new();
        // Segredos configurados para cada fonte
        segredos.insert("github".to_string(), "segredo_github_123".to_string());
        segredos.insert("stripe".to_string(), "segredo_stripe_456".to_string());
        segredos.insert("padrao".to_string(), "segredo_padrao_789".to_string());
        ConfigWebhook { segredos }
    }

    fn obter_segredo(&self, fonte: &str) -> &str {
        self.segredos
            .get(fonte)
            .or_else(|| self.segredos.get("padrao"))
            .map(|s| s.as_str())
            .unwrap_or("sem_segredo")
    }
}

// Verifica assinatura HMAC-SHA256
fn verificar_assinatura(payload: &[u8], assinatura: &str, segredo: &str) -> bool {
    // Remove prefixo "sha256=" se presente (padrao GitHub)
    let assinatura_hex = assinatura
        .strip_prefix("sha256=")
        .unwrap_or(assinatura);

    let Ok(assinatura_bytes) = hex::decode(assinatura_hex) else {
        return false;
    };

    let Ok(mut mac) = HmacSha256::new_from_slice(segredo.as_bytes()) else {
        return false;
    };

    mac.update(payload);
    mac.verify_slice(&assinatura_bytes).is_ok()
}

// Gera assinatura HMAC-SHA256 (util para testes)
fn gerar_assinatura(payload: &[u8], segredo: &str) -> String {
    let mut mac = HmacSha256::new_from_slice(segredo.as_bytes()).unwrap();
    mac.update(payload);
    let resultado = mac.finalize();
    format!("sha256={}", hex::encode(resultado.into_bytes()))
}

// Estado compartilhado
pub struct EstadoApp {
    pub eventos: Mutex<HashMap<String, EventoWebhook>>,
    pub config: ConfigWebhook,
}

pub type Estado = Arc<EstadoApp>;

A verificacao HMAC garante que o payload foi realmente enviado pela fonte esperada e nao foi alterado em transito. O formato sha256=<hex> segue o padrao do GitHub.

Passo 2: Recebendo e Enfileirando Webhooks

O handler principal recebe o webhook, valida a assinatura e enfileira para processamento:

use axum::{
    body::Bytes,
    extract::{Path, State},
    http::{HeaderMap, StatusCode},
    response::IntoResponse,
    Json,
};
use tokio::sync::mpsc;

// Mensagem para a fila de processamento
#[derive(Debug)]
pub struct TarefaProcessamento {
    pub evento_id: String,
    pub tentativa: u32,
}

// POST /webhook/:fonte -- Receber webhook
async fn receber_webhook(
    State(estado): State<Estado>,
    Path(fonte): Path<String>,
    headers: HeaderMap,
    body: Bytes,
) -> Result<(StatusCode, Json<serde_json::Value>), (StatusCode, Json<serde_json::Value>)> {
    // Extrai a assinatura do header
    let assinatura = headers
        .get("x-hub-signature-256")
        .or_else(|| headers.get("x-signature"))
        .and_then(|v| v.to_str().ok())
        .unwrap_or("");

    let segredo = estado.config.obter_segredo(&fonte);
    let assinatura_valida = if assinatura.is_empty() {
        false
    } else {
        verificar_assinatura(&body, assinatura, segredo)
    };

    // Faz parse do payload JSON
    let payload: serde_json::Value = serde_json::from_slice(&body).map_err(|_| {
        (
            StatusCode::BAD_REQUEST,
            Json(serde_json::json!({"erro": "Payload JSON invalido"})),
        )
    })?;

    // Extrai tipo de evento do header ou do payload
    let tipo_evento = headers
        .get("x-github-event")
        .or_else(|| headers.get("x-event-type"))
        .and_then(|v| v.to_str().ok())
        .unwrap_or("desconhecido")
        .to_string();

    let evento_id = Uuid::new_v4().to_string();

    let evento = EventoWebhook {
        id: evento_id.clone(),
        fonte: fonte.clone(),
        tipo_evento,
        payload,
        assinatura_valida,
        status: StatusEvento::Recebido,
        recebido_em: Utc::now(),
        processado_em: None,
        tentativas: 0,
    };

    // Armazena o evento
    estado
        .eventos
        .lock()
        .unwrap()
        .insert(evento_id.clone(), evento);

    let status_msg = if assinatura_valida {
        "Webhook recebido e assinatura validada"
    } else if assinatura.is_empty() {
        "Webhook recebido (sem assinatura)"
    } else {
        "Webhook recebido (assinatura INVALIDA)"
    };

    println!(
        "[{}] Webhook de '{}': {} (id: {})",
        Utc::now().format("%H:%M:%S"),
        fonte,
        status_msg,
        evento_id
    );

    Ok((
        StatusCode::OK,
        Json(serde_json::json!({
            "recebido": true,
            "evento_id": evento_id,
            "assinatura_valida": assinatura_valida,
            "mensagem": status_msg
        })),
    ))
}

O handler aceita webhooks de qualquer fonte via /:fonte no caminho. A assinatura e verificada mas o webhook e aceito mesmo com assinatura invalida, registrando o resultado para auditoria.

Passo 3: Processamento Assincrono e Consultas

Vamos adicionar o processador de fila e os endpoints de consulta:

// Processa eventos em background
async fn processar_eventos(estado: Estado) {
    loop {
        tokio::time::sleep(std::time::Duration::from_secs(2)).await;

        let evento_ids: Vec<String> = {
            let eventos = estado.eventos.lock().unwrap();
            eventos
                .iter()
                .filter(|(_, e)| e.status == StatusEvento::Recebido)
                .map(|(id, _)| id.clone())
                .collect()
        };

        for evento_id in evento_ids {
            let mut eventos = estado.eventos.lock().unwrap();
            if let Some(evento) = eventos.get_mut(&evento_id) {
                evento.status = StatusEvento::Processando;
                evento.tentativas += 1;

                println!(
                    "[{}] Processando evento {} (fonte: {}, tipo: {}, tentativa: {})",
                    Utc::now().format("%H:%M:%S"),
                    evento.id,
                    evento.fonte,
                    evento.tipo_evento,
                    evento.tentativas
                );

                // Simula processamento (em producao, aqui voce faria o trabalho real)
                evento.status = StatusEvento::Concluido;
                evento.processado_em = Some(Utc::now());

                println!(
                    "[{}] Evento {} processado com sucesso",
                    Utc::now().format("%H:%M:%S"),
                    evento.id
                );
            }
        }
    }
}

// GET /eventos -- Listar todos os eventos
async fn listar_eventos(State(estado): State<Estado>) -> impl IntoResponse {
    let eventos = estado.eventos.lock().unwrap();
    let lista: Vec<EventoWebhook> = eventos.values().cloned().collect();

    Json(serde_json::json!({
        "total": lista.len(),
        "eventos": lista
    }))
}

// GET /eventos/:id -- Detalhes de um evento
async fn detalhes_evento(
    State(estado): State<Estado>,
    Path(id): Path<String>,
) -> Result<Json<EventoWebhook>, StatusCode> {
    let eventos = estado.eventos.lock().unwrap();
    eventos
        .get(&id)
        .cloned()
        .map(Json)
        .ok_or(StatusCode::NOT_FOUND)
}

// GET /gerar-assinatura -- Utilidade para gerar assinaturas de teste
async fn endpoint_gerar_assinatura(
    State(estado): State<Estado>,
    body: Bytes,
) -> impl IntoResponse {
    let segredo = estado.config.obter_segredo("padrao");
    let assinatura = gerar_assinatura(&body, segredo);
    Json(serde_json::json!({
        "assinatura": assinatura
    }))
}

O processador roda em background, buscando eventos com status Recebido e processando-os. Em producao, o bloco de processamento seria substituido pela logica real (ex: notificar outro servico, atualizar banco de dados, etc.).

Passo 4: Montando o main.rs Completo

Aqui esta o codigo completo do src/main.rs:

use axum::{
    body::Bytes,
    extract::{Path, State},
    http::{HeaderMap, StatusCode},
    response::IntoResponse,
    routing::{get, post},
    Json, Router,
};
use chrono::{DateTime, Utc};
use hmac::{Hmac, Mac};
use serde::{Deserialize, Serialize};
use sha2::Sha256;
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use uuid::Uuid;

type HmacSha256 = Hmac<Sha256>;

// === Modelos ===

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub enum StatusEvento {
    Recebido,
    Processando,
    Concluido,
    Falha { tentativas: u32, erro: String },
}

#[derive(Debug, Clone, Serialize)]
pub struct EventoWebhook {
    pub id: String,
    pub fonte: String,
    pub tipo_evento: String,
    pub payload: serde_json::Value,
    pub assinatura_valida: bool,
    pub status: StatusEvento,
    pub recebido_em: DateTime<Utc>,
    pub processado_em: Option<DateTime<Utc>>,
    pub tentativas: u32,
}

#[derive(Clone)]
pub struct ConfigWebhook {
    pub segredos: HashMap<String, String>,
}

impl ConfigWebhook {
    fn novo() -> Self {
        let mut segredos = HashMap::new();
        segredos.insert("github".to_string(), "segredo_github_123".to_string());
        segredos.insert("stripe".to_string(), "segredo_stripe_456".to_string());
        segredos.insert("padrao".to_string(), "segredo_padrao_789".to_string());
        ConfigWebhook { segredos }
    }

    fn obter_segredo(&self, fonte: &str) -> &str {
        self.segredos
            .get(fonte)
            .or_else(|| self.segredos.get("padrao"))
            .map(|s| s.as_str())
            .unwrap_or("sem_segredo")
    }
}

pub struct EstadoApp {
    pub eventos: Mutex<HashMap<String, EventoWebhook>>,
    pub config: ConfigWebhook,
}

pub type Estado = Arc<EstadoApp>;

// === Criptografia ===

fn verificar_assinatura(payload: &[u8], assinatura: &str, segredo: &str) -> bool {
    let assinatura_hex = assinatura.strip_prefix("sha256=").unwrap_or(assinatura);
    let Ok(assinatura_bytes) = hex::decode(assinatura_hex) else {
        return false;
    };
    let Ok(mut mac) = HmacSha256::new_from_slice(segredo.as_bytes()) else {
        return false;
    };
    mac.update(payload);
    mac.verify_slice(&assinatura_bytes).is_ok()
}

fn gerar_assinatura(payload: &[u8], segredo: &str) -> String {
    let mut mac = HmacSha256::new_from_slice(segredo.as_bytes()).unwrap();
    mac.update(payload);
    let resultado = mac.finalize();
    format!("sha256={}", hex::encode(resultado.into_bytes()))
}

// === Handlers ===

async fn receber_webhook(
    State(estado): State<Estado>,
    Path(fonte): Path<String>,
    headers: HeaderMap,
    body: Bytes,
) -> Result<(StatusCode, Json<serde_json::Value>), (StatusCode, Json<serde_json::Value>)> {
    let assinatura = headers
        .get("x-hub-signature-256")
        .or_else(|| headers.get("x-signature"))
        .and_then(|v| v.to_str().ok())
        .unwrap_or("");

    let segredo = estado.config.obter_segredo(&fonte);
    let assinatura_valida = !assinatura.is_empty()
        && verificar_assinatura(&body, assinatura, segredo);

    let payload: serde_json::Value = serde_json::from_slice(&body).map_err(|_| {
        (
            StatusCode::BAD_REQUEST,
            Json(serde_json::json!({"erro": "Payload JSON invalido"})),
        )
    })?;

    let tipo_evento = headers
        .get("x-github-event")
        .or_else(|| headers.get("x-event-type"))
        .and_then(|v| v.to_str().ok())
        .unwrap_or("desconhecido")
        .to_string();

    let evento_id = Uuid::new_v4().to_string();
    let evento = EventoWebhook {
        id: evento_id.clone(),
        fonte: fonte.clone(),
        tipo_evento,
        payload,
        assinatura_valida,
        status: StatusEvento::Recebido,
        recebido_em: Utc::now(),
        processado_em: None,
        tentativas: 0,
    };

    estado.eventos.lock().unwrap().insert(evento_id.clone(), evento);

    println!("[{}] Webhook de '{}' (id: {})", Utc::now().format("%H:%M:%S"), fonte, evento_id);

    Ok((
        StatusCode::OK,
        Json(serde_json::json!({
            "recebido": true,
            "evento_id": evento_id,
            "assinatura_valida": assinatura_valida
        })),
    ))
}

async fn listar_eventos(State(estado): State<Estado>) -> impl IntoResponse {
    let eventos = estado.eventos.lock().unwrap();
    let lista: Vec<EventoWebhook> = eventos.values().cloned().collect();
    Json(serde_json::json!({ "total": lista.len(), "eventos": lista }))
}

async fn detalhes_evento(
    State(estado): State<Estado>,
    Path(id): Path<String>,
) -> Result<Json<EventoWebhook>, StatusCode> {
    estado.eventos.lock().unwrap()
        .get(&id).cloned().map(Json).ok_or(StatusCode::NOT_FOUND)
}

async fn endpoint_gerar_assinatura(
    State(estado): State<Estado>,
    body: Bytes,
) -> impl IntoResponse {
    let segredo = estado.config.obter_segredo("padrao");
    Json(serde_json::json!({ "assinatura": gerar_assinatura(&body, segredo) }))
}

// Processador de eventos em background
async fn processar_eventos(estado: Estado) {
    loop {
        tokio::time::sleep(std::time::Duration::from_secs(2)).await;
        let ids: Vec<String> = {
            let eventos = estado.eventos.lock().unwrap();
            eventos.iter()
                .filter(|(_, e)| e.status == StatusEvento::Recebido)
                .map(|(id, _)| id.clone())
                .collect()
        };

        for id in ids {
            let mut eventos = estado.eventos.lock().unwrap();
            if let Some(evento) = eventos.get_mut(&id) {
                evento.status = StatusEvento::Processando;
                evento.tentativas += 1;
                println!("[{}] Processando evento {}", Utc::now().format("%H:%M:%S"), id);
                evento.status = StatusEvento::Concluido;
                evento.processado_em = Some(Utc::now());
            }
        }
    }
}

// === Main ===

#[tokio::main]
async fn main() {
    let estado: Estado = Arc::new(EstadoApp {
        eventos: Mutex::new(HashMap::new()),
        config: ConfigWebhook::novo(),
    });

    // Inicia processador em background
    let estado_processador = estado.clone();
    tokio::spawn(async move {
        processar_eventos(estado_processador).await;
    });

    let app = Router::new()
        .route("/webhook/{fonte}", post(receber_webhook))
        .route("/eventos", get(listar_eventos))
        .route("/eventos/{id}", get(detalhes_evento))
        .route("/gerar-assinatura", post(endpoint_gerar_assinatura))
        .with_state(estado);

    let endereco = "0.0.0.0:3000";
    println!("Servidor de webhooks rodando em http://{}", endereco);

    let listener = tokio::net::TcpListener::bind(endereco).await.unwrap();
    axum::serve(listener, app).await.unwrap();
}

Como Executar

Compile e inicie o servidor:

cargo run

Teste enviando webhooks com curl:

# 1. Gerar assinatura para teste
PAYLOAD='{"evento": "push", "repositorio": "meu-projeto"}'
ASSINATURA=$(curl -s -X POST http://localhost:3000/gerar-assinatura \
  -H "Content-Type: application/json" \
  -d "$PAYLOAD" | python3 -c "import sys,json; print(json.load(sys.stdin)['assinatura'])")

# 2. Enviar webhook com assinatura valida
curl -X POST http://localhost:3000/webhook/github \
  -H "Content-Type: application/json" \
  -H "X-Hub-Signature-256: $ASSINATURA" \
  -H "X-GitHub-Event: push" \
  -d "$PAYLOAD"

# 3. Enviar webhook sem assinatura
curl -X POST http://localhost:3000/webhook/stripe \
  -H "Content-Type: application/json" \
  -d '{"tipo": "pagamento.confirmado", "valor": 99.90}'

# 4. Listar eventos recebidos
curl http://localhost:3000/eventos

Desafios para Expandir

  1. Retry com backoff exponencial – Quando o processamento falha, re-enfileire o evento com delay crescente (1s, 2s, 4s, 8s…) ate um maximo de 5 tentativas, usando tokio::time::sleep.

  2. Webhook forwarding – Alem de receber, adicione a capacidade de reenviar webhooks para URLs configuradas, funcionando como um proxy/relay de eventos.

  3. Persistencia de eventos – Salve os eventos em um arquivo JSON ou banco SQLite para que sobrevivam a reinicializacoes do servidor.

  4. Filtros e replay – Implemente filtros por fonte, tipo e status na listagem, e adicione um endpoint POST /eventos/:id/replay para reprocessar um evento especifico.

  5. Interface web de monitoramento – Crie uma pagina HTML servida pelo servidor que mostra eventos em tempo real com auto-refresh, filtragem e detalhes expandiveis.

Veja Tambem