1use 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
12struct AppState {
14 records: Mutex<Vec<Value>>,
15 max_payloads: Option<usize>,
16 done: Notify,
17 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 .layer(axum::extract::DefaultBodyLimit::max(
37 self.config.max_body_bytes,
38 ))
39 .with_state(state)
40 }
41}
42
43pub struct WebhookSource {
46 config: WebhookSourceConfig,
47}
48
49impl WebhookSource {
50 pub fn new(config: WebhookSourceConfig) -> Self {
52 Self { config }
53 }
54
55 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
98fn 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
122struct PayloadDecision {
123 accept: bool,
125 done: bool,
128}
129
130fn 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 PayloadDecision {
150 accept: false,
151 done: true,
152 }
153 } else {
154 PayloadDecision {
157 accept: true,
158 done: current_len + 1 >= max,
159 }
160 }
161 }
162 }
163}
164
165async fn webhook_handler(
167 State(state): State<Arc<AppState>>,
168 headers: axum::http::HeaderMap,
169 body: axum::body::Bytes,
170) -> StatusCode {
171 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 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 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 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 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(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 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 assert_eq!(
362 decide_payload(0, max),
363 PayloadDecision {
364 accept: true,
365 done: false
366 }
367 );
368 assert_eq!(
370 decide_payload(1, max),
371 PayloadDecision {
372 accept: true,
373 done: true
374 }
375 );
376 assert_eq!(
378 decide_payload(2, max),
379 PayloadDecision {
380 accept: false,
381 done: true
382 }
383 );
384 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 let max = 2usize;
401 let mut records: Vec<Value> = Vec::new();
402 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 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 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 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 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 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 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 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}