Skip to main content

rustfs_targets/target/
webhook.rs

1// Copyright 2024 RustFS Team
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15use 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/// Arguments for configuring a Webhook target
119#[derive(Clone)]
120pub struct WebhookArgs {
121    /// Whether the target is enabled
122    pub enable: bool,
123    /// The endpoint URL to send events to
124    pub endpoint: Url,
125    /// The authorization token for the endpoint
126    pub auth_token: String,
127    /// The directory to store events in case of failure
128    pub queue_dir: String,
129    /// The maximum number of events to store
130    pub queue_limit: u64,
131    /// The client certificate for TLS (PEM format)
132    pub client_cert: String,
133    /// The client key for TLS (PEM format)
134    pub client_key: String,
135    /// The path to a custom client root CA certificate file (PEM format) to trust the server.
136    pub client_ca: String,
137    /// Skip TLS certificate verification. DANGEROUS: for testing only.
138    pub skip_tls_verify: bool,
139    /// the target type
140    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    /// WebhookArgs verification method
162    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
197/// A target that sends events to a webhook
198pub 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    /// When present, the adapter provides coordinator-managed TLS material;
208    /// otherwise the inline fingerprint path is used as a fallback.
209    tls_adapter: Option<TlsReloadAdapter<Client>>,
210    // Add Send + Sync constraints to ensure thread safety
211    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    /// Clones the WebhookTarget, creating a new instance with the same configuration
223    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    /// Creates a new WebhookTarget
240    #[instrument(skip(args), fields(target_id = %id))]
241    pub fn new(id: String, args: WebhookArgs) -> Result<Self, TargetError> {
242        // First verify the parameters
243        args.validate()?;
244        // Create a TargetID
245        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        // Create a cancel channel
273        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            // SSRF hardening (backlog#974): never follow HTTP redirects on webhook delivery.
313            // reqwest follows up to 10 redirects by default, which lets a malicious or
314            // compromised endpoint use a 3xx response to bounce the outbound request to an
315            // internal address (e.g. the cloud metadata service at 169.254.169.254),
316            // bypassing the outbound-endpoint validation performed on the configured URL.
317            .redirect(reqwest::redirect::Policy::none())
318            .user_agent(crate::get_user_agent(crate::ServiceType::Basis));
319        // 1. Configure server certificate verification
320        if args.skip_tls_verify {
321            // DANGEROUS: For testing only, skip all certificate verification
322            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            // Use user-provided custom CA certificate
334            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 neither is set, use the system's default trust store
348
349        // 2. Configure client certificate (mTLS)
350        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        // Use the configured reqwest client against the origin URL so proxy and TLS
448        // behavior matches real delivery while avoiding path-specific false negatives.
449        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        // When a TLS reload adapter is attached, it drives client rebuilds in
500        // the background. The inline per-send fingerprint check is skipped.
501        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            // Split auth_token string to check if the authentication type is included
512            match self.args.auth_token.split_whitespace().count() {
513                2 => {
514                    // Already include authentication type and token, such as "Bearer token123"
515                    req_builder = req_builder.header("Authorization", &self.args.auth_token);
516                }
517                1 => {
518                    // Only tokens, need to add "Bearer" prefix
519                    req_builder = req_builder.header("Authorization", format!("Bearer {}", self.args.auth_token));
520                }
521                _ => {
522                    // Empty string or other situations, no authentication header is added
523                }
524            }
525        }
526
527        // Send a request
528        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        // Drain the response body so the underlying connection is returned to the
538        // pool and can be reused (keep-alive) instead of being closed mid-stream
539        // (backlog#983). The body content is not needed for delivery accounting.
540        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        // Send cancel signal to background tasks
681        let _ = self.cancel_sender.try_send(());
682        // Adapter cleanup is done by the coordinator; no local state to reset.
683        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        // Returns the reference to the internal store
696        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            // Webhook targets record no terminal failures and keep no failed store.
726            0,
727        )
728    }
729
730    fn record_final_failure(&self) {
731        self.delivery_counters.record_final_failure();
732    }
733}
734
735/// Coordinated TLS hot-reload implementation for Webhook targets.
736///
737/// The coordinator calls these methods on a background poll loop to detect
738/// TLS file changes and rebuild the HTTP client without restarting.
739#[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        // build_http_client is synchronous (reads files + configures reqwest).
757        // The coordinator already runs this in a background task, so the
758        // synchronous file I/O does not block the send path.
759        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        // Test case from the issue: "greeting file (2).csv"
1061        let object_name = "greeting file (2).csv";
1062
1063        // Simulate what event.rs does: form-urlencoded encoding (spaces become +)
1064        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        // Test the decode_object_name helper function
1068        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        // Test with various special characters
1076        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            // Test the decode_object_name helper function
1085            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        // Test that files without spaces still work correctly
1093        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        // On systems with DNS interception (common on macOS), `.invalid` may resolve
1142        // to an interception address, producing `Unreachable` instead of `DnsFailure`.
1143        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    // SSRF hardening regression (backlog#974): the delivery client must not follow HTTP
1255    // redirects, otherwise a 3xx from the endpoint could bounce the outbound request to an
1256    // internal address (e.g. the cloud metadata service) and bypass endpoint validation.
1257    #[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        // Minimal HTTP server on an ephemeral loopback port.
1263        let listener = TcpListener::bind("127.0.0.1:0").expect("bind mock server");
1264        let addr = listener.local_addr().expect("local addr");
1265
1266        // Serve exactly one request with a 302 pointing at an internal metadata address.
1267        // If the client followed the redirect it would issue a second request to that
1268        // (unreachable) target instead of returning the 3xx status.
1269        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        // Redirects are disabled, so the 3xx is surfaced as-is rather than chased to the
1290        // internal Location target.
1291        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}