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
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.Webhook forwarding – Alem de receber, adicione a capacidade de reenviar webhooks para URLs configuradas, funcionando como um proxy/relay de eventos.
Persistencia de eventos – Salve os eventos em um arquivo JSON ou banco SQLite para que sobrevivam a reinicializacoes do servidor.
Filtros e replay – Implemente filtros por fonte, tipo e status na listagem, e adicione um endpoint
POST /eventos/:id/replaypara reprocessar um evento especifico.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
- HashMap na Biblioteca Padrao – Armazenamento de eventos e configuracoes
- Channels para Comunicacao – Conceitos de filas e canais
- Gerando Hashes em Rust – Fundamentos de hashing e HMAC
- Serializando JSON em Rust – Trabalho com JSON e serde
- Rust para DevOps – Ferramentas DevOps em Rust