Skip to main content

faucet_source_webhook/
stream.rs

1//! Webhook source stream executor.
2
3use crate::config::WebhookSourceConfig;
4use async_trait::async_trait;
5use axum::{Router, extract::State, http::StatusCode, routing::post};
6use faucet_core::FaucetError;
7use serde_json::Value;
8use std::sync::Arc;
9use subtle::ConstantTimeEq;
10use tokio::sync::{Mutex, Notify};
11
12/// Shared state for the webhook HTTP handler.
13struct AppState {
14    records: Mutex<Vec<Value>>,
15    max_payloads: Option<usize>,
16    done: Notify,
17    /// Optional shared secret required in the `Authorization` header.
18    auth_token: Option<String>,
19}
20
21impl WebhookSource {
22    fn new_state(&self) -> Arc<AppState> {
23        Arc::new(AppState {
24            records: Mutex::new(Vec::new()),
25            max_payloads: self.config.max_payloads,
26            done: Notify::new(),
27            auth_token: self.config.auth_token.clone(),
28        })
29    }
30
31    fn build_router(&self, path: &str, state: Arc<AppState>) -> Router {
32        Router::new()
33            .route(path, post(webhook_handler))
34            // Bound request body size so a single huge POST can't exhaust
35            // memory (#78/#26).
36            .layer(axum::extract::DefaultBodyLimit::max(
37                self.config.max_body_bytes,
38            ))
39            .with_state(state)
40    }
41}
42
43/// A webhook receiver source that starts a temporary HTTP server and
44/// collects incoming POST payloads as records.
45pub struct WebhookSource {
46    config: WebhookSourceConfig,
47}
48
49impl WebhookSource {
50    /// Create a new webhook source from the given configuration.
51    pub fn new(config: WebhookSourceConfig) -> Self {
52        Self { config }
53    }
54
55    /// Start the webhook server, collect payloads, and return them.
56    pub async fn fetch_all(&self) -> Result<Vec<Value>, FaucetError> {
57        let state = self.new_state();
58        let app = self.build_router(&self.config.path, Arc::clone(&state));
59
60        let listener = tokio::net::TcpListener::bind(&self.config.listen_addr)
61            .await
62            .map_err(|e| {
63                FaucetError::Config(format!(
64                    "failed to bind to {}: {e}",
65                    self.config.listen_addr
66                ))
67            })?;
68
69        tracing::info!(
70            addr = %self.config.listen_addr,
71            path = %self.config.path,
72            "webhook server listening"
73        );
74
75        let timeout = tokio::time::sleep(std::time::Duration::from_secs(self.config.timeout_secs));
76        let done_notified = state.done.notified();
77
78        tokio::select! {
79            result = axum::serve(listener, app).into_future() => {
80                if let Err(e) = result {
81                    return Err(FaucetError::Config(format!("webhook server error: {e}")));
82                }
83            }
84            () = timeout => {
85                tracing::info!("webhook timeout reached");
86            }
87            () = done_notified => {
88                tracing::info!("max payloads reached");
89            }
90        }
91
92        let records = state.records.lock().await.clone();
93        tracing::info!(records = records.len(), "webhook fetch complete");
94        Ok(records)
95    }
96}
97
98/// Constant-time check of an `Authorization` header value against the shared
99/// secret. Accepts either the raw token or a `Bearer <token>` form, matching the
100/// original behaviour. The comparison uses [`subtle::ConstantTimeEq`] and a
101/// non-short-circuiting `|` so neither *which* form matched nor *where* the first
102/// byte differs is observable via response timing — closing a side-channel that
103/// could otherwise leak the secret byte-by-byte. (Length difference short-circuits;
104/// the secret's length is not itself sensitive.)
105fn token_matches(provided: Option<&str>, expected: &str) -> bool {
106    let Some(p) = provided else {
107        return false;
108    };
109    let exp = expected.as_bytes();
110    let raw = bool::from(p.as_bytes().ct_eq(exp));
111    let stripped = p
112        .strip_prefix("Bearer ")
113        .map(|s| bool::from(s.as_bytes().ct_eq(exp)))
114        .unwrap_or(false);
115    raw | stripped
116}
117
118/// Decision for an incoming payload, given the current record count and the
119/// configured cap. Extracted as a pure function so the cap invariant can be
120/// tested deterministically without driving concurrent HTTP.
121#[derive(Debug, Clone, Copy, PartialEq, Eq)]
122struct PayloadDecision {
123    /// Whether to store the just-received payload.
124    accept: bool,
125    /// Whether the receive loop is now done (cap reached) and `done` should be
126    /// notified.
127    done: bool,
128}
129
130/// Decide whether to store a payload and whether the cap is now satisfied.
131///
132/// `current_len` is the number of records already stored (observed under the
133/// records lock). With no cap, every payload is accepted and the loop never
134/// self-terminates. With a cap, the cap is **exact**: once `current_len` has
135/// reached `max`, further concurrently-accepted POSTs are dropped (so the Vec
136/// never exceeds `max`) while still notifying `done` so a slightly-late request
137/// doesn't wedge the receive loop (#146 LOW).
138fn decide_payload(current_len: usize, max_payloads: Option<usize>) -> PayloadDecision {
139    match max_payloads {
140        None => PayloadDecision {
141            accept: true,
142            done: false,
143        },
144        Some(max) => {
145            if current_len >= max {
146                // Already at (or somehow past) the cap — drop this payload and
147                // signal completion. Pushing here is what let concurrent
148                // in-flight POSTs overflow the Vec past `max`.
149                PayloadDecision {
150                    accept: false,
151                    done: true,
152                }
153            } else {
154                // Room for this one. Accept it; we're done once it fills the
155                // last slot.
156                PayloadDecision {
157                    accept: true,
158                    done: current_len + 1 >= max,
159                }
160            }
161        }
162    }
163}
164
165/// Axum handler for incoming webhook POST requests.
166async fn webhook_handler(
167    State(state): State<Arc<AppState>>,
168    headers: axum::http::HeaderMap,
169    body: axum::body::Bytes,
170) -> StatusCode {
171    // Optional shared-secret check: accept either the raw token or
172    // `Bearer <token>` in the Authorization header (#78/#26).
173    if let Some(expected) = &state.auth_token {
174        let provided = headers
175            .get(axum::http::header::AUTHORIZATION)
176            .and_then(|v| v.to_str().ok());
177        if !token_matches(provided, expected) {
178            return StatusCode::UNAUTHORIZED;
179        }
180    }
181
182    let value = match serde_json::from_slice::<Value>(&body) {
183        Ok(v) => v,
184        Err(_) => {
185            // If the body is not valid JSON, wrap it as a string.
186            match String::from_utf8(body.to_vec()) {
187                Ok(s) => Value::String(s),
188                Err(_) => return StatusCode::BAD_REQUEST,
189            }
190        }
191    };
192
193    let mut records = state.records.lock().await;
194    // Decide under the lock so the cap is exact: concurrent in-flight POSTs
195    // that have already been accepted can't push the Vec past `max_payloads`
196    // — once the cap is reached we drop the surplus payload instead of
197    // storing it (#146 LOW).
198    let decision = decide_payload(records.len(), state.max_payloads);
199    if decision.accept {
200        records.push(value);
201    }
202    if decision.done {
203        state.done.notify_one();
204    }
205
206    StatusCode::OK
207}
208
209#[async_trait]
210impl faucet_core::Source for WebhookSource {
211    async fn fetch_with_context(
212        &self,
213        context: &std::collections::HashMap<String, serde_json::Value>,
214    ) -> Result<Vec<Value>, FaucetError> {
215        if context.is_empty() {
216            return WebhookSource::fetch_all(self).await;
217        }
218
219        // Substitute context into the webhook path.
220        let resolved_path = faucet_core::util::substitute_context(&self.config.path, context);
221
222        let state = self.new_state();
223        let app = self.build_router(&resolved_path, Arc::clone(&state));
224
225        let listener = tokio::net::TcpListener::bind(&self.config.listen_addr)
226            .await
227            .map_err(|e| {
228                FaucetError::Config(format!(
229                    "failed to bind to {}: {e}",
230                    self.config.listen_addr
231                ))
232            })?;
233
234        tracing::info!(
235            addr = %self.config.listen_addr,
236            path = %resolved_path,
237            "webhook server listening (with context)"
238        );
239
240        let timeout = tokio::time::sleep(std::time::Duration::from_secs(self.config.timeout_secs));
241        let done_notified = state.done.notified();
242
243        tokio::select! {
244            result = axum::serve(listener, app).into_future() => {
245                if let Err(e) = result {
246                    return Err(FaucetError::Config(format!("webhook server error: {e}")));
247                }
248            }
249            () = timeout => {
250                tracing::info!("webhook timeout reached");
251            }
252            () = done_notified => {
253                tracing::info!("max payloads reached");
254            }
255        }
256
257        let records = state.records.lock().await.clone();
258        tracing::info!(
259            records = records.len(),
260            "webhook fetch complete (with context)"
261        );
262        Ok(records)
263    }
264
265    fn config_schema(&self) -> serde_json::Value {
266        serde_json::to_value(faucet_core::schema_for!(WebhookSourceConfig))
267            .expect("schema serialization")
268    }
269
270    fn connector_name(&self) -> &'static str {
271        "webhook"
272    }
273
274    fn dataset_uri(&self) -> String {
275        format!("webhook://{}{}", self.config.listen_addr, self.config.path)
276    }
277
278    /// Preflight probe that does **not** start the receive loop.
279    ///
280    /// The default `Source::check` would call `stream_pages`, which boots the
281    /// HTTP server and blocks for the whole receive window waiting for inbound
282    /// POSTs — useless as a fast preflight. Instead we just verify the
283    /// configured `listen_addr` is bindable: bind a `tokio::net::TcpListener`
284    /// to it and immediately drop it. Success means the port is free; a bind
285    /// error (port in use, permission denied, bad address) fails the probe.
286    async fn check(
287        &self,
288        _ctx: &faucet_core::check::CheckContext,
289    ) -> Result<faucet_core::check::CheckReport, FaucetError> {
290        use faucet_core::check::{CheckReport, Probe};
291
292        let start = std::time::Instant::now();
293        match tokio::net::TcpListener::bind(&self.config.listen_addr).await {
294            Ok(listener) => {
295                // Drop the listener immediately so we don't hold the port.
296                drop(listener);
297                Ok(CheckReport::single(Probe::pass("io", start.elapsed())))
298            }
299            Err(e) => Ok(CheckReport::single(Probe::fail_hint(
300                "io",
301                start.elapsed(),
302                e.to_string(),
303                format!("{} is not bindable", self.config.listen_addr),
304            ))),
305        }
306    }
307}
308
309#[cfg(test)]
310mod tests {
311    use super::*;
312    use serde_json::json;
313
314    #[test]
315    fn token_matches_accepts_raw_and_bearer() {
316        assert!(token_matches(
317            Some("sekret-token-value"),
318            "sekret-token-value"
319        ));
320        assert!(token_matches(
321            Some("Bearer sekret-token-value"),
322            "sekret-token-value"
323        ));
324    }
325
326    #[test]
327    fn token_matches_rejects_wrong_and_missing() {
328        assert!(!token_matches(
329            Some("wrong-token-value"),
330            "sekret-token-value"
331        ));
332        assert!(!token_matches(
333            Some("Bearer wrong-token-value"),
334            "sekret-token-value"
335        ));
336        assert!(!token_matches(None, "sekret-token-value"));
337        // Differing length must reject (not panic).
338        assert!(!token_matches(
339            Some("sekret-token-valu"),
340            "sekret-token-value"
341        ));
342    }
343
344    #[test]
345    fn decide_payload_no_cap_always_accepts() {
346        for len in [0usize, 1, 100, 10_000] {
347            assert_eq!(
348                decide_payload(len, None),
349                PayloadDecision {
350                    accept: true,
351                    done: false
352                }
353            );
354        }
355    }
356
357    #[test]
358    fn decide_payload_accepts_until_cap_then_drops() {
359        let max = Some(2);
360        // 0 stored: accept, not yet done.
361        assert_eq!(
362            decide_payload(0, max),
363            PayloadDecision {
364                accept: true,
365                done: false
366            }
367        );
368        // 1 stored: accept the last slot, now done.
369        assert_eq!(
370            decide_payload(1, max),
371            PayloadDecision {
372                accept: true,
373                done: true
374            }
375        );
376        // 2 stored (at cap): drop, signal done.
377        assert_eq!(
378            decide_payload(2, max),
379            PayloadDecision {
380                accept: false,
381                done: true
382            }
383        );
384        // 3 stored (somehow past cap): still drop, still done.
385        assert_eq!(
386            decide_payload(3, max),
387            PayloadDecision {
388                accept: false,
389                done: true
390            }
391        );
392    }
393
394    #[test]
395    fn cap_invariant_never_exceeded_under_concurrent_arrivals() {
396        // Simulate many in-flight POSTs all racing the cap: the same record
397        // count can be observed by several requests before any of them push.
398        // Applying `decide_payload` per request must still bound the Vec at
399        // `max`, dropping the surplus rather than overflowing (#146 LOW).
400        let max = 2usize;
401        let mut records: Vec<Value> = Vec::new();
402        // 5 concurrent arrivals; each makes its decision against the current
403        // length and only pushes if accepted (mirroring the handler under the
404        // records lock).
405        for i in 0..5 {
406            let decision = decide_payload(records.len(), Some(max));
407            if decision.accept {
408                records.push(json!({ "id": i }));
409            }
410        }
411        assert_eq!(
412            records.len(),
413            max,
414            "Vec must never exceed max_payloads, got {}",
415            records.len()
416        );
417    }
418
419    #[tokio::test]
420    async fn handler_never_exceeds_cap_under_concurrent_posts() {
421        // Drive the real handler with many concurrent requests against one
422        // shared AppState. The records Vec must never exceed `max_payloads`,
423        // even though several requests are accepted in-flight before any of
424        // them observe the cap being hit (#146 LOW).
425        let max = 3usize;
426        let state = Arc::new(AppState {
427            records: Mutex::new(Vec::new()),
428            max_payloads: Some(max),
429            done: Notify::new(),
430            auth_token: None,
431        });
432
433        let mut handles = Vec::new();
434        for i in 0..50 {
435            let st = Arc::clone(&state);
436            handles.push(tokio::spawn(async move {
437                let body = axum::body::Bytes::from(format!("{{\"id\":{i}}}"));
438                webhook_handler(State(st), axum::http::HeaderMap::new(), body).await
439            }));
440        }
441        for h in handles {
442            // Every request returns 200 (accepted or gracefully dropped — we
443            // don't surface a different status for drops to keep clients happy).
444            assert_eq!(h.await.unwrap(), StatusCode::OK);
445        }
446
447        let records = state.records.lock().await;
448        assert_eq!(
449            records.len(),
450            max,
451            "Vec must never exceed max_payloads, got {}",
452            records.len()
453        );
454    }
455
456    #[tokio::test]
457    async fn webhook_collects_payloads() {
458        // Use port 0 to get a random available port.
459        let config = WebhookSourceConfig::new()
460            .listen_addr("127.0.0.1:0")
461            .max_payloads(2)
462            .timeout_secs(5);
463
464        let state = Arc::new(AppState {
465            records: Mutex::new(Vec::new()),
466            max_payloads: config.max_payloads,
467            done: Notify::new(),
468            auth_token: config.auth_token.clone(),
469        });
470
471        let server_state = Arc::clone(&state);
472        let app = Router::new()
473            .route(&config.path, post(webhook_handler))
474            .with_state(Arc::clone(&state));
475
476        let listener = tokio::net::TcpListener::bind(&config.listen_addr)
477            .await
478            .unwrap();
479        let addr = listener.local_addr().unwrap();
480
481        let server_handle = tokio::spawn(async move {
482            let done_notified = server_state.done.notified();
483            tokio::select! {
484                result = axum::serve(listener, app).into_future() => {
485                    if let Err(e) = result {
486                        panic!("server error: {e}");
487                    }
488                }
489                () = done_notified => {}
490            }
491        });
492
493        let client = reqwest::Client::new();
494        let url = format!("http://{addr}/webhook");
495
496        // Send two payloads.
497        let resp1 = client
498            .post(&url)
499            .json(&json!({"event": "created", "id": 1}))
500            .send()
501            .await
502            .unwrap();
503        assert_eq!(resp1.status(), 200);
504
505        let resp2 = client
506            .post(&url)
507            .json(&json!({"event": "updated", "id": 2}))
508            .send()
509            .await
510            .unwrap();
511        assert_eq!(resp2.status(), 200);
512
513        // Wait for the server to notice max_payloads.
514        tokio::time::sleep(std::time::Duration::from_millis(100)).await;
515        server_handle.abort();
516
517        let records = state.records.lock().await;
518        assert_eq!(records.len(), 2);
519        assert_eq!(records[0]["event"], "created");
520        assert_eq!(records[1]["event"], "updated");
521    }
522
523    #[tokio::test]
524    async fn check_passes_when_port_is_bindable() {
525        use faucet_core::Source;
526        use faucet_core::check::{CheckContext, ProbeStatus};
527
528        // Port 0 = let the OS pick a free port, so the bind always succeeds.
529        let source = WebhookSource::new(WebhookSourceConfig::new().listen_addr("127.0.0.1:0"));
530        let report = source.check(&CheckContext::default()).await.unwrap();
531        assert_eq!(report.probes.len(), 1);
532        assert_eq!(report.probes[0].name, "io");
533        assert!(
534            matches!(report.probes[0].status, ProbeStatus::Pass),
535            "expected Pass, got {:?}",
536            report.probes[0].status
537        );
538        assert_eq!(report.failed_count(), 0);
539    }
540
541    #[tokio::test]
542    async fn check_fails_when_port_is_already_bound() {
543        use faucet_core::Source;
544        use faucet_core::check::{CheckContext, ProbeStatus};
545
546        // Hold a real listener, then point the source at the same address so
547        // the probe's bind collides.
548        let held = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
549        let addr = held.local_addr().unwrap();
550
551        let source = WebhookSource::new(WebhookSourceConfig::new().listen_addr(addr.to_string()));
552        let report = source.check(&CheckContext::default()).await.unwrap();
553        assert_eq!(report.probes.len(), 1);
554        assert_eq!(report.probes[0].name, "io");
555        assert!(
556            matches!(report.probes[0].status, ProbeStatus::Fail { .. }),
557            "expected Fail, got {:?}",
558            report.probes[0].status
559        );
560        assert_eq!(report.failed_count(), 1);
561        assert!(
562            report.probes[0]
563                .hint
564                .as_deref()
565                .unwrap()
566                .contains("not bindable")
567        );
568    }
569
570    #[tokio::test]
571    async fn webhook_handles_non_json_body() {
572        let state = Arc::new(AppState {
573            records: Mutex::new(Vec::new()),
574            max_payloads: Some(1),
575            done: Notify::new(),
576            auth_token: None,
577        });
578
579        let server_state = Arc::clone(&state);
580        let app = Router::new()
581            .route("/webhook", post(webhook_handler))
582            .with_state(Arc::clone(&state));
583
584        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
585        let addr = listener.local_addr().unwrap();
586
587        let server_handle = tokio::spawn(async move {
588            let done_notified = server_state.done.notified();
589            tokio::select! {
590                result = axum::serve(listener, app).into_future() => {
591                    if let Err(e) = result {
592                        panic!("server error: {e}");
593                    }
594                }
595                () = done_notified => {}
596            }
597        });
598
599        let client = reqwest::Client::new();
600        let resp = client
601            .post(format!("http://{addr}/webhook"))
602            .body("plain text body")
603            .send()
604            .await
605            .unwrap();
606        assert_eq!(resp.status(), 200);
607
608        tokio::time::sleep(std::time::Duration::from_millis(100)).await;
609        server_handle.abort();
610
611        let records = state.records.lock().await;
612        assert_eq!(records.len(), 1);
613        assert_eq!(records[0], Value::String("plain text body".into()));
614    }
615
616    #[test]
617    fn dataset_uri_combines_addr_and_path() {
618        use faucet_core::Source;
619        let source = WebhookSource::new(
620            WebhookSourceConfig::new()
621                .listen_addr("127.0.0.1:8080")
622                .path("/hooks/incoming"),
623        );
624        assert_eq!(
625            source.dataset_uri(),
626            "webhook://127.0.0.1:8080/hooks/incoming"
627        );
628    }
629}