1use crate::plugin::PluginEvent;
16use crate::{
17 StoreError, Target,
18 arn::TargetID,
19 error::TargetError,
20 runtime::tls::{
21 ReloadableTargetTls, TargetTlsInputSet, TlsReloadAdapter, config::ReloadApplyMode, fingerprint::TargetTlsGeneration,
22 validate::validate_tls_material,
23 },
24 store::{Key, Store},
25 target::{
26 ChannelTargetType, EntityTarget, QueuedPayload, QueuedPayloadMeta, TargetDeliveryCounters, TargetDeliverySnapshot,
27 TargetHealth, TargetHealthReason, TargetHealthState, TargetTlsState, TargetType, build_queued_payload,
28 build_target_tls_fingerprint, open_target_queue_store, persist_queued_payload_to_store, redacted_secret,
29 },
30};
31use async_trait::async_trait;
32use parking_lot::Mutex;
33use reqwest::{Client, StatusCode, Url};
34use rustfs_tls_runtime::load_cert_bundle_der_bytes;
35use rustfs_utils::egress::OutboundPolicy;
36use std::{
37 error::Error as StdError,
38 fmt,
39 marker::PhantomData,
40 sync::{
41 Arc,
42 atomic::{AtomicBool, Ordering},
43 },
44 time::Duration,
45};
46use tokio::sync::mpsc;
47use tracing::{debug, error, info, instrument, warn};
48
49const LOG_COMPONENT_TARGETS: &str = "targets";
50const LOG_SUBSYSTEM_WEBHOOK: &str = "webhook";
51const EVENT_WEBHOOK_TARGET_STATE: &str = "webhook_target_state";
52const EVENT_WEBHOOK_DELIVERY_STATE: &str = "webhook_delivery_state";
53const WEBHOOK_HEALTH_TIMEOUT: Duration = Duration::from_secs(5);
54
55fn classify_probe_error(err: &reqwest::Error) -> TargetHealthReason {
56 if err.is_timeout() {
57 return TargetHealthReason::TimedOut;
58 }
59
60 let mut source = err.source();
61 while let Some(cause) = source {
62 if cause.downcast_ref::<rustls::Error>().is_some() {
63 return TargetHealthReason::TlsFailure;
64 }
65 if let Some(io_error) = cause.downcast_ref::<std::io::Error>() {
66 match io_error.kind() {
67 std::io::ErrorKind::ConnectionRefused => return TargetHealthReason::ConnectionRefused,
68 std::io::ErrorKind::NotFound | std::io::ErrorKind::AddrNotAvailable => {
69 return TargetHealthReason::DnsFailure;
70 }
71 _ => {}
72 }
73 }
74
75 let label = cause.to_string().to_ascii_lowercase();
76 if label.contains("dns error") || label.contains("failed to lookup") || label.contains("name or service not known") {
77 return TargetHealthReason::DnsFailure;
78 }
79 if label.contains("certificate") || label.contains("tls") {
80 return TargetHealthReason::TlsFailure;
81 }
82 source = cause.source();
83 }
84
85 TargetHealthReason::Unreachable
86}
87
88#[hotpath::measure]
89async fn probe_health_url(client: &Client, health_check_url: &Url) -> TargetHealth {
90 match tokio::time::timeout(WEBHOOK_HEALTH_TIMEOUT, client.head(health_check_url.as_str()).send()).await {
91 Ok(Ok(_)) => TargetHealth::online(TargetHealthReason::Reachable),
92 Ok(Err(err)) => TargetHealth::error(classify_probe_error(&err)),
93 Err(_) => TargetHealth::error(TargetHealthReason::TimedOut),
94 }
95}
96
97fn classify_delivery_status(status: StatusCode) -> Result<(), TargetError> {
98 if status.is_success() {
99 Ok(())
100 } else if status.is_redirection() {
101 Err(TargetError::Request(format!(
102 "Webhook endpoint returned redirect '{}'; redirects are not followed for webhook delivery",
103 status
104 )))
105 } else if status == StatusCode::FORBIDDEN || status == StatusCode::UNAUTHORIZED {
106 Err(TargetError::Authentication(format!(
107 "Webhook endpoint returned '{}', please check if your auth token is correctly set",
108 status
109 )))
110 } else {
111 Err(TargetError::Request(format!(
112 "Webhook endpoint returned '{}', please check your endpoint configuration",
113 status
114 )))
115 }
116}
117
118#[derive(Clone)]
120pub struct WebhookArgs {
121 pub enable: bool,
123 pub endpoint: Url,
125 pub auth_token: String,
127 pub queue_dir: String,
129 pub queue_limit: u64,
131 pub client_cert: String,
133 pub client_key: String,
135 pub client_ca: String,
137 pub skip_tls_verify: bool,
139 pub target_type: TargetType,
141}
142
143impl fmt::Debug for WebhookArgs {
144 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
145 f.debug_struct("WebhookArgs")
146 .field("enable", &self.enable)
147 .field("endpoint_origin", &self.endpoint.origin().ascii_serialization())
148 .field("auth_token", &redacted_secret(&self.auth_token))
149 .field("queue_dir", &self.queue_dir)
150 .field("queue_limit", &self.queue_limit)
151 .field("client_cert", &self.client_cert)
152 .field("client_key", &redacted_secret(&self.client_key))
153 .field("client_ca", &self.client_ca)
154 .field("skip_tls_verify", &self.skip_tls_verify)
155 .field("target_type", &self.target_type)
156 .finish()
157 }
158}
159
160impl WebhookArgs {
161 pub fn validate(&self) -> Result<(), TargetError> {
163 if !self.enable {
164 return Ok(());
165 }
166
167 if self.endpoint.as_str().is_empty() {
168 return Err(TargetError::Configuration("endpoint empty".to_string()));
169 }
170 outbound_policy()?
171 .validate_url(&self.endpoint)
172 .map_err(|err| TargetError::Configuration(format!("webhook endpoint is not allowed: {err}")))?;
173
174 if !self.queue_dir.is_empty() {
175 let path = std::path::Path::new(&self.queue_dir);
176 if !path.is_absolute() {
177 return Err(TargetError::Configuration("webhook queue_dir path should be absolute".to_string()));
178 }
179 }
180
181 if !self.client_cert.is_empty() && self.client_key.is_empty()
182 || self.client_cert.is_empty() && !self.client_key.is_empty()
183 {
184 return Err(TargetError::Configuration("cert and key must be specified as a pair".to_string()));
185 }
186
187 if self.skip_tls_verify && !self.client_ca.is_empty() {
188 return Err(TargetError::Configuration(
189 "skip_tls_verify and client_ca are mutually exclusive; remove client_ca or disable skip_tls_verify".to_string(),
190 ));
191 }
192
193 Ok(())
194 }
195}
196
197pub struct WebhookTarget<E>
199where
200 E: PluginEvent,
201{
202 id: TargetID,
203 args: WebhookArgs,
204 health_check_url: Option<Url>,
205 http_client: Arc<Mutex<Client>>,
206 tls_state: Arc<Mutex<TargetTlsState>>,
207 tls_adapter: Option<TlsReloadAdapter<Client>>,
210 store: Option<Box<dyn Store<QueuedPayload, Error = StoreError, Key = Key> + Send + Sync>>,
212 initialized: AtomicBool,
213 cancel_sender: mpsc::Sender<()>,
214 delivery_counters: Arc<TargetDeliveryCounters>,
215 _phantom: PhantomData<E>,
216}
217
218impl<E> WebhookTarget<E>
219where
220 E: PluginEvent,
221{
222 pub fn clone_box(&self) -> Box<dyn Target<E> + Send + Sync> {
224 Box::new(WebhookTarget::<E> {
225 id: self.id.clone(),
226 args: self.args.clone(),
227 health_check_url: self.health_check_url.clone(),
228 http_client: Arc::clone(&self.http_client),
229 tls_state: Arc::clone(&self.tls_state),
230 tls_adapter: self.tls_adapter.clone(),
231 store: self.store.as_ref().map(|s| s.boxed_clone()),
232 initialized: AtomicBool::new(self.initialized.load(Ordering::SeqCst)),
233 cancel_sender: self.cancel_sender.clone(),
234 delivery_counters: Arc::clone(&self.delivery_counters),
235 _phantom: PhantomData,
236 })
237 }
238
239 #[instrument(skip(args), fields(target_id = %id))]
241 pub fn new(id: String, args: WebhookArgs) -> Result<Self, TargetError> {
242 args.validate()?;
244 let target_id = TargetID::new(id, ChannelTargetType::Webhook.as_str().to_string());
246 let health_check_url = if args.enable {
247 Some(Self::health_check_url(&args.endpoint)?)
248 } else {
249 None
250 };
251
252 let http_client = if args.enable {
253 Self::build_http_client(&args)?
254 } else {
255 Client::builder()
256 .no_proxy()
257 .redirect(reqwest::redirect::Policy::none())
258 .build()
259 .map_err(|e| TargetError::Configuration(format!("Failed to build disabled webhook HTTP client: {e}")))?
260 };
261 let http_client = Arc::new(Mutex::new(http_client));
262
263 let queue_store = open_target_queue_store(
264 &args.queue_dir,
265 args.queue_limit,
266 args.target_type,
267 ChannelTargetType::Webhook.as_str(),
268 &target_id,
269 "Failed to open store for Webhook target",
270 )?;
271
272 let (cancel_sender, _) = mpsc::channel(1);
274 info!(
275 event = EVENT_WEBHOOK_TARGET_STATE,
276 component = LOG_COMPONENT_TARGETS,
277 subsystem = LOG_SUBSYSTEM_WEBHOOK,
278 target_id = %target_id.id,
279 state = "created",
280 "webhook target state"
281 );
282 Ok(WebhookTarget::<E> {
283 id: target_id,
284 args,
285 health_check_url,
286 http_client,
287 tls_state: Arc::new(Mutex::new(TargetTlsState::default())),
288 tls_adapter: None,
289 store: queue_store,
290 initialized: AtomicBool::new(false),
291 cancel_sender,
292 delivery_counters: Arc::new(TargetDeliveryCounters::default()),
293 _phantom: PhantomData,
294 })
295 }
296
297 fn build_http_client(args: &WebhookArgs) -> Result<Client, TargetError> {
298 let resolver = outbound_policy()?
299 .resolver_for(&args.endpoint)
300 .map_err(|err| TargetError::Configuration(format!("webhook endpoint is not allowed: {err}")))?;
301 Self::build_http_client_with_resolver(args, resolver)
302 }
303
304 fn build_http_client_with_resolver(
305 args: &WebhookArgs,
306 resolver: impl reqwest::dns::Resolve + 'static,
307 ) -> Result<Client, TargetError> {
308 let mut client_builder = Client::builder()
309 .no_proxy()
310 .dns_resolver(resolver)
311 .timeout(Duration::from_secs(30))
312 .redirect(reqwest::redirect::Policy::none())
318 .user_agent(crate::get_user_agent(crate::ServiceType::Basis));
319 if args.skip_tls_verify {
321 client_builder = client_builder.danger_accept_invalid_certs(true);
323 warn!(
324 event = EVENT_WEBHOOK_TARGET_STATE,
325 component = LOG_COMPONENT_TARGETS,
326 subsystem = LOG_SUBSYSTEM_WEBHOOK,
327 endpoint_origin = %args.endpoint.origin().ascii_serialization(),
328 state = "tls_verification_skipped",
329 fallback = "danger_accept_invalid_certs",
330 "webhook target state"
331 );
332 } else if !args.client_ca.is_empty() {
333 let certs_der = load_cert_bundle_der_bytes(&args.client_ca)
335 .map_err(|e| TargetError::Configuration(format!("Failed to parse root CA cert: {e}")))?;
336 if certs_der.is_empty() {
337 return Err(TargetError::Configuration(
338 "Webhook client_ca did not contain any parsable certificates".to_string(),
339 ));
340 }
341 for cert_der in certs_der {
342 let ca_cert = reqwest::Certificate::from_der(&cert_der)
343 .map_err(|e| TargetError::Configuration(format!("Failed to load root CA cert: {e}")))?;
344 client_builder = client_builder.add_root_certificate(ca_cert);
345 }
346 }
347 if !args.client_cert.is_empty() && !args.client_key.is_empty() {
351 let cert = std::fs::read(&args.client_cert)
352 .map_err(|e| TargetError::Configuration(format!("Failed to read client cert: {e}")))?;
353 let key = std::fs::read(&args.client_key)
354 .map_err(|e| TargetError::Configuration(format!("Failed to read client key: {e}")))?;
355
356 let identity = reqwest::Identity::from_pem(&[cert, key].concat())
357 .map_err(|e| TargetError::Configuration(format!("Failed to create identity for mTLS: {e}")))?;
358 client_builder = client_builder.identity(identity);
359 }
360
361 client_builder
362 .build()
363 .map_err(|e| TargetError::Configuration(format!("Failed to build HTTP client: {e}")))
364 }
365
366 async fn refresh_tls(&self) -> Result<(), TargetError> {
367 let next_fingerprint =
368 build_target_tls_fingerprint(&self.args.client_ca, &self.args.client_cert, &self.args.client_key).await?;
369 let tls_changed = {
370 let tls_state_guard = self.tls_state.lock();
371 tls_state_guard.fingerprint.as_ref() != Some(&next_fingerprint)
372 };
373 if !tls_changed {
374 return Ok(());
375 }
376
377 let new_client = Self::build_http_client(&self.args)?;
378 {
379 let mut tls_state_guard = self.tls_state.lock();
380 if tls_state_guard.fingerprint.as_ref() == Some(&next_fingerprint) {
381 return Ok(());
382 }
383 *self.http_client.lock() = new_client;
384 tls_state_guard.refresh(next_fingerprint);
385 }
386 Ok(())
387 }
388
389 fn health_check_url(endpoint: &Url) -> Result<Url, TargetError> {
390 endpoint
391 .host()
392 .ok_or_else(|| TargetError::Configuration("Webhook endpoint is missing a host".to_string()))?;
393 let mut health_check_url = endpoint.clone();
394 health_check_url
395 .set_username("")
396 .map_err(|_| TargetError::Configuration("Webhook endpoint contains invalid user information".to_string()))?;
397 health_check_url
398 .set_password(None)
399 .map_err(|_| TargetError::Configuration("Webhook endpoint contains invalid user information".to_string()))?;
400 health_check_url.set_path("/");
401 health_check_url.set_query(None);
402 health_check_url.set_fragment(None);
403
404 Ok(health_check_url)
405 }
406
407 async fn probe_health(&self) -> TargetHealth {
408 let Some(health_check_url) = self.health_check_url.as_ref() else {
409 return TargetHealth::offline(TargetHealthReason::Unreachable);
410 };
411 let client = self.http_client.lock().clone();
412 let health = probe_health_url(&client, health_check_url).await;
413 if health.state == TargetHealthState::Online {
414 debug!(
415 event = EVENT_WEBHOOK_TARGET_STATE,
416 component = LOG_COMPONENT_TARGETS,
417 subsystem = LOG_SUBSYSTEM_WEBHOOK,
418 target_id = %self.id,
419 state = "reachability_probe_succeeded",
420 "webhook target state"
421 );
422 }
423 health
424 }
425
426 async fn probe_reachability(&self) -> Result<bool, TargetError> {
427 let health = self.probe_health().await;
428 match health.state {
429 TargetHealthState::Online => Ok(true),
430 TargetHealthState::Offline | TargetHealthState::Disabled => Ok(false),
431 TargetHealthState::Error => match health.reason {
432 TargetHealthReason::TimedOut => Err(TargetError::Timeout("Webhook health check timed out".to_string())),
433 _ => Err(TargetError::Network(format!("Webhook health check failed: {}", health.reason.as_str()))),
434 },
435 }
436 }
437
438 async fn init_inner(&self) -> Result<(), TargetError> {
439 if self.initialized.load(Ordering::SeqCst) {
440 return Ok(());
441 }
442
443 if !self.args.enable {
444 return Ok(());
445 }
446
447 match self.probe_reachability().await {
450 Ok(true) => {
451 debug!(
452 event = EVENT_WEBHOOK_TARGET_STATE,
453 component = LOG_COMPONENT_TARGETS,
454 subsystem = LOG_SUBSYSTEM_WEBHOOK,
455 target_id = %self.id,
456 state = "reachable",
457 "webhook target state"
458 );
459 }
460 Ok(false) => {
461 return Err(TargetError::NotConnected);
462 }
463 Err(err) => {
464 return Err(err);
465 }
466 }
467
468 self.initialized.store(true, Ordering::SeqCst);
469 info!(
470 event = EVENT_WEBHOOK_TARGET_STATE,
471 component = LOG_COMPONENT_TARGETS,
472 subsystem = LOG_SUBSYSTEM_WEBHOOK,
473 target_id = %self.id,
474 state = "initialized",
475 "webhook target state"
476 );
477 Ok(())
478 }
479
480 fn build_queued_payload(&self, event: &EntityTarget<E>) -> Result<QueuedPayload, TargetError> {
481 build_queued_payload(event)
482 }
483
484 #[hotpath::measure]
485 async fn send_body(&self, body: Vec<u8>, meta: &QueuedPayloadMeta) -> Result<(), TargetError> {
486 debug!(
487 event = EVENT_WEBHOOK_DELIVERY_STATE,
488 component = LOG_COMPONENT_TARGETS,
489 subsystem = LOG_SUBSYSTEM_WEBHOOK,
490 target_id = %self.id,
491 bucket = %meta.bucket_name,
492 object = %meta.object_name,
493 payload_event = %meta.event_name,
494 payload_len = body.len(),
495 state = "sending",
496 "webhook delivery state"
497 );
498
499 if self.tls_adapter.is_none() {
502 self.refresh_tls().await?;
503 }
504
505 let client = self.http_client.lock().clone();
506 let mut req_builder = client
507 .post(self.args.endpoint.as_str())
508 .header("Content-Type", meta.content_type.as_str());
509
510 if !self.args.auth_token.is_empty() {
511 match self.args.auth_token.split_whitespace().count() {
513 2 => {
514 req_builder = req_builder.header("Authorization", &self.args.auth_token);
516 }
517 1 => {
518 req_builder = req_builder.header("Authorization", format!("Bearer {}", self.args.auth_token));
520 }
521 _ => {
522 }
524 }
525 }
526
527 let resp = req_builder.body(body).send().await.map_err(|e| {
529 if e.is_timeout() || e.is_connect() {
530 TargetError::NotConnected
531 } else {
532 TargetError::Request("Webhook delivery request failed".to_string())
533 }
534 })?;
535
536 let status = resp.status();
537 let _ = resp.bytes().await;
541 let result = classify_delivery_status(status);
542 if result.is_ok() {
543 debug!(
544 event = EVENT_WEBHOOK_DELIVERY_STATE,
545 component = LOG_COMPONENT_TARGETS,
546 subsystem = LOG_SUBSYSTEM_WEBHOOK,
547 target_id = %self.id,
548 status = %status,
549 state = "sent",
550 "webhook delivery state"
551 );
552 self.delivery_counters.record_success();
553 }
554 result
555 }
556}
557
558fn outbound_policy() -> Result<&'static OutboundPolicy, TargetError> {
559 OutboundPolicy::from_env_cached().map_err(|err| TargetError::Configuration(format!("invalid outbound policy: {err}")))
560}
561
562#[async_trait]
563impl<E> Target<E> for WebhookTarget<E>
564where
565 E: PluginEvent,
566{
567 fn id(&self) -> TargetID {
568 self.id.clone()
569 }
570
571 async fn is_active(&self) -> Result<bool, TargetError> {
572 if !self.args.enable {
573 return Ok(false);
574 }
575
576 self.probe_reachability().await
577 }
578
579 async fn health(&self) -> TargetHealth {
580 if !self.args.enable {
581 return TargetHealth::disabled();
582 }
583 self.probe_health().await
584 }
585
586 async fn save(&self, event: Arc<EntityTarget<E>>) -> Result<(), TargetError> {
587 let queued = match self.build_queued_payload(&event) {
588 Ok(queued) => queued,
589 Err(err) => {
590 self.delivery_counters.record_final_failure();
591 return Err(err);
592 }
593 };
594
595 if let Some(store) = &self.store {
596 if let Err(e) = persist_queued_payload_to_store(store.as_ref(), &queued) {
597 self.delivery_counters.record_final_failure();
598 return Err(e);
599 }
600 debug!(
601 event = EVENT_WEBHOOK_DELIVERY_STATE,
602 component = LOG_COMPONENT_TARGETS,
603 subsystem = LOG_SUBSYSTEM_WEBHOOK,
604 target_id = %self.id,
605 state = "store_enqueued",
606 "webhook delivery state"
607 );
608 Ok(())
609 } else {
610 match self.init().await {
611 Ok(_) => (),
612 Err(e) => {
613 error!(
614 event = EVENT_WEBHOOK_TARGET_STATE,
615 component = LOG_COMPONENT_TARGETS,
616 subsystem = LOG_SUBSYSTEM_WEBHOOK,
617 target_id = %self.id.id,
618 state = "init_failed",
619 error = %e,
620 "webhook target state"
621 );
622 self.delivery_counters.record_final_failure();
623 return Err(TargetError::NotConnected);
624 }
625 }
626 if let Err(err) = self.send_body(queued.body, &queued.meta).await {
627 self.delivery_counters.record_final_failure();
628 return Err(err);
629 }
630 Ok(())
631 }
632 }
633
634 async fn send_raw_from_store(&self, key: Key, body: Vec<u8>, meta: QueuedPayloadMeta) -> Result<(), TargetError> {
635 debug!(
636 event = EVENT_WEBHOOK_DELIVERY_STATE,
637 component = LOG_COMPONENT_TARGETS,
638 subsystem = LOG_SUBSYSTEM_WEBHOOK,
639 target_id = %self.id,
640 key = %key,
641 state = "store_replay_started",
642 "webhook delivery state"
643 );
644 match self.init().await {
645 Ok(_) => {}
646 Err(e) => {
647 error!(
648 event = EVENT_WEBHOOK_TARGET_STATE,
649 component = LOG_COMPONENT_TARGETS,
650 subsystem = LOG_SUBSYSTEM_WEBHOOK,
651 target_id = %self.id.id,
652 state = "init_failed",
653 error = %e,
654 "webhook target state"
655 );
656 return Err(TargetError::NotConnected);
657 }
658 }
659
660 if let Err(e) = self.send_body(body, &meta).await {
661 if let TargetError::NotConnected = e {
662 return Err(TargetError::NotConnected);
663 }
664 return Err(e);
665 }
666
667 debug!(
668 event = EVENT_WEBHOOK_DELIVERY_STATE,
669 component = LOG_COMPONENT_TARGETS,
670 subsystem = LOG_SUBSYSTEM_WEBHOOK,
671 target_id = %self.id,
672 key = %key,
673 state = "store_replay_sent",
674 "webhook delivery state"
675 );
676 Ok(())
677 }
678
679 async fn close(&self) -> Result<(), TargetError> {
680 let _ = self.cancel_sender.try_send(());
682 info!(
684 event = EVENT_WEBHOOK_TARGET_STATE,
685 component = LOG_COMPONENT_TARGETS,
686 subsystem = LOG_SUBSYSTEM_WEBHOOK,
687 target_id = %self.id,
688 state = "closed",
689 "webhook target state"
690 );
691 Ok(())
692 }
693
694 fn store(&self) -> Option<&(dyn Store<QueuedPayload, Error = StoreError, Key = Key> + Send + Sync)> {
695 self.store.as_deref()
697 }
698
699 fn clone_dyn(&self) -> Box<dyn Target<E> + Send + Sync> {
700 self.clone_box()
701 }
702
703 async fn init(&self) -> Result<(), TargetError> {
704 if !self.is_enabled() {
705 debug!(
706 event = EVENT_WEBHOOK_TARGET_STATE,
707 component = LOG_COMPONENT_TARGETS,
708 subsystem = LOG_SUBSYSTEM_WEBHOOK,
709 target_id = %self.id,
710 state = "disabled",
711 "webhook target state"
712 );
713 return Ok(());
714 }
715 self.init_inner().await
716 }
717
718 fn is_enabled(&self) -> bool {
719 self.args.enable
720 }
721
722 fn delivery_snapshot(&self) -> TargetDeliverySnapshot {
723 self.delivery_counters.snapshot(
724 self.store.as_deref().map_or(0, |store| store.len() as u64),
725 0,
727 )
728 }
729
730 fn record_final_failure(&self) {
731 self.delivery_counters.record_final_failure();
732 }
733}
734
735#[async_trait]
740impl<E> ReloadableTargetTls for WebhookTarget<E>
741where
742 E: PluginEvent,
743{
744 type Material = Client;
745
746 fn tls_input_set(&self) -> TargetTlsInputSet {
747 TargetTlsInputSet {
748 ca_path: self.args.client_ca.clone(),
749 client_cert_path: self.args.client_cert.clone(),
750 client_key_path: self.args.client_key.clone(),
751 target_label: format!("webhook:{}", self.id.id),
752 }
753 }
754
755 async fn build_tls_material(&self) -> Result<Self::Material, TargetError> {
756 Self::build_http_client(&self.args)
760 }
761
762 async fn apply_tls_material(
763 &self,
764 _generation: TargetTlsGeneration,
765 material: Arc<Self::Material>,
766 _mode: ReloadApplyMode,
767 ) -> Result<(), TargetError> {
768 *self.http_client.lock() = (*material).clone();
769 Ok(())
770 }
771
772 async fn validate_tls_files(&self) -> Result<(), TargetError> {
773 validate_tls_material(&self.args.client_ca, &self.args.client_cert, &self.args.client_key)
774 }
775}
776
777#[cfg(test)]
778mod tests {
779 use super::{WebhookArgs, WebhookTarget, classify_delivery_status, probe_health_url};
780 use crate::target::{REDACTED_SECRET, Target, TargetHealthReason, TargetHealthState, TargetType, decode_object_name};
781 use std::net::{IpAddr, SocketAddr};
782 use tokio::io::{AsyncReadExt, AsyncWriteExt};
783 use tokio::net::TcpListener;
784 use url::Url;
785 use url::form_urlencoded;
786
787 #[derive(Clone)]
788 struct StaticResolver(IpAddr);
789
790 impl reqwest::dns::Resolve for StaticResolver {
791 fn resolve(&self, _name: reqwest::dns::Name) -> reqwest::dns::Resolving {
792 let address = SocketAddr::new(self.0, 0);
793 Box::pin(async move { Ok(Box::new(std::iter::once(address)) as reqwest::dns::Addrs) })
794 }
795 }
796
797 #[derive(Clone)]
798 struct FailingResolver;
799
800 impl reqwest::dns::Resolve for FailingResolver {
801 fn resolve(&self, _name: reqwest::dns::Name) -> reqwest::dns::Resolving {
802 Box::pin(async { Err(std::io::Error::new(std::io::ErrorKind::NotFound, "test DNS failure").into()) })
803 }
804 }
805
806 #[derive(Clone, Default)]
807 struct CapturedLog(std::sync::Arc<std::sync::Mutex<Vec<u8>>>);
808
809 struct CapturedLogWriter(std::sync::Arc<std::sync::Mutex<Vec<u8>>>);
810
811 impl std::io::Write for CapturedLogWriter {
812 fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
813 self.0.lock().expect("captured log lock").extend_from_slice(buf);
814 Ok(buf.len())
815 }
816
817 fn flush(&mut self) -> std::io::Result<()> {
818 Ok(())
819 }
820 }
821
822 impl<'writer> tracing_subscriber::fmt::MakeWriter<'writer> for CapturedLog {
823 type Writer = CapturedLogWriter;
824
825 fn make_writer(&'writer self) -> Self::Writer {
826 CapturedLogWriter(self.0.clone())
827 }
828 }
829
830 impl CapturedLog {
831 fn contents(&self) -> String {
832 String::from_utf8(self.0.lock().expect("captured log lock").clone()).expect("captured logs must be UTF-8")
833 }
834 }
835
836 fn base_args() -> WebhookArgs {
837 WebhookArgs {
838 enable: true,
839 endpoint: Url::parse("https://example.com/hook").unwrap(),
840 auth_token: String::new(),
841 queue_dir: String::new(),
842 queue_limit: 0,
843 client_cert: String::new(),
844 client_key: String::new(),
845 client_ca: String::new(),
846 skip_tls_verify: false,
847 target_type: TargetType::NotifyEvent,
848 }
849 }
850
851 async fn http_status_url(status: u16) -> Url {
852 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.expect("bind test server");
853 let address = listener.local_addr().expect("test server address");
854 tokio::spawn(async move {
855 let (mut stream, _) = listener.accept().await.expect("accept health probe");
856 let mut request = [0u8; 1024];
857 let _ = stream.read(&mut request).await;
858 stream
859 .write_all(format!("HTTP/1.1 {status} Test\r\nContent-Length: 0\r\nConnection: close\r\n\r\n").as_bytes())
860 .await
861 .expect("write health response");
862 });
863 Url::parse(&format!("http://{address}/")).expect("health probe URL")
864 }
865
866 #[test]
867 fn debug_redacts_webhook_secret_fields() {
868 let args = WebhookArgs {
869 endpoint: Url::parse("https://user:password@example.com/private?token=query-secret").expect("debug URL"),
870 auth_token: "webhook-token".to_string(),
871 client_key: "/etc/rustfs/webhook.key".to_string(),
872 ..base_args()
873 };
874
875 let rendered = format!("{args:?}");
876
877 assert!(!rendered.contains("webhook-token"));
878 assert!(!rendered.contains("/etc/rustfs/webhook.key"));
879 assert!(!rendered.contains("password"));
880 assert!(!rendered.contains("/private"));
881 assert!(!rendered.contains("query-secret"));
882 assert!(rendered.contains(REDACTED_SECRET));
883 assert!(rendered.contains("https://example.com"));
884 assert!(rendered.contains("WebhookArgs"));
885 }
886
887 #[tokio::test]
888 async fn webhook_client_uses_the_supplied_connection_resolver() {
889 let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind resolver test listener");
890 let address = listener.local_addr().expect("resolver test listener address");
891 let server = tokio::spawn(async move {
892 let (mut stream, _) = listener.accept().await.expect("accept resolved webhook request");
893 let mut request = [0_u8; 1024];
894 let read = stream.read(&mut request).await.expect("read webhook request");
895 assert!(String::from_utf8_lossy(&request[..read]).starts_with("GET /hook HTTP/1.1"));
896 stream
897 .write_all(b"HTTP/1.1 204 No Content\r\nContent-Length: 0\r\nConnection: close\r\n\r\n")
898 .await
899 .expect("write webhook response");
900 });
901 let args = WebhookArgs {
902 endpoint: Url::parse(&format!("http://webhook.test:{}/hook", address.port())).expect("endpoint should parse"),
903 ..base_args()
904 };
905 let client = WebhookTarget::<serde_json::Value>::build_http_client_with_resolver(&args, StaticResolver(address.ip()))
906 .expect("webhook client should build");
907
908 let response = client
909 .get(args.endpoint)
910 .send()
911 .await
912 .expect("resolver should route request to test listener");
913 assert_eq!(response.status(), reqwest::StatusCode::NO_CONTENT);
914 server.await.expect("resolver test server should finish");
915 }
916
917 #[test]
918 fn webhook_client_ignores_environment_proxies_before_dns_filtering() {
919 const CHILD_ENV: &str = "RUSTFS_TEST_WEBHOOK_PROXY_CHILD";
920 const TARGET_URL_ENV: &str = "RUSTFS_TEST_WEBHOOK_PROXY_TARGET_URL";
921 const TARGET_ADDR_ENV: &str = "RUSTFS_TEST_WEBHOOK_PROXY_TARGET_ADDR";
922
923 if std::env::var_os(CHILD_ENV).is_some() {
924 let endpoint = Url::parse(&std::env::var(TARGET_URL_ENV).expect("child target URL")).expect("target URL");
925 let address = std::env::var(TARGET_ADDR_ENV)
926 .expect("child target address")
927 .parse::<SocketAddr>()
928 .expect("target address");
929 let args = WebhookArgs { endpoint, ..base_args() };
930 let client = WebhookTarget::<serde_json::Value>::build_http_client_with_resolver(&args, StaticResolver(address.ip()))
931 .expect("webhook client should build");
932 tokio::runtime::Runtime::new().expect("child runtime").block_on(async {
933 let response = client
934 .get(args.endpoint)
935 .send()
936 .await
937 .expect("webhook request should bypass environment proxy");
938 assert_eq!(response.status(), reqwest::StatusCode::NO_CONTENT);
939 });
940 return;
941 }
942
943 use std::io::{Read, Write};
944 use std::net::TcpListener as StdTcpListener;
945
946 let target_listener = StdTcpListener::bind("127.0.0.1:0").expect("bind webhook target listener");
947 let target_address = target_listener.local_addr().expect("webhook target address");
948 let target = std::thread::spawn(move || {
949 let (mut stream, _) = target_listener.accept().expect("accept direct webhook request");
950 let mut request = [0_u8; 1024];
951 let read = stream.read(&mut request).expect("read direct webhook request");
952 assert!(String::from_utf8_lossy(&request[..read]).starts_with("GET /hook HTTP/1.1"));
953 stream
954 .write_all(b"HTTP/1.1 204 No Content\r\nContent-Length: 0\r\nConnection: close\r\n\r\n")
955 .expect("write direct webhook response");
956 });
957 let proxy_listener = StdTcpListener::bind("127.0.0.1:0").expect("reserve refused proxy address");
958 let proxy_address = proxy_listener.local_addr().expect("proxy address");
959 drop(proxy_listener);
960 let target_url = format!("http://webhook.test:{}/hook", target_address.port());
961 let proxy_url = format!("http://{proxy_address}");
962 let output = std::process::Command::new(std::env::current_exe().expect("resolve current test executable"))
963 .arg("webhook_client_ignores_environment_proxies_before_dns_filtering")
964 .arg("--nocapture")
965 .env(CHILD_ENV, "1")
966 .env(TARGET_URL_ENV, target_url)
967 .env(TARGET_ADDR_ENV, target_address.to_string())
968 .env("HTTP_PROXY", &proxy_url)
969 .env("HTTPS_PROXY", &proxy_url)
970 .env("ALL_PROXY", &proxy_url)
971 .env("NO_PROXY", "")
972 .output()
973 .expect("run isolated proxy test child");
974
975 assert!(
976 output.status.success(),
977 "proxy test child failed: stdout={} stderr={}",
978 String::from_utf8_lossy(&output.stdout),
979 String::from_utf8_lossy(&output.stderr)
980 );
981 target.join().expect("direct webhook target should finish");
982 }
983
984 #[test]
985 fn test_validate_skip_tls_verify_and_client_ca_mutually_exclusive() {
986 let args = WebhookArgs {
987 skip_tls_verify: true,
988 client_ca: "/path/to/ca.pem".to_string(),
989 ..base_args()
990 };
991 let result = args.validate();
992 assert!(result.is_err());
993 let err_msg = result.unwrap_err().to_string();
994 assert!(
995 err_msg.contains("skip_tls_verify") && err_msg.contains("client_ca"),
996 "Error message should mention both fields, got: {err_msg}"
997 );
998 }
999
1000 #[test]
1001 fn test_validate_skip_tls_verify_without_client_ca_is_ok() {
1002 let args = WebhookArgs {
1003 skip_tls_verify: true,
1004 ..base_args()
1005 };
1006 assert!(args.validate().is_ok());
1007 }
1008
1009 #[test]
1010 fn test_validate_client_ca_without_skip_tls_verify_is_ok() {
1011 let args = WebhookArgs {
1012 client_ca: "/path/to/ca.pem".to_string(),
1013 ..base_args()
1014 };
1015 assert!(args.validate().is_ok());
1016 }
1017
1018 #[test]
1019 fn webhook_tls_warning_redacts_endpoint_details() {
1020 let captured = CapturedLog::default();
1021 let subscriber = tracing_subscriber::fmt()
1022 .with_ansi(false)
1023 .without_time()
1024 .with_max_level(tracing::Level::WARN)
1025 .with_writer(captured.clone())
1026 .finish();
1027 let args = WebhookArgs {
1028 endpoint: Url::parse("https://webhook.test/private?token=secret").expect("webhook endpoint"),
1029 skip_tls_verify: true,
1030 ..base_args()
1031 };
1032
1033 tracing::subscriber::with_default(subscriber, || {
1034 WebhookTarget::<serde_json::Value>::build_http_client_with_resolver(
1035 &args,
1036 StaticResolver(IpAddr::V4(std::net::Ipv4Addr::LOCALHOST)),
1037 )
1038 .expect("webhook client should build");
1039 });
1040
1041 let logs = captured.contents();
1042 assert!(logs.contains("https://webhook.test"));
1043 for secret in ["/private", "token=secret"] {
1044 assert!(!logs.contains(secret), "TLS warning leaked {secret}: {logs}");
1045 }
1046 }
1047
1048 #[test]
1049 fn test_validate_rejects_loopback_endpoint() {
1050 let args = WebhookArgs {
1051 endpoint: Url::parse("https://127.0.0.1/hook").expect("loopback endpoint should parse"),
1052 ..base_args()
1053 };
1054 let err = args.validate().expect_err("loopback endpoint should be rejected");
1055 assert!(err.to_string().contains("not allowed"));
1056 }
1057
1058 #[test]
1059 fn test_decode_object_name_with_spaces() {
1060 let object_name = "greeting file (2).csv";
1062
1063 let form_encoded = form_urlencoded::byte_serialize(object_name.as_bytes()).collect::<String>();
1065 assert_eq!(form_encoded, "greeting+file+%282%29.csv");
1066
1067 let decoded = decode_object_name(&form_encoded).unwrap();
1069 assert_eq!(decoded, object_name);
1070 assert!(!decoded.contains('+'), "Decoded string should not contain + symbols");
1071 }
1072
1073 #[test]
1074 fn test_decode_object_name_with_special_chars() {
1075 let test_cases = vec![
1077 ("folder/greeting file (2).csv", "folder%2Fgreeting+file+%282%29.csv"),
1078 ("test file.txt", "test+file.txt"),
1079 ("my file (copy).pdf", "my+file+%28copy%29.pdf"),
1080 ("file with spaces and (parentheses).doc", "file+with+spaces+and+%28parentheses%29.doc"),
1081 ];
1082
1083 for (original, form_encoded) in test_cases {
1084 let decoded = decode_object_name(form_encoded).unwrap();
1086 assert_eq!(decoded, original, "Failed to decode: {}", form_encoded);
1087 }
1088 }
1089
1090 #[test]
1091 fn test_decode_object_name_without_spaces() {
1092 let object_name = "simple-file.txt";
1094 let form_encoded = form_urlencoded::byte_serialize(object_name.as_bytes()).collect::<String>();
1095
1096 let decoded = decode_object_name(&form_encoded).unwrap();
1097 assert_eq!(decoded, object_name);
1098 }
1099
1100 #[test]
1101 fn test_health_check_url_ignores_endpoint_path() {
1102 let endpoint = Url::parse("https://user:password@example.com:9443/hook/path?token=secret").expect("webhook endpoint URL");
1103 let health_check_url = WebhookTarget::<serde_json::Value>::health_check_url(&endpoint).expect("webhook health-check URL");
1104
1105 assert_eq!(health_check_url.as_str(), "https://example.com:9443/");
1106 }
1107
1108 #[tokio::test]
1109 async fn head_http_responses_only_measure_reachability() {
1110 let client = WebhookTarget::<serde_json::Value>::build_http_client(&base_args()).expect("build client");
1111 for status in [401, 404, 500] {
1112 let health = probe_health_url(&client, &http_status_url(status).await).await;
1113 assert_eq!(health.state, TargetHealthState::Online, "HEAD {status} is reachable");
1114 assert_eq!(health.reason, TargetHealthReason::Reachable);
1115 }
1116 }
1117
1118 #[tokio::test]
1119 async fn refused_connection_has_stable_health_reason() {
1120 let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("reserve refused port");
1121 let address = listener.local_addr().expect("refused address");
1122 drop(listener);
1123 let url = Url::parse(&format!("http://{address}/")).expect("refused URL");
1124 let client = WebhookTarget::<serde_json::Value>::build_http_client(&base_args()).expect("build client");
1125
1126 let health = probe_health_url(&client, &url).await;
1127
1128 assert_eq!(health.state, TargetHealthState::Error);
1129 assert_eq!(health.reason, TargetHealthReason::ConnectionRefused);
1130 }
1131
1132 #[tokio::test]
1133 async fn dns_failure_has_stable_health_reason() {
1134 let url = Url::parse("http://unresolvable.test/").expect("invalid test domain URL");
1135 let client = WebhookTarget::<serde_json::Value>::build_http_client_with_resolver(&base_args(), FailingResolver)
1136 .expect("build client");
1137
1138 let health = probe_health_url(&client, &url).await;
1139
1140 assert_eq!(health.state, TargetHealthState::Error);
1141 assert!(
1144 matches!(health.reason, TargetHealthReason::DnsFailure | TargetHealthReason::Unreachable),
1145 "expected DnsFailure or Unreachable, got {:?}",
1146 health.reason
1147 );
1148 }
1149
1150 #[tokio::test(start_paused = true)]
1151 async fn webhook_health_probe_has_a_five_second_total_budget() {
1152 let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
1153 .await
1154 .expect("bind stalled server");
1155 let address = listener.local_addr().expect("stalled server address");
1156 let server = tokio::spawn(async move {
1157 let (_stream, _) = listener.accept().await.expect("accept stalled probe");
1158 std::future::pending::<()>().await;
1159 });
1160 let url = Url::parse(&format!("http://{address}/")).expect("stalled URL");
1161 let client = WebhookTarget::<serde_json::Value>::build_http_client(&base_args()).expect("build client");
1162 let started = tokio::time::Instant::now();
1163
1164 let health = probe_health_url(&client, &url).await;
1165
1166 assert_eq!(started.elapsed(), super::WEBHOOK_HEALTH_TIMEOUT);
1167 assert_eq!(health.state, TargetHealthState::Error);
1168 assert_eq!(health.reason, TargetHealthReason::TimedOut);
1169 server.abort();
1170 }
1171
1172 #[tokio::test]
1173 async fn tls_failure_has_stable_health_reason() {
1174 use rustls::{
1175 ServerConfig, ServerConnection, StreamOwned,
1176 pki_types::{PrivateKeyDer, PrivatePkcs8KeyDer},
1177 };
1178 use std::io::Read;
1179 use std::sync::{Arc, Once};
1180
1181 static INSTALL_CRYPTO_PROVIDER: Once = Once::new();
1182 INSTALL_CRYPTO_PROVIDER.call_once(|| {
1183 let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();
1184 });
1185 let rcgen::CertifiedKey { cert, signing_key } =
1186 rcgen::generate_simple_self_signed(vec!["localhost".to_string()]).expect("cert should generate");
1187 let server_config = Arc::new(
1188 ServerConfig::builder()
1189 .with_no_client_auth()
1190 .with_single_cert(
1191 vec![cert.der().clone()],
1192 PrivateKeyDer::Pkcs8(PrivatePkcs8KeyDer::from(signing_key.serialize_der())),
1193 )
1194 .expect("server cert should be valid"),
1195 );
1196 let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind TLS server");
1197 let address = listener.local_addr().expect("TLS server address");
1198 let server = std::thread::spawn(move || {
1199 let (stream, _) = listener.accept().expect("accept TLS client");
1200 let connection = ServerConnection::new(server_config).expect("server connection");
1201 let mut tls_stream = StreamOwned::new(connection, stream);
1202 let mut request = [0u8; 1024];
1203 let _ = tls_stream.read(&mut request);
1204 });
1205 let url = Url::parse(&format!("https://localhost:{}/", address.port())).expect("TLS health URL");
1206 let client =
1207 WebhookTarget::<serde_json::Value>::build_http_client_with_resolver(&base_args(), StaticResolver(address.ip()))
1208 .expect("build client");
1209
1210 let health = probe_health_url(&client, &url).await;
1211
1212 assert_eq!(health.state, TargetHealthState::Error);
1213 assert_eq!(health.reason, TargetHealthReason::TlsFailure);
1214 server.join().expect("TLS server thread");
1215 }
1216
1217 #[test]
1218 fn post_status_classification_requires_success() {
1219 assert!(classify_delivery_status(reqwest::StatusCode::NO_CONTENT).is_ok());
1220 for status in [
1221 reqwest::StatusCode::MOVED_PERMANENTLY,
1222 reqwest::StatusCode::UNAUTHORIZED,
1223 reqwest::StatusCode::INTERNAL_SERVER_ERROR,
1224 ] {
1225 assert!(classify_delivery_status(status).is_err(), "POST {status} must fail");
1226 }
1227 }
1228
1229 #[tokio::test]
1230 async fn test_disabled_target_can_be_constructed_without_origin_probe() {
1231 let args = WebhookArgs {
1232 enable: false,
1233 endpoint: Url::parse("about:blank").unwrap(),
1234 ..base_args()
1235 };
1236 let target = WebhookTarget::<serde_json::Value>::new("disabled-target".to_string(), args).unwrap();
1237
1238 assert!(!target.is_active().await.unwrap());
1239 }
1240
1241 #[test]
1242 fn test_origin_reachability_probe_requires_non_local_endpoint() {
1243 let args = WebhookArgs {
1244 endpoint: Url::parse("http://127.0.0.1/hook").unwrap(),
1245 ..base_args()
1246 };
1247 let err = match WebhookTarget::<serde_json::Value>::new("path-probe".to_string(), args) {
1248 Ok(_) => panic!("loopback origin probes should now be rejected at construction time"),
1249 Err(err) => err,
1250 };
1251 assert!(err.to_string().contains("not allowed"));
1252 }
1253
1254 #[tokio::test]
1258 async fn test_webhook_client_does_not_follow_redirects() {
1259 use std::io::{Read, Write};
1260 use std::net::TcpListener;
1261
1262 let listener = TcpListener::bind("127.0.0.1:0").expect("bind mock server");
1264 let addr = listener.local_addr().expect("local addr");
1265
1266 let handle = std::thread::spawn(move || {
1270 if let Ok((mut stream, _)) = listener.accept() {
1271 let mut buf = [0u8; 1024];
1272 let _ = stream.read(&mut buf);
1273 let response =
1274 "HTTP/1.1 302 Found\r\nLocation: http://169.254.169.254/latest/meta-data/\r\nContent-Length: 0\r\n\r\n";
1275 let _ = stream.write_all(response.as_bytes());
1276 let _ = stream.flush();
1277 }
1278 });
1279
1280 let client = WebhookTarget::<serde_json::Value>::build_http_client(&base_args()).expect("build client");
1281
1282 let resp = client
1283 .post(format!("http://{addr}/hook"))
1284 .body("{}")
1285 .send()
1286 .await
1287 .expect("request should complete without following the redirect");
1288
1289 assert_eq!(resp.status().as_u16(), 302, "webhook client must not follow redirects");
1292
1293 handle.join().expect("mock server thread");
1294 }
1295
1296 #[tokio::test]
1297 async fn test_webhook_client_reaches_https_origin_with_custom_ca() {
1298 use rustls::{
1299 ServerConfig, ServerConnection, StreamOwned,
1300 pki_types::{PrivateKeyDer, PrivatePkcs8KeyDer},
1301 };
1302 use std::io::{Read, Write};
1303 use std::net::TcpListener;
1304 use std::sync::{Arc, Once};
1305
1306 static INSTALL_CRYPTO_PROVIDER: Once = Once::new();
1307 INSTALL_CRYPTO_PROVIDER.call_once(|| {
1308 let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();
1309 });
1310
1311 let rcgen::CertifiedKey { cert, signing_key } =
1312 rcgen::generate_simple_self_signed(vec!["localhost".to_string()]).expect("cert should generate");
1313 let temp_dir = tempfile::tempdir().expect("tempdir");
1314 let ca_path = temp_dir.path().join("webhook-ca.pem");
1315 std::fs::write(&ca_path, cert.pem()).expect("write ca pem");
1316
1317 let cert_chain = vec![cert.der().clone()];
1318 let key_der = PrivateKeyDer::Pkcs8(PrivatePkcs8KeyDer::from(signing_key.serialize_der()));
1319 let server_config = Arc::new(
1320 ServerConfig::builder()
1321 .with_no_client_auth()
1322 .with_single_cert(cert_chain, key_der)
1323 .expect("server cert should be valid"),
1324 );
1325
1326 let listener = TcpListener::bind("127.0.0.1:0").expect("bind tls server");
1327 let addr = listener.local_addr().expect("local addr");
1328 let handle = std::thread::spawn(move || {
1329 let (stream, _) = listener.accept().expect("accept tls client");
1330 let connection = ServerConnection::new(server_config).expect("server connection");
1331 let mut tls_stream = StreamOwned::new(connection, stream);
1332 let mut buf = [0u8; 1024];
1333 let _ = tls_stream.read(&mut buf);
1334 let response = "HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok";
1335 tls_stream.write_all(response.as_bytes()).expect("write response");
1336 tls_stream.flush().expect("flush response");
1337 });
1338
1339 let args = WebhookArgs {
1340 endpoint: Url::parse(&format!("https://localhost:{}/hook", addr.port())).expect("endpoint should parse"),
1341 client_ca: ca_path.to_string_lossy().into_owned(),
1342 ..base_args()
1343 };
1344 let client = WebhookTarget::<serde_json::Value>::build_http_client_with_resolver(&args, StaticResolver(addr.ip()))
1345 .expect("build https client");
1346 let resp = client
1347 .head(args.endpoint)
1348 .send()
1349 .await
1350 .expect("https webhook probe should trust configured ca");
1351
1352 assert_eq!(resp.status(), reqwest::StatusCode::OK);
1353 assert!(resp.bytes().await.expect("read response body").is_empty());
1354 handle.join().expect("tls server thread");
1355 }
1356}