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
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¶
// 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.
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¶
// 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¶
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¶
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 :
// 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 :
.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.