trusty_console/webhook/mod.rs
1//! `POST /api/webhooks/{source}` — the console's webhook ingress (#5089 step 3).
2//!
3//! Why: ADR-0032 removed every sibling daemon's HTTP listener, and ADR-0034
4//! rules that console terminates the GitHub request and relays inward over UDS.
5//! The two handlers this path replaced both acknowledged GitHub *before* the
6//! work ran and downgraded every later failure to a log line. GitHub never
7//! retries an acknowledged delivery, so each of those failures was permanent,
8//! silent loss with every health signal still green. #5181 deleted them —
9//! `trusty-review`'s `POST /pr/github/webhook` and `trusty-analyze`'s
10//! `POST /webhooks/github` now 404 — so this route is the only HTTP webhook
11//! surface in the workspace, and the only holder of the shared secret.
12//!
13//! What: one route, multiplexed by `{source}` over both targets. The order of
14//! operations is the fix and is not negotiable:
15//!
16//! 1. unknown `{source}` → `404`, before any secret handling;
17//! 2. HMAC verified once, over the exact received bytes; unset secret and bad
18//! signature both → `401` (ADR-0034 §2 unifies the policy to fail-closed);
19//! 3. the delivery is written and fsync'd to the spool — **on failure `500`,
20//! and no `202` is ever sent**, so GitHub keeps the delivery redeliverable;
21//! 4. the relay runs, and its outcome is recorded durably: an explicit ack
22//! deletes the entry, anything else leaves it `pending` with an incremented
23//! attempt count;
24//! 5. `202`, because step 3 succeeded — not because step 4 did.
25//!
26//! 🔴 Explicitly absent, per ADR-0034 §2: `let _ = relay(...)`, a bare
27//! `tracing::warn!` as the sole record of a failed relay, and any `202` issued
28//! before the spool write returns.
29//!
30//! Spawn-on-demand — console starting a target that is not resident — landed in
31//! #5182 alongside the targets' listeners: [`spawn::TargetSupervisor`] runs
32//! `ensure_running` before each relay. A target that will not start is still
33//! [`relay::RelayOutcome::Unreachable`], which is a durable pending state, not a
34//! dropped delivery.
35//!
36//! Test: `tests.rs`.
37
38pub mod health;
39pub mod relay;
40pub mod schedule;
41pub mod spawn;
42pub mod spool;
43
44#[cfg(test)]
45#[path = "tests.rs"]
46mod tests;
47
48use std::collections::BTreeMap;
49use std::path::PathBuf;
50use std::sync::Arc;
51use std::time::{Duration, SystemTime, UNIX_EPOCH};
52
53use axum::body::Bytes;
54use axum::extract::{Path, State};
55use axum::http::{HeaderMap, StatusCode};
56use axum::response::IntoResponse;
57use base64::Engine as _;
58use base64::engine::general_purpose::STANDARD as BASE64;
59use serde_json::json;
60use trusty_common::webhook_hmac::{HMAC_ALGORITHM, SIGNATURE_HEADER, SignatureVerdict};
61
62use health::{DEFAULT_RED_AFTER, SpoolHealth};
63use relay::{RelayOutcome, UdsRelay};
64use schedule::ClaimSet;
65use spool::{Provenance, SPOOL_SCHEMA_VERSION, Spool, SpoolEntry, SpoolError};
66
67pub use schedule::BackoffPolicy;
68
69/// Most entries one sweep pass will relay.
70///
71/// Why: the sweep is serial and each relay can burn its full timeout, so an
72/// unbounded pass can outlast its own tick interval. Backoff already keeps the
73/// due set small; this bounds the pathological case where it is not. Entries
74/// left over are simply relayed on the next tick — they stay pending and
75/// durable in the meantime.
76const SWEEP_BUDGET: usize = 32;
77
78/// Largest webhook body the ingress route accepts.
79///
80/// Why: axum's `DefaultBodyLimit` is 2 MiB, and a rejection there happens
81/// *before* this module's handler runs — no spool entry, no metric, no log,
82/// just a 413 GitHub records and nobody reads. That is the invisible drop the
83/// whole step exists to remove, arriving through the framework instead of the
84/// code. GitHub payloads are legal to 25 MB and `push` / `pull_request` bodies
85/// routinely exceed 2 MiB, so the default silently refuses real deliveries.
86/// What: 25 MiB, matching GitHub's documented ceiling. Applied only to the
87/// webhook sub-router (`server::build_router_with_webhooks`), so the proxy and
88/// SPA routes keep the framework default.
89/// Test: `route_accepts_a_body_larger_than_the_axum_default_limit`.
90pub const MAX_WEBHOOK_BODY_BYTES: usize = 25 * 1024 * 1024;
91
92/// Environment variable holding the shared webhook secret.
93pub const SECRET_ENV: &str = "GITHUB_WEBHOOK_SECRET";
94
95/// Headers never written to the spool.
96///
97/// GitHub sends none of these; storing one would put a caller-supplied
98/// credential on disk for no benefit.
99const HEADER_DENYLIST: [&str; 3] = ["authorization", "cookie", "proxy-authorization"];
100
101/// Result of one ingress attempt, before it becomes an HTTP response.
102///
103/// Why: keeping the decision separate from axum lets every arm — including the
104/// two that must never produce a `202` — be asserted directly, without a router.
105/// What: one variant per outcome the ADR distinguishes.
106/// Test: the `ingest_*` cases in `tests.rs`.
107#[derive(Debug, Clone, PartialEq, Eq)]
108pub enum IngestOutcome {
109 /// `{source}` names no configured target.
110 UnknownSource {
111 /// The path segment that did not resolve.
112 source: String,
113 },
114 /// No secret is configured. Fail closed.
115 SecretMissing,
116 /// A secret is configured and the signature did not verify.
117 InvalidSignature,
118 /// The durable write failed. The caller MUST return 5xx and MUST NOT ack.
119 SpoolFailed {
120 /// Why the write failed.
121 reason: String,
122 },
123 /// The delivery is durably recorded. Safe to acknowledge GitHub.
124 Accepted {
125 /// The delivery id the entry is filed under.
126 delivery_id: String,
127 /// What the relay attempt established. Not a precondition of the ack.
128 relay: RelayOutcome,
129 /// Set when the post-relay bookkeeping write failed. The delivery is
130 /// still durable — this only means the attempt count or the deletion
131 /// did not land, both of which are recoverable by the retry sweep.
132 bookkeeping_error: Option<String>,
133 },
134}
135
136/// One relay target reachable through `/api/webhooks/{source}`.
137#[derive(Debug, Clone)]
138pub struct Target {
139 /// Route segment that selects it.
140 pub source: String,
141 /// UDS client for it.
142 pub relay: UdsRelay,
143}
144
145/// The webhook ingress: spool, secret, and one relay per target.
146///
147/// Why: assembled once at startup and shared by the route handler, the metrics
148/// handler, and the background retry sweep, so all three see the same spool.
149/// What: cheap to clone (everything behind `Arc`).
150/// Test: constructed directly in `tests.rs` with a temp-dir spool, so no test
151/// mutates a process-global env var.
152#[derive(Debug, Clone)]
153pub struct WebhookIngress {
154 spool: Arc<Spool>,
155 secret: Arc<String>,
156 key_id: Arc<String>,
157 targets: Arc<BTreeMap<String, UdsRelay>>,
158 red_after: Duration,
159 /// Which entries are being relayed right now, so the sweep and the request
160 /// path cannot both relay one delivery. See [`schedule::ClaimSet`].
161 claims: ClaimSet,
162 /// When a pending entry becomes eligible for another attempt.
163 backoff: BackoffPolicy,
164 /// Each target's inbox, so an acknowledged-but-undrained delivery is
165 /// metered rather than disappearing from the signal. See
166 /// [`health::SpoolHealth::undrained`] and #5192.
167 inbox_roots: Arc<Vec<(String, PathBuf)>>,
168}
169
170impl WebhookIngress {
171 /// Assemble an ingress from explicit parts.
172 ///
173 /// Test: used by every `tests.rs` case.
174 pub fn new(spool: Spool, secret: String, key_id: String, targets: Vec<Target>) -> Self {
175 Self {
176 spool: Arc::new(spool),
177 secret: Arc::new(secret),
178 key_id: Arc::new(key_id),
179 targets: Arc::new(
180 targets
181 .into_iter()
182 .map(|t| (t.source, t.relay))
183 .collect::<BTreeMap<_, _>>(),
184 ),
185 red_after: DEFAULT_RED_AFTER,
186 claims: ClaimSet::new(),
187 backoff: BackoffPolicy::default(),
188 // Deliberately empty rather than derived: deriving would make every
189 // unit test read the developer's real `~/…/webhook-inbox`. Production
190 // wiring is `from_env`, and `from_env_meters_every_targets_inbox`
191 // pins that it populates this.
192 inbox_roots: Arc::new(Vec::new()),
193 }
194 }
195
196 /// Meter these inboxes when reporting health.
197 ///
198 /// Why: an acknowledged delivery leaves the spool, so without this the
199 /// status goes green while the work sits unprocessed in a target's inbox.
200 /// Test: `health_is_degraded_while_a_delivery_sits_undrained`.
201 pub fn with_inbox_roots(mut self, roots: Vec<(String, PathBuf)>) -> Self {
202 self.inbox_roots = Arc::new(roots);
203 self
204 }
205
206 /// Override the red-health threshold.
207 pub fn with_red_after(mut self, red_after: Duration) -> Self {
208 self.red_after = red_after;
209 self
210 }
211
212 /// Override the retry schedule.
213 ///
214 /// Test: the `sweep_*` and `backoff_*` cases use a zeroed grace so a sweep
215 /// runs without waiting out the real 5 s hold-off.
216 pub fn with_backoff(mut self, backoff: BackoffPolicy) -> Self {
217 self.backoff = backoff;
218 self
219 }
220
221 /// The retry schedule in force.
222 pub fn backoff(&self) -> BackoffPolicy {
223 self.backoff
224 }
225
226 /// Production wiring: spool under the console data dir, secret from
227 /// [`SECRET_ENV`], and one target per relay-capable service.
228 ///
229 /// Why: the socket paths come from `trusty_common::uds::scratch_socket_dir`,
230 /// the shared entry point #5099 built. That is `$TMPDIR/trusty-<uid>` with a
231 /// `/tmp` fallback — the *base* ADR-0034 §3 names, but not the exposure it
232 /// objects to: the uid-keyed subdirectory is created at `0700` and owned by
233 /// this process, and `connect_hardened` re-verifies owner and mode before
234 /// dialling. #5099 supersedes §3's "use the service state directory instead"
235 /// path rule by making the scratch path satisfy the property §3 wanted.
236 /// Nothing binds these sockets until step 4; dialling an absent one is a
237 /// clean `Unreachable`.
238 /// What: creates the spool directory eagerly so a misconfigured data dir
239 /// fails at startup rather than on the first delivery.
240 ///
241 /// # Errors
242 ///
243 /// When the data directory cannot be resolved or the spool directory cannot
244 /// be created.
245 ///
246 /// Test: `default_spool_root_lives_under_the_console_data_dir`, plus the
247 /// `#[ignore]`d `integration_from_env_*` cases, which point
248 /// `TRUSTY_DATA_DIR_OVERRIDE` at a temp dir under a lock.
249 pub fn from_env() -> anyhow::Result<Self> {
250 let spool = Spool::open(Spool::default_root()?)?;
251 let secret = std::env::var(SECRET_ENV).unwrap_or_default();
252 // #5182: the paths come from the shared contract rather than a literal
253 // here, so the sender and the two receivers cannot disagree about them.
254 let supervisor: spawn::SharedSupervisor = Arc::new(spawn::TargetSupervisor::new());
255 let mut targets = Vec::new();
256 for source in [
257 trusty_common::webhook_relay::REVIEW_SOURCE,
258 trusty_common::webhook_relay::ANALYZE_SOURCE,
259 ] {
260 let socket = trusty_common::webhook_relay::socket_path_for(source)
261 .ok_or_else(|| anyhow::anyhow!("no socket is defined for source {source}"))?;
262 targets.push(Target {
263 source: source.to_string(),
264 relay: UdsRelay::new(socket).with_supervisor(source, Arc::clone(&supervisor)),
265 });
266 }
267 // #5182 review: meter each target's inbox. Without it an acknowledged
268 // delivery leaves the spool and the signal goes green while the work
269 // sits unprocessed — see `health::SpoolHealth::undrained` and #5192.
270 let mut inbox_roots = Vec::new();
271 for source in [
272 trusty_common::webhook_relay::REVIEW_SOURCE,
273 trusty_common::webhook_relay::ANALYZE_SOURCE,
274 ] {
275 let root = trusty_common::webhook_relay::inbox_root_for(source)
276 .ok_or_else(|| anyhow::anyhow!("no inbox is defined for source {source}"))??;
277 inbox_roots.push((source.to_string(), root));
278 }
279 Ok(Self::new(spool, secret, SECRET_ENV.to_string(), targets).with_inbox_roots(inbox_roots))
280 }
281
282 /// The spool this ingress writes to.
283 pub fn spool(&self) -> &Spool {
284 &self.spool
285 }
286
287 /// Run one blocking spool operation off the async runtime.
288 ///
289 /// Why: every spool call does real filesystem work — `persist_new` alone
290 /// fsyncs a file and a directory, and a census `read_dir`s two of them.
291 /// Doing that inline stalls a runtime worker thread, and the ingest path
292 /// runs it twice per delivery while the metrics route runs it per request.
293 /// What: `spawn_blocking` over a cloned [`Spool`] (a `PathBuf` and a flag,
294 /// so cloning is free). A join failure — the blocking pool shutting down
295 /// mid-operation — is surfaced as an error rather than silently swallowed.
296 /// Test: exercised by every async case; the correctness of each operation
297 /// is covered by its own `spool_*` case.
298 async fn blocking<T, F>(&self, op: F) -> Result<T, SpoolError>
299 where
300 F: FnOnce(Spool) -> Result<T, SpoolError> + Send + 'static,
301 T: Send + 'static,
302 {
303 let spool = (*self.spool).clone();
304 match tokio::task::spawn_blocking(move || op(spool)).await {
305 Ok(result) => result,
306 Err(join) => Err(SpoolError::PrepareDir {
307 path: self.spool.root().to_path_buf(),
308 source: std::io::Error::other(format!("spool task did not complete: {join}")),
309 }),
310 }
311 }
312
313 /// Scan the spool and classify its health, now.
314 ///
315 /// Deliberately not cached — see [`health`]'s module docs. The scan is
316 /// filesystem work, so it runs off the async runtime.
317 pub async fn health(&self) -> SpoolHealth {
318 let red_after = self.red_after;
319 let now = now_unix_ms();
320 let spool = (*self.spool).clone();
321 let roots = Arc::clone(&self.inbox_roots);
322 match tokio::task::spawn_blocking(move || {
323 health::scan_health(&spool, now, red_after, &roots)
324 })
325 .await
326 {
327 Ok(health) => health,
328 // A scan that could not run is not a healthy spool.
329 Err(join) => health::scan_failed(
330 red_after,
331 format!("health scan task did not complete: {join}"),
332 ),
333 }
334 }
335
336 /// Verify, spool, relay — in that order.
337 ///
338 /// Why: the ordering IS the fix; see the module docs. In particular the
339 /// spool write happens before this function can return anything a caller
340 /// would turn into a `202`, and a relay failure never propagates as a
341 /// reason to drop the delivery.
342 ///
343 /// What: returns an [`IngestOutcome`]; performs no HTTP.
344 ///
345 /// Test: `ingest_rejects_an_unknown_source`,
346 /// `ingest_fails_closed_when_no_secret_is_configured`,
347 /// `ingest_rejects_a_forged_signature`,
348 /// `ingest_returns_spool_failed_and_never_accepts_when_the_write_fails`,
349 /// `ingest_accepts_and_deletes_on_an_explicit_ack`,
350 /// `relay_failure_leaves_a_pending_entry_with_an_incremented_attempt_count`.
351 pub async fn ingest(&self, source: &str, headers: &HeaderMap, body: &[u8]) -> IngestOutcome {
352 let Some(relay) = self.targets.get(source) else {
353 return IngestOutcome::UnknownSource {
354 source: source.to_string(),
355 };
356 };
357
358 // Step 2 — one verification, over the exact received bytes. Anything
359 // that re-frames the body first destroys the ability to check it.
360 let signature = header_str(headers, SIGNATURE_HEADER).unwrap_or_default();
361 match trusty_common::webhook_hmac::verify_github_signature(&self.secret, body, &signature) {
362 SignatureVerdict::Valid => {}
363 SignatureVerdict::SecretMissing => {
364 tracing::warn!(
365 source,
366 "{SECRET_ENV} is not set — refusing the delivery (fail-closed, ADR-0034 §2)"
367 );
368 return IngestOutcome::SecretMissing;
369 }
370 SignatureVerdict::Invalid => {
371 tracing::warn!(source, "webhook HMAC verification failed");
372 return IngestOutcome::InvalidSignature;
373 }
374 }
375
376 let received_at_unix_ms = now_unix_ms();
377 let delivery_id = header_str(headers, "x-github-delivery")
378 .filter(|v| !v.trim().is_empty())
379 .unwrap_or_else(|| format!("no-delivery-header-{received_at_unix_ms}"));
380
381 let mut entry = SpoolEntry {
382 schema_version: SPOOL_SCHEMA_VERSION,
383 delivery_id: delivery_id.clone(),
384 source: source.to_string(),
385 event: header_str(headers, "x-github-event").unwrap_or_default(),
386 headers: collect_headers(headers),
387 body_b64: BASE64.encode(body),
388 provenance: Provenance {
389 algorithm: HMAC_ALGORITHM.to_string(),
390 key_id: self.key_id.as_str().to_string(),
391 verified: true,
392 },
393 received_at_unix_ms,
394 attempts: 0,
395 last_error: None,
396 last_attempt_at_unix_ms: None,
397 };
398
399 // 🔴 Claim BEFORE the write, not after it. `entry_path` is a pure
400 // function of the receipt time and delivery id, both already fixed, so
401 // the path is known before the entry exists. Claiming afterwards left a
402 // window in which the entry was on disk and unclaimed — and the write
403 // now runs on the blocking pool, which widens that window to however
404 // long a worker thread takes to be scheduled. A sweep landing there
405 // relayed a delivery this request was about to relay itself. Measured,
406 // not theorised: with the claim taken after the write, a sweep 20 ms
407 // into `ingest` reports `acked: 1` for an entry the request path then
408 // relays again.
409 //
410 // Backoff's first-attempt grace also covers this window in production,
411 // but two guards that each fully close it is the point — a deployment
412 // that tunes the grace to zero must not reopen a double-relay.
413 //
414 // 🔴 No regression test pins this ordering. The window is one scheduler
415 // poll wide, and every deterministic probe tried against it — a
416 // rendezvous, a select! loop, a parallel hammer on a 4-worker runtime —
417 // passed against a claim-after-write build as readily as against this
418 // one. A test that cannot tell the two apart is worse than none, so
419 // none was kept. Do not reorder these two statements on the strength of
420 // a green suite.
421 let claim = self.claims.claim(&self.spool.entry_path(&entry));
422
423 // Step 3 — durable BEFORE the ack. A failure here is a 5xx, never a
424 // logged-and-accepted delivery. `persist_new` refuses to overwrite: the
425 // entry already at that path may be one console has acknowledged, and
426 // GitHub will never re-send it.
427 let to_write = entry.clone();
428 let path = match self
429 .blocking(move |spool| spool.persist_new(&to_write))
430 .await
431 {
432 Ok(path) => path,
433 Err(e) => {
434 let already = matches!(e, SpoolError::AlreadyExists { .. });
435 tracing::error!(
436 source,
437 delivery_id = %delivery_id,
438 error = %e,
439 already_spooled = already,
440 "spool write failed — refusing the delivery so GitHub keeps it redeliverable"
441 );
442 return IngestOutcome::SpoolFailed {
443 reason: format!("{e}"),
444 };
445 }
446 };
447
448 // Step 4 — relay, and record what happened durably either way, holding
449 // the claim taken before the write. It is released on drop, so a
450 // panicking relay cannot wedge the entry.
451 let outcome = match claim {
452 Some(_claim) => {
453 let outcome = relay.deliver(&entry).await;
454 let bookkeeping_error = self.settle(&path, &mut entry, &outcome).await;
455 return IngestOutcome::Accepted {
456 delivery_id,
457 relay: outcome,
458 bookkeeping_error,
459 };
460 }
461 // Someone else is already relaying this exact path. The delivery is
462 // durable, so acknowledging is still correct; the in-flight relay
463 // (or the next sweep) settles it.
464 None => RelayOutcome::Unreachable {
465 reason: "another relay for this entry is already in flight".to_string(),
466 },
467 };
468
469 IngestOutcome::Accepted {
470 delivery_id,
471 relay: outcome,
472 bookkeeping_error: None,
473 }
474 }
475
476 /// Apply a relay outcome to the spool: delete on an explicit ack, otherwise
477 /// bump the attempt count.
478 ///
479 /// Why: the single place a spool entry can be removed, so "connection
480 /// succeeded" can never be mistaken for "work acknowledged".
481 /// What: returns `Some(reason)` when the bookkeeping write itself failed.
482 /// The delivery stays durable in that case — a failed delete leaves an entry
483 /// the sweep will retry (a duplicate delivery, which is recoverable), and a
484 /// failed attempt-bump leaves the count stale (visible as a growing age).
485 /// Both are the safe direction.
486 /// Test: `ingest_accepts_and_deletes_on_an_explicit_ack`,
487 /// `relay_failure_leaves_a_pending_entry_with_an_incremented_attempt_count`.
488 async fn settle(
489 &self,
490 path: &std::path::Path,
491 entry: &mut SpoolEntry,
492 outcome: &RelayOutcome,
493 ) -> Option<String> {
494 if outcome.is_acked() {
495 let acked_path = path.to_path_buf();
496 return match self
497 .blocking(move |spool| spool.remove_acked(&acked_path))
498 .await
499 {
500 Ok(()) => None,
501 Err(e) => {
502 tracing::error!(
503 delivery_id = %entry.delivery_id,
504 error = %e,
505 "target acknowledged but the spool entry could not be removed; \
506 the retry sweep will redeliver it"
507 );
508 Some(format!("{e}"))
509 }
510 };
511 }
512
513 let mut updated = entry.clone();
514 let reason = outcome.reason().to_string();
515 let now = now_unix_ms();
516 let written = self
517 .blocking(move |spool| {
518 spool
519 .record_attempt(&mut updated, reason, now)
520 .map(|_| updated)
521 })
522 .await;
523 match written {
524 Ok(after) => {
525 *entry = after;
526 tracing::warn!(
527 delivery_id = %entry.delivery_id,
528 attempts = entry.attempts,
529 reason = outcome.reason(),
530 "relay did not acknowledge; entry stays pending"
531 );
532 None
533 }
534 Err(e) => {
535 tracing::error!(
536 delivery_id = %entry.delivery_id,
537 error = %e,
538 "relay failed AND the attempt count could not be recorded; \
539 the entry is still on disk and still pending"
540 );
541 Some(format!("{e}"))
542 }
543 }
544 }
545
546 /// Re-attempt every pending delivery that is due, once.
547 ///
548 /// Why: ADR-0034 §2 — "Console retries with backoff." Three guards, each
549 /// closing a different failure:
550 ///
551 /// - **Backoff** ([`BackoffPolicy::is_due`]) — without it every pending
552 /// entry is re-relayed on every tick and each non-ack rewrites the whole
553 /// base64 body plus two `fsync`s. Until step 4 binds a listener that is
554 /// every delivery, forever.
555 /// - **Claims** ([`schedule::ClaimSet`]) — without them a tick landing
556 /// inside the ≤5 s relay window sends a delivery the request path is
557 /// still sending. One delivery, two relays.
558 /// - **[`SWEEP_BUDGET`]** — the pass is serial and each relay can burn its
559 /// full timeout, so an unbounded pass can outlast its own tick interval.
560 ///
561 /// Nothing any guard skips is dropped: it stays pending, durable, and
562 /// visible to [`WebhookIngress::health`], which scans on the request rather
563 /// than trusting this loop to still be alive.
564 ///
565 /// What: relays each due entry, deleting only on an explicit ack. Returns
566 /// per-sweep counts.
567 ///
568 /// Test: `retry_sweep_acks_and_clears_a_pending_entry`,
569 /// `retry_sweep_leaves_an_unrelayable_entry_pending_with_more_attempts`,
570 /// `sweep_does_not_relay_an_entry_the_request_path_is_still_relaying`,
571 /// `sweep_honours_backoff_between_ticks`,
572 /// `sweep_stops_relaying_an_exhausted_entry`.
573 pub async fn retry_pending_once(&self) -> SweepReport {
574 let listing = match self.blocking(|spool| spool.list_pending()).await {
575 Ok(listing) => listing,
576 Err(e) => {
577 tracing::error!(error = %e, "webhook retry sweep could not read the spool");
578 return SweepReport {
579 scan_error: Some(format!("{e}")),
580 ..SweepReport::default()
581 };
582 }
583 };
584
585 let mut report = SweepReport {
586 undecodable: listing.undecodable.len(),
587 ..SweepReport::default()
588 };
589 let now = now_unix_ms();
590 for pending in listing.pending {
591 if report.acked + report.still_pending >= SWEEP_BUDGET {
592 report.deferred += 1;
593 continue;
594 }
595 let mut entry = pending.entry;
596 let Some(relay) = self.targets.get(&entry.source) else {
597 // A target removed from the config leaves its deliveries on
598 // disk rather than dropping them; the age turns the health
599 // state red, which is the correct operator signal.
600 report.orphaned += 1;
601 continue;
602 };
603 if self.backoff.is_exhausted(&entry) {
604 // Move it out of the live set. Leaving it here would make every
605 // later sweep and every metrics request read and decode it
606 // forever, and would pin the oldest-pending diagnostics to it so
607 // a genuinely new failure changed nothing an operator reads.
608 // It is kept, not deleted — it is still an unacknowledged
609 // webhook, and it keeps the health signal red.
610 report.exhausted += 1;
611 let quarantine_path = pending.path.clone();
612 if let Err(e) = self
613 .blocking(move |spool| spool.quarantine(&quarantine_path))
614 .await
615 {
616 tracing::error!(
617 delivery_id = %entry.delivery_id,
618 error = %e,
619 "could not move an exhausted entry aside; it stays in the live set"
620 );
621 report.bookkeeping_failures += 1;
622 }
623 continue;
624 }
625 if !self.backoff.is_due(&entry, now) {
626 report.not_due += 1;
627 continue;
628 }
629 let Some(_claim) = self.claims.claim(&pending.path) else {
630 report.in_flight += 1;
631 continue;
632 };
633
634 let outcome = relay.deliver(&entry).await;
635 if outcome.is_acked() {
636 report.acked += 1;
637 } else {
638 report.still_pending += 1;
639 }
640 if self
641 .settle(&pending.path, &mut entry, &outcome)
642 .await
643 .is_some()
644 {
645 report.bookkeeping_failures += 1;
646 }
647 }
648 report
649 }
650}
651
652/// Counts from one [`WebhookIngress::retry_pending_once`] pass.
653///
654/// Every pending entry lands in exactly one bucket, so the counts account for
655/// the whole spool — a delivery that vanishes from all of them is a bug the
656/// tests can see.
657#[derive(Debug, Clone, Default, PartialEq, Eq)]
658pub struct SweepReport {
659 /// Entries a target acknowledged and that were removed.
660 pub acked: usize,
661 /// Entries relayed this pass that stayed pending.
662 pub still_pending: usize,
663 /// Entries not yet eligible under the backoff schedule.
664 pub not_due: usize,
665 /// Entries past `max_attempts` — never retried again, never deleted, and
666 /// holding the health signal red until an operator intervenes.
667 pub exhausted: usize,
668 /// Entries another relay was already handling.
669 pub in_flight: usize,
670 /// Entries left for the next tick by [`SWEEP_BUDGET`].
671 pub deferred: usize,
672 /// Entries whose `source` no longer maps to a configured target.
673 pub orphaned: usize,
674 /// Entries on disk that could not be decoded.
675 pub undecodable: usize,
676 /// Entries whose post-relay spool write failed.
677 pub bookkeeping_failures: usize,
678 /// Set when the spool itself could not be listed.
679 pub scan_error: Option<String>,
680}
681
682/// `POST /api/webhooks/{source}` — the axum front door.
683///
684/// Why: a thin mapping from [`IngestOutcome`] to a status code, so the ordering
685/// guarantee lives in [`WebhookIngress::ingest`] and cannot be broken by an
686/// edit to the HTTP layer.
687/// What: `404` unknown source, `401` both refusal arms, `500` spool failure,
688/// `202` once the delivery is durable. The body reports the relay state so an
689/// operator can see a pending delivery without opening the dashboard.
690/// Test: `route_returns_500_and_no_ack_when_the_spool_write_fails`,
691/// `route_returns_401_for_an_unset_secret`, `route_returns_202_after_a_durable_write`.
692pub async fn webhook_handler(
693 State(ingress): State<WebhookIngress>,
694 Path(source): Path<String>,
695 headers: HeaderMap,
696 body: Bytes,
697) -> axum::response::Response {
698 match ingress.ingest(&source, &headers, &body).await {
699 IngestOutcome::UnknownSource { source } => (
700 StatusCode::NOT_FOUND,
701 axum::Json(json!({"error": "unknown webhook source", "source": source})),
702 )
703 .into_response(),
704 // One uniform 401 for both refusals: the response must not tell an
705 // unauthenticated caller whether a secret is configured.
706 IngestOutcome::SecretMissing | IngestOutcome::InvalidSignature => (
707 StatusCode::UNAUTHORIZED,
708 axum::Json(json!({"error": "signature verification failed"})),
709 )
710 .into_response(),
711 IngestOutcome::SpoolFailed { reason } => (
712 StatusCode::INTERNAL_SERVER_ERROR,
713 axum::Json(json!({
714 "error": "could not durably record the delivery; not acknowledged",
715 "detail": reason,
716 })),
717 )
718 .into_response(),
719 IngestOutcome::Accepted {
720 delivery_id,
721 relay,
722 bookkeeping_error,
723 } => (
724 StatusCode::ACCEPTED,
725 axum::Json(json!({
726 "status": "accepted",
727 "delivery_id": delivery_id,
728 "relay": if relay.is_acked() { "acknowledged" } else { "pending" },
729 "detail": relay.reason(),
730 "bookkeeping_error": bookkeeping_error,
731 })),
732 )
733 .into_response(),
734 }
735}
736
737/// `GET /api/console/metrics/webhooks` — oldest-pending-age as a health state.
738///
739/// Why: ADR-0034 §2 requires the signal on console's existing metrics surface,
740/// red once a delivery has been pending too long.
741/// What: scans the spool on this request and returns a `ConsoleMetricsReport`.
742/// Always `200`, never `503`: a red report is information, and a `503` would be
743/// indistinguishable from "no data yet" — which is the fail-quiet reading this
744/// signal exists to prevent.
745/// Test: `metrics_route_reports_red_for_an_aged_pending_entry`,
746/// `metrics_route_reports_ok_on_an_empty_spool`.
747pub async fn metrics_webhooks_handler(
748 State(ingress): State<WebhookIngress>,
749) -> axum::response::Response {
750 axum::Json(health::to_report(&ingress.health().await)).into_response()
751}
752
753/// Milliseconds since the Unix epoch, saturating at 0 before it.
754fn now_unix_ms() -> u64 {
755 SystemTime::now()
756 .duration_since(UNIX_EPOCH)
757 .map(|d| d.as_millis().min(u128::from(u64::MAX)) as u64)
758 .unwrap_or(0)
759}
760
761/// One header value as a `String`, lowercased name, `None` when absent or not
762/// valid UTF-8.
763fn header_str(headers: &HeaderMap, name: &str) -> Option<String> {
764 headers
765 .get(name)
766 .and_then(|v| v.to_str().ok())
767 .map(str::to_string)
768}
769
770/// Every header worth spooling, lowercased, minus [`HEADER_DENYLIST`].
771fn collect_headers(headers: &HeaderMap) -> BTreeMap<String, String> {
772 headers
773 .iter()
774 .filter_map(|(name, value)| {
775 let name = name.as_str().to_ascii_lowercase();
776 if HEADER_DENYLIST.contains(&name.as_str()) {
777 return None;
778 }
779 value.to_str().ok().map(|v| (name, v.to_string()))
780 })
781 .collect()
782}
783
784/// The spool root, for callers that need it before an ingress exists.
785pub fn default_spool_root() -> anyhow::Result<PathBuf> {
786 Spool::default_root()
787}
788
789/// Run [`WebhookIngress::retry_pending_once`] on an interval, forever.
790///
791/// Why: recovery for entries whose first relay failed — expected for every
792/// delivery until #5089 step 4 binds the targets' listeners.
793/// What: spawns a detached task. Deliberately NOT the detection path: if this
794/// task dies, `GET /api/console/metrics/webhooks` still turns red, because it
795/// scans the spool on the request rather than reading anything this loop wrote.
796/// Test: the sweep body is tested directly via `retry_sweep_*`; the timer
797/// wrapper carries no logic to test.
798pub fn start_retry_sweep(ingress: WebhookIngress, interval: Duration) {
799 tokio::spawn(async move {
800 let mut ticker = tokio::time::interval(interval);
801 ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
802 loop {
803 ticker.tick().await;
804 let report = ingress.retry_pending_once().await;
805 if report.acked > 0
806 || report.still_pending > 0
807 || report.exhausted > 0
808 || report.scan_error.is_some()
809 {
810 tracing::info!(
811 acked = report.acked,
812 still_pending = report.still_pending,
813 not_due = report.not_due,
814 exhausted = report.exhausted,
815 in_flight = report.in_flight,
816 deferred = report.deferred,
817 orphaned = report.orphaned,
818 undecodable = report.undecodable,
819 "webhook retry sweep completed"
820 );
821 }
822 }
823 });
824}