Implémenter des webhooks dans gitrust
Ce guide couvre l’ajout d’un nouveau déclencheur webhook : modèle de données, service de dispatch, signature HMAC, configuration UI par dépôt, et rotation de secret.
Pré-requis
- Lecture de
ajouter-service-metier.mdetajouter-migration-db.md. - Compréhension du patron vertical slice (voir
tutorials/01-getting-started.md).
Vue d’ensemble
sequenceDiagram
participant Push as git push
participant Hook as receive-pack handler
participant Svc as WebhookService
participant Disp as WebhookDispatcher
participant Ext as Endpoint HTTP distant
Push->>Hook: réception des objets
Hook->>Svc: list_by_repo_and_event(repo_id, "push")
Svc-->>Hook: Vec<Webhook>
Hook->>Disp: deliver(webhook, "push", payload)
Disp->>Ext: POST url (payload signé HMAC-SHA256)
Ext-->>Disp: 200 OK
Disp->>Svc: record_delivery(webhook_id, status, duration)
Modèle de données
La table webhooks stocke la configuration par dépôt :
CREATE TABLE webhooks (
id UUID PRIMARY KEY,
repository_id UUID NOT NULL REFERENCES repositories(id) ON DELETE CASCADE,
url TEXT NOT NULL,
secret_hash TEXT NOT NULL, -- HMAC secret haché SHA-256 (jamais en clair)
events TEXT NOT NULL, -- JSON array : ["push","pull_request","issues"]
is_active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL
);
CREATE INDEX webhooks_repo_active_idx ON webhooks(repository_id, is_active);
La table webhook_deliveries trace chaque envoi :
CREATE TABLE webhook_deliveries (
id UUID PRIMARY KEY,
webhook_id UUID NOT NULL REFERENCES webhooks(id) ON DELETE CASCADE,
event TEXT NOT NULL,
payload TEXT NOT NULL,
response_status INTEGER NULL,
response_body TEXT NULL,
duration_ms INTEGER NULL,
success BOOLEAN NOT NULL,
created_at TIMESTAMPTZ NOT NULL
);
Créez les deux migrations correspondantes (voir ajouter-migration-db.md).
WebhookService — méthodes publiques
#![allow(unused)]
fn main() {
// crates/gitrust-core/src/services/webhook_service.rs
pub struct WebhookService;
impl WebhookService {
/// Crée un webhook pour un dépôt.
pub async fn create(
db: &DatabaseConnection,
repo_id: Uuid,
url: String,
secret: &str, // secret en clair — haché avant stockage
events: Vec<String>,
) -> Result<webhook::Model, GitrustError>;
/// Liste les webhooks actifs pour un dépôt et un événement donné.
pub async fn list_by_repo_and_event(
db: &DatabaseConnection,
repo_id: Uuid,
event: &str,
) -> Result<Vec<webhook::Model>, GitrustError>;
/// Met à jour l'URL, le secret ou les événements.
pub async fn update(
db: &DatabaseConnection,
webhook_id: Uuid,
repo_id: Uuid, // anti-IDOR : vérifie l'appartenance
url: Option<String>,
new_secret: Option<&str>,
events: Option<Vec<String>>,
) -> Result<webhook::Model, GitrustError>;
/// Désactive / active un webhook.
pub async fn set_active(
db: &DatabaseConnection,
webhook_id: Uuid,
repo_id: Uuid,
active: bool,
) -> Result<(), GitrustError>;
/// Supprime un webhook et ses deliveries en cascade.
pub async fn delete(
db: &DatabaseConnection,
webhook_id: Uuid,
repo_id: Uuid,
) -> Result<(), GitrustError>;
/// Enregistre le résultat d'une livraison.
pub async fn record_delivery(
db: &DatabaseConnection,
webhook_id: Uuid,
event: &str,
payload: &str,
response_status: Option<i32>,
response_body: Option<&str>,
duration_ms: i32,
success: bool,
) -> Result<(), GitrustError>;
/// Liste les dernières deliveries d'un webhook (pagination).
pub async fn list_deliveries(
db: &DatabaseConnection,
webhook_id: Uuid,
repo_id: Uuid,
page: u64,
per_page: u64,
) -> Result<Vec<webhook_delivery::Model>, GitrustError>;
}
}
Sécurité — stockage du secret : le secret ne doit jamais être stocké en clair. Lors de la création ou mise à jour, hachez-le avec SHA-256 avant insertion. La signature HMAC utilise le secret en clair fourni par l’utilisateur au moment de la création (retourné une seule fois) ou récupéré depuis un secret manager.
#![allow(unused)]
fn main() {
fn hash_secret(secret: &str) -> String {
use sha2::{Digest, Sha256};
let mut hasher = Sha256::new();
hasher.update(secret.as_bytes());
format!("{:x}", hasher.finalize())
}
}
WebhookDispatcher — envoi avec retry et signature HMAC
#![allow(unused)]
fn main() {
// crates/gitrust-core/src/services/webhook_dispatcher.rs
pub struct WebhookDispatcher;
impl WebhookDispatcher {
/// Envoie le payload à l'URL du webhook avec signature HMAC-SHA256.
/// Retry exponentiel : 3 tentatives (0s, 5s, 25s).
pub async fn deliver(
webhook: &webhook::Model,
event: &str,
payload: serde_json::Value,
secret_plain: &str, // secret en clair pour la signature
) -> DeliveryResult;
}
pub struct DeliveryResult {
pub success: bool,
pub response_status: Option<i32>,
pub response_body: Option<String>,
pub duration_ms: i32,
pub attempts: u32,
}
}
Calcul de la signature HMAC
#![allow(unused)]
fn main() {
use hmac::{Hmac, Mac};
use sha2::Sha256;
fn compute_hmac_signature(secret: &str, body: &str) -> String {
let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes())
.expect("HMAC accepte toutes les longueurs de clé");
mac.update(body.as_bytes());
let result = mac.finalize();
format!("sha256={}", hex::encode(result.into_bytes()))
}
}
Le header HTTP envoyé avec chaque livraison :
X-Gitrust-Signature-256: sha256=<hmac_hex>
X-Gitrust-Event: push
X-Gitrust-Delivery: <uuid de la delivery>
Content-Type: application/json
Retry exponentiel
#![allow(unused)]
fn main() {
const RETRY_DELAYS: [u64; 3] = [0, 5, 25]; // secondes
for (attempt, delay) in RETRY_DELAYS.iter().enumerate() {
if *delay > 0 {
tokio::time::sleep(Duration::from_secs(*delay)).await;
}
match client.post(&webhook.url).send().await {
Ok(resp) if resp.status().is_success() => return Ok(DeliveryResult { success: true, attempts: attempt + 1, .. }),
Ok(resp) => last_error = format!("HTTP {}", resp.status()),
Err(e) => last_error = e.to_string(),
}
}
}
Règle SSRF : l’URL du webhook est validée à la création. Les adresses RFC1918 (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16) et localhost sont interdites en production.
Injecter le dispatch dans le hook post-receive
Dans le handler receive_pack (crates/gitrust-web/src/handlers/git_http.rs), après la réception des objets Git :
#![allow(unused)]
fn main() {
// Déclencher les webhooks "push"
let payload = serde_json::json!({
"repository": { "id": repo.id, "slug": repo.slug, "owner": owner.username },
"pusher": { "username": user.username },
"ref": pushed_ref,
"commits": commit_summaries,
});
let webhooks = WebhookService::list_by_repo_and_event(&db, repo.id, "push").await?;
for webhook in webhooks {
// Le dispatch est non-bloquant : on ne veut pas bloquer le push si le
// serveur distant est lent.
let db_clone = db.clone();
let payload_clone = payload.clone();
tokio::spawn(async move {
let result = WebhookDispatcher::deliver(&webhook, "push", payload_clone, &secret_plain).await;
let _ = WebhookService::record_delivery(
&db_clone,
webhook.id,
"push",
&payload_clone.to_string(),
result.response_status,
result.response_body.as_deref(),
result.duration_ms,
result.success,
).await;
});
}
}
UI de configuration par dépôt
Ajoutez les routes dans routes.rs :
#![allow(unused)]
fn main() {
.route(
"/{owner}/{repo}/settings/webhooks",
get(handlers::webhooks::list_webhooks).post(handlers::webhooks::create_webhook),
)
.route(
"/{owner}/{repo}/settings/webhooks/{id}",
get(handlers::webhooks::webhook_detail),
)
.route(
"/{owner}/{repo}/settings/webhooks/{id}/edit",
post(handlers::webhooks::edit_webhook),
)
.route(
"/{owner}/{repo}/settings/webhooks/{id}/delete",
post(handlers::webhooks::delete_webhook),
)
.route(
"/{owner}/{repo}/settings/webhooks/{id}/test",
post(handlers::webhooks::test_webhook),
)
}
Rotation du secret
Quand un utilisateur régénère le secret via l’UI, le nouveau secret est :
- Affiché une seule fois à l’utilisateur (jamais re-lisible depuis l’UI).
- Haché SHA-256 et stocké dans
webhooks.secret_hash. - Utilisé immédiatement pour les livraisons suivantes.
Les livraisons en cours avec l’ancien secret ne sont pas réessayées — informez l’utilisateur dans l’UI.
Checklist de test
- Créer un webhook avec URL + secret + événements → visible dans la liste.
- Push sur le dépôt → delivery créée avec statut
successoufailed. - Désactiver le webhook → push ne déclenche aucune livraison.
- URL RFC1918 → rejet à la création avec message d’erreur.
- Bouton « Test » → delivery avec événement
pingcréée. - Rotation du secret → livraisons suivantes signées avec le nouveau secret.