pub mod health;
pub mod relay;
pub mod schedule;
pub mod spawn;
pub mod spool;
#[cfg(test)]
#[path = "tests.rs"]
mod tests;
use std::collections::BTreeMap;
use std::path::PathBuf;
use std::sync::Arc;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use axum::body::Bytes;
use axum::extract::{Path, State};
use axum::http::{HeaderMap, StatusCode};
use axum::response::IntoResponse;
use base64::Engine as _;
use base64::engine::general_purpose::STANDARD as BASE64;
use serde_json::json;
use trusty_common::webhook_hmac::{HMAC_ALGORITHM, SIGNATURE_HEADER, SignatureVerdict};
use health::{DEFAULT_RED_AFTER, SpoolHealth};
use relay::{RelayOutcome, UdsRelay};
use schedule::ClaimSet;
use spool::{Provenance, SPOOL_SCHEMA_VERSION, Spool, SpoolEntry, SpoolError};
pub use schedule::BackoffPolicy;
const SWEEP_BUDGET: usize = 32;
pub const MAX_WEBHOOK_BODY_BYTES: usize = 25 * 1024 * 1024;
pub const SECRET_ENV: &str = "GITHUB_WEBHOOK_SECRET";
const HEADER_DENYLIST: [&str; 3] = ["authorization", "cookie", "proxy-authorization"];
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum IngestOutcome {
UnknownSource {
source: String,
},
SecretMissing,
InvalidSignature,
SpoolFailed {
reason: String,
},
Accepted {
delivery_id: String,
relay: RelayOutcome,
bookkeeping_error: Option<String>,
},
}
#[derive(Debug, Clone)]
pub struct Target {
pub source: String,
pub relay: UdsRelay,
}
#[derive(Debug, Clone)]
pub struct WebhookIngress {
spool: Arc<Spool>,
secret: Arc<String>,
key_id: Arc<String>,
targets: Arc<BTreeMap<String, UdsRelay>>,
red_after: Duration,
claims: ClaimSet,
backoff: BackoffPolicy,
inbox_roots: Arc<Vec<(String, PathBuf)>>,
}
impl WebhookIngress {
pub fn new(spool: Spool, secret: String, key_id: String, targets: Vec<Target>) -> Self {
Self {
spool: Arc::new(spool),
secret: Arc::new(secret),
key_id: Arc::new(key_id),
targets: Arc::new(
targets
.into_iter()
.map(|t| (t.source, t.relay))
.collect::<BTreeMap<_, _>>(),
),
red_after: DEFAULT_RED_AFTER,
claims: ClaimSet::new(),
backoff: BackoffPolicy::default(),
inbox_roots: Arc::new(Vec::new()),
}
}
pub fn with_inbox_roots(mut self, roots: Vec<(String, PathBuf)>) -> Self {
self.inbox_roots = Arc::new(roots);
self
}
pub fn with_red_after(mut self, red_after: Duration) -> Self {
self.red_after = red_after;
self
}
pub fn with_backoff(mut self, backoff: BackoffPolicy) -> Self {
self.backoff = backoff;
self
}
pub fn backoff(&self) -> BackoffPolicy {
self.backoff
}
pub fn from_env() -> anyhow::Result<Self> {
let spool = Spool::open(Spool::default_root()?)?;
let secret = std::env::var(SECRET_ENV).unwrap_or_default();
let supervisor: spawn::SharedSupervisor = Arc::new(spawn::TargetSupervisor::new());
let mut targets = Vec::new();
for source in [
trusty_common::webhook_relay::REVIEW_SOURCE,
trusty_common::webhook_relay::ANALYZE_SOURCE,
] {
let socket = trusty_common::webhook_relay::socket_path_for(source)
.ok_or_else(|| anyhow::anyhow!("no socket is defined for source {source}"))?;
targets.push(Target {
source: source.to_string(),
relay: UdsRelay::new(socket).with_supervisor(source, Arc::clone(&supervisor)),
});
}
let mut inbox_roots = Vec::new();
for source in [
trusty_common::webhook_relay::REVIEW_SOURCE,
trusty_common::webhook_relay::ANALYZE_SOURCE,
] {
let root = trusty_common::webhook_relay::inbox_root_for(source)
.ok_or_else(|| anyhow::anyhow!("no inbox is defined for source {source}"))??;
inbox_roots.push((source.to_string(), root));
}
Ok(Self::new(spool, secret, SECRET_ENV.to_string(), targets).with_inbox_roots(inbox_roots))
}
pub fn spool(&self) -> &Spool {
&self.spool
}
async fn blocking<T, F>(&self, op: F) -> Result<T, SpoolError>
where
F: FnOnce(Spool) -> Result<T, SpoolError> + Send + 'static,
T: Send + 'static,
{
let spool = (*self.spool).clone();
match tokio::task::spawn_blocking(move || op(spool)).await {
Ok(result) => result,
Err(join) => Err(SpoolError::PrepareDir {
path: self.spool.root().to_path_buf(),
source: std::io::Error::other(format!("spool task did not complete: {join}")),
}),
}
}
pub async fn health(&self) -> SpoolHealth {
let red_after = self.red_after;
let now = now_unix_ms();
let spool = (*self.spool).clone();
let roots = Arc::clone(&self.inbox_roots);
match tokio::task::spawn_blocking(move || {
health::scan_health(&spool, now, red_after, &roots)
})
.await
{
Ok(health) => health,
Err(join) => health::scan_failed(
red_after,
format!("health scan task did not complete: {join}"),
),
}
}
pub async fn ingest(&self, source: &str, headers: &HeaderMap, body: &[u8]) -> IngestOutcome {
let Some(relay) = self.targets.get(source) else {
return IngestOutcome::UnknownSource {
source: source.to_string(),
};
};
let signature = header_str(headers, SIGNATURE_HEADER).unwrap_or_default();
match trusty_common::webhook_hmac::verify_github_signature(&self.secret, body, &signature) {
SignatureVerdict::Valid => {}
SignatureVerdict::SecretMissing => {
tracing::warn!(
source,
"{SECRET_ENV} is not set — refusing the delivery (fail-closed, ADR-0034 §2)"
);
return IngestOutcome::SecretMissing;
}
SignatureVerdict::Invalid => {
tracing::warn!(source, "webhook HMAC verification failed");
return IngestOutcome::InvalidSignature;
}
}
let received_at_unix_ms = now_unix_ms();
let delivery_id = header_str(headers, "x-github-delivery")
.filter(|v| !v.trim().is_empty())
.unwrap_or_else(|| format!("no-delivery-header-{received_at_unix_ms}"));
let mut entry = SpoolEntry {
schema_version: SPOOL_SCHEMA_VERSION,
delivery_id: delivery_id.clone(),
source: source.to_string(),
event: header_str(headers, "x-github-event").unwrap_or_default(),
headers: collect_headers(headers),
body_b64: BASE64.encode(body),
provenance: Provenance {
algorithm: HMAC_ALGORITHM.to_string(),
key_id: self.key_id.as_str().to_string(),
verified: true,
},
received_at_unix_ms,
attempts: 0,
last_error: None,
last_attempt_at_unix_ms: None,
};
let claim = self.claims.claim(&self.spool.entry_path(&entry));
let to_write = entry.clone();
let path = match self
.blocking(move |spool| spool.persist_new(&to_write))
.await
{
Ok(path) => path,
Err(e) => {
let already = matches!(e, SpoolError::AlreadyExists { .. });
tracing::error!(
source,
delivery_id = %delivery_id,
error = %e,
already_spooled = already,
"spool write failed — refusing the delivery so GitHub keeps it redeliverable"
);
return IngestOutcome::SpoolFailed {
reason: format!("{e}"),
};
}
};
let outcome = match claim {
Some(_claim) => {
let outcome = relay.deliver(&entry).await;
let bookkeeping_error = self.settle(&path, &mut entry, &outcome).await;
return IngestOutcome::Accepted {
delivery_id,
relay: outcome,
bookkeeping_error,
};
}
None => RelayOutcome::Unreachable {
reason: "another relay for this entry is already in flight".to_string(),
},
};
IngestOutcome::Accepted {
delivery_id,
relay: outcome,
bookkeeping_error: None,
}
}
async fn settle(
&self,
path: &std::path::Path,
entry: &mut SpoolEntry,
outcome: &RelayOutcome,
) -> Option<String> {
if outcome.is_acked() {
let acked_path = path.to_path_buf();
return match self
.blocking(move |spool| spool.remove_acked(&acked_path))
.await
{
Ok(()) => None,
Err(e) => {
tracing::error!(
delivery_id = %entry.delivery_id,
error = %e,
"target acknowledged but the spool entry could not be removed; \
the retry sweep will redeliver it"
);
Some(format!("{e}"))
}
};
}
let mut updated = entry.clone();
let reason = outcome.reason().to_string();
let now = now_unix_ms();
let written = self
.blocking(move |spool| {
spool
.record_attempt(&mut updated, reason, now)
.map(|_| updated)
})
.await;
match written {
Ok(after) => {
*entry = after;
tracing::warn!(
delivery_id = %entry.delivery_id,
attempts = entry.attempts,
reason = outcome.reason(),
"relay did not acknowledge; entry stays pending"
);
None
}
Err(e) => {
tracing::error!(
delivery_id = %entry.delivery_id,
error = %e,
"relay failed AND the attempt count could not be recorded; \
the entry is still on disk and still pending"
);
Some(format!("{e}"))
}
}
}
pub async fn retry_pending_once(&self) -> SweepReport {
let listing = match self.blocking(|spool| spool.list_pending()).await {
Ok(listing) => listing,
Err(e) => {
tracing::error!(error = %e, "webhook retry sweep could not read the spool");
return SweepReport {
scan_error: Some(format!("{e}")),
..SweepReport::default()
};
}
};
let mut report = SweepReport {
undecodable: listing.undecodable.len(),
..SweepReport::default()
};
let now = now_unix_ms();
for pending in listing.pending {
if report.acked + report.still_pending >= SWEEP_BUDGET {
report.deferred += 1;
continue;
}
let mut entry = pending.entry;
let Some(relay) = self.targets.get(&entry.source) else {
report.orphaned += 1;
continue;
};
if self.backoff.is_exhausted(&entry) {
report.exhausted += 1;
let quarantine_path = pending.path.clone();
if let Err(e) = self
.blocking(move |spool| spool.quarantine(&quarantine_path))
.await
{
tracing::error!(
delivery_id = %entry.delivery_id,
error = %e,
"could not move an exhausted entry aside; it stays in the live set"
);
report.bookkeeping_failures += 1;
}
continue;
}
if !self.backoff.is_due(&entry, now) {
report.not_due += 1;
continue;
}
let Some(_claim) = self.claims.claim(&pending.path) else {
report.in_flight += 1;
continue;
};
let outcome = relay.deliver(&entry).await;
if outcome.is_acked() {
report.acked += 1;
} else {
report.still_pending += 1;
}
if self
.settle(&pending.path, &mut entry, &outcome)
.await
.is_some()
{
report.bookkeeping_failures += 1;
}
}
report
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct SweepReport {
pub acked: usize,
pub still_pending: usize,
pub not_due: usize,
pub exhausted: usize,
pub in_flight: usize,
pub deferred: usize,
pub orphaned: usize,
pub undecodable: usize,
pub bookkeeping_failures: usize,
pub scan_error: Option<String>,
}
pub async fn webhook_handler(
State(ingress): State<WebhookIngress>,
Path(source): Path<String>,
headers: HeaderMap,
body: Bytes,
) -> axum::response::Response {
match ingress.ingest(&source, &headers, &body).await {
IngestOutcome::UnknownSource { source } => (
StatusCode::NOT_FOUND,
axum::Json(json!({"error": "unknown webhook source", "source": source})),
)
.into_response(),
IngestOutcome::SecretMissing | IngestOutcome::InvalidSignature => (
StatusCode::UNAUTHORIZED,
axum::Json(json!({"error": "signature verification failed"})),
)
.into_response(),
IngestOutcome::SpoolFailed { reason } => (
StatusCode::INTERNAL_SERVER_ERROR,
axum::Json(json!({
"error": "could not durably record the delivery; not acknowledged",
"detail": reason,
})),
)
.into_response(),
IngestOutcome::Accepted {
delivery_id,
relay,
bookkeeping_error,
} => (
StatusCode::ACCEPTED,
axum::Json(json!({
"status": "accepted",
"delivery_id": delivery_id,
"relay": if relay.is_acked() { "acknowledged" } else { "pending" },
"detail": relay.reason(),
"bookkeeping_error": bookkeeping_error,
})),
)
.into_response(),
}
}
pub async fn metrics_webhooks_handler(
State(ingress): State<WebhookIngress>,
) -> axum::response::Response {
axum::Json(health::to_report(&ingress.health().await)).into_response()
}
fn now_unix_ms() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_millis().min(u128::from(u64::MAX)) as u64)
.unwrap_or(0)
}
fn header_str(headers: &HeaderMap, name: &str) -> Option<String> {
headers
.get(name)
.and_then(|v| v.to_str().ok())
.map(str::to_string)
}
fn collect_headers(headers: &HeaderMap) -> BTreeMap<String, String> {
headers
.iter()
.filter_map(|(name, value)| {
let name = name.as_str().to_ascii_lowercase();
if HEADER_DENYLIST.contains(&name.as_str()) {
return None;
}
value.to_str().ok().map(|v| (name, v.to_string()))
})
.collect()
}
pub fn default_spool_root() -> anyhow::Result<PathBuf> {
Spool::default_root()
}
pub fn start_retry_sweep(ingress: WebhookIngress, interval: Duration) {
tokio::spawn(async move {
let mut ticker = tokio::time::interval(interval);
ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
loop {
ticker.tick().await;
let report = ingress.retry_pending_once().await;
if report.acked > 0
|| report.still_pending > 0
|| report.exhausted > 0
|| report.scan_error.is_some()
{
tracing::info!(
acked = report.acked,
still_pending = report.still_pending,
not_due = report.not_due,
exhausted = report.exhausted,
in_flight = report.in_flight,
deferred = report.deferred,
orphaned = report.orphaned,
undecodable = report.undecodable,
"webhook retry sweep completed"
);
}
}
});
}