use std::sync::Arc;
use std::time::Duration;
use reqwest::redirect::Policy;
use url::Url;
use crate::application::a2a::{
FilterDenial, NotificationConfig, NotificationConfigError, PushNotificationDelivery, UrlFilter,
};
use crate::domain::a2a::A2aVersion;
pub struct WebhookDelivery {
client: reqwest::Client,
filter: Arc<dyn UrlFilter>,
max_redirects: u8,
}
impl std::fmt::Debug for WebhookDelivery {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("WebhookDelivery")
.field("max_redirects", &self.max_redirects)
.finish_non_exhaustive()
}
}
impl WebhookDelivery {
#[must_use]
pub fn new(filter: Arc<dyn UrlFilter>) -> Self {
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(10))
.connect_timeout(Duration::from_secs(10))
.redirect(Policy::none())
.build();
let Ok(client) = client else {
return Self::with_client(reqwest::Client::new(), filter);
};
Self {
client,
filter,
max_redirects: 5,
}
}
#[must_use]
pub fn with_client(client: reqwest::Client, filter: Arc<dyn UrlFilter>) -> Self {
Self {
client,
filter,
max_redirects: 5,
}
}
}
impl PushNotificationDelivery for WebhookDelivery {
async fn deliver(
&self,
config: &NotificationConfig,
body: &[u8],
) -> Result<(), NotificationConfigError> {
let mut current_url: Url = config.webhook.as_url().clone();
for hop in 0..=self.max_redirects {
if hop == self.max_redirects {
return Err(NotificationConfigError::TransientFailure {
reason: format!("exceeded {} redirects", self.max_redirects),
});
}
let host = current_url
.host_str()
.ok_or(NotificationConfigError::Refused(FilterDenial::MissingHost))?
.to_owned();
let port = current_url
.port_or_known_default()
.ok_or(NotificationConfigError::Refused(FilterDenial::Malformed))?;
let mut resolved_ips = Vec::new();
let lookup = tokio::net::lookup_host((host.as_str(), port)).await;
match lookup {
Ok(addrs) => {
for addr in addrs {
resolved_ips.push(addr.ip());
}
}
Err(error) => {
return Err(NotificationConfigError::TransientFailure {
reason: format!("DNS resolution failed: {error}"),
});
}
}
if resolved_ips.is_empty() {
return Err(NotificationConfigError::TransientFailure {
reason: format!("no addresses resolved for {host}"),
});
}
for ip in &resolved_ips {
if let Err(_denial) =
self.filter
.check_at_delivery(¤t_url, *ip, A2aVersion::V1_0)
{
return Err(NotificationConfigError::Refused(FilterDenial::MissingHost));
}
}
let mut request = self
.client
.post(current_url.as_str())
.header("content-type", "application/json")
.body(body.to_vec());
if let Some(token) = &config.token {
request = request.header("x-a2a-notification-token", token.as_ref());
}
let response = request.send().await.map_err(|error| {
NotificationConfigError::TransientFailure {
reason: format!("HTTP POST failed: {error}"),
}
})?;
let status = response.status();
if status.is_success() {
return Ok(());
}
if status.is_redirection() {
if let Some(location) = response.headers().get(reqwest::header::LOCATION) {
let location_str = location.to_str().map_err(|error| {
NotificationConfigError::TransientFailure {
reason: format!("redirect location is not valid UTF-8: {error}"),
}
})?;
let next_url = current_url.join(location_str).map_err(|error| {
NotificationConfigError::TransientFailure {
reason: format!("redirect location is not a valid URL: {error}"),
}
})?;
current_url = next_url;
continue;
}
return Err(NotificationConfigError::TransientFailure {
reason: "redirect without a location".to_owned(),
});
}
return Err(NotificationConfigError::TransientFailure {
reason: format!("HTTP {status}"),
});
}
Err(NotificationConfigError::TransientFailure {
reason: "redirect loop ended without a response".to_owned(),
})
}
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use super::WebhookDelivery;
use crate::adapters::secondary::a2a::SsrfDenylist;
#[tokio::test]
async fn webhook_delivery_can_be_constructed_with_default_filter() {
let delivery = WebhookDelivery::new(Arc::new(SsrfDenylist::FILTER));
let _ = format!("{delivery:?}");
}
#[tokio::test]
async fn webhook_delivery_with_client_can_be_constructed() {
let client = reqwest::Client::new();
let delivery = WebhookDelivery::with_client(client, Arc::new(SsrfDenylist::FILTER));
let _ = format!("{delivery:?}");
}
}