1use super::rpc::{SendStreamingMessageResponse, StreamingEvent, TaskPushNotificationConfig};
7use reqwest::Client;
8use std::time::Duration;
9use tracing::{debug, warn};
10use url::{Host, Url};
11
12pub(crate) fn parse_webhook_url(raw_url: &str) -> Result<Url, String> {
17 let url = Url::parse(raw_url).map_err(|error| format!("Webhook URL must be a valid absolute URL: {error}"))?;
18
19 if url.username() != "" || url.password().is_some() {
20 return Err("Webhook URL must not contain credentials".to_string());
21 }
22 if url.fragment().is_some() {
23 return Err("Webhook URL must not contain a fragment".to_string());
24 }
25
26 let is_loopback = matches!(
27 url.host(),
28 Some(Host::Domain(host)) if host.eq_ignore_ascii_case("localhost")
29 ) || matches!(url.host(), Some(Host::Ipv4(address)) if address.is_loopback())
30 || matches!(url.host(), Some(Host::Ipv6(address)) if address.is_loopback());
31
32 match url.scheme() {
33 "https" => Ok(url),
34 "http" if is_loopback => Ok(url),
35 "http" => Err("Webhook URL must use HTTPS unless it targets localhost".to_string()),
36 _ => Err("Webhook URL must use HTTPS or HTTP localhost".to_string()),
37 }
38}
39
40#[derive(Debug, Clone)]
42pub struct WebhookNotifier {
43 client: Option<Client>,
44 max_retries: u32,
45 retry_delay_ms: u64,
46}
47
48impl Default for WebhookNotifier {
49 fn default() -> Self {
50 Self::new()
51 }
52}
53
54impl WebhookNotifier {
55 fn build_http_client() -> Option<Client> {
56 match Client::builder()
57 .timeout(Duration::from_secs(10))
58 .redirect(reqwest::redirect::Policy::none())
59 .build()
60 {
61 Ok(client) => Some(client),
62 Err(error) => {
63 warn!(error = %error, "Failed to configure webhook HTTP client; webhook delivery disabled");
64 None
65 }
66 }
67 }
68
69 pub(crate) fn new() -> Self {
71 Self {
72 client: Self::build_http_client(),
73 max_retries: 3,
74 retry_delay_ms: 1000,
75 }
76 }
77
78 fn with_settings(max_retries: u32, retry_delay_ms: u64) -> Self {
80 Self {
81 client: Self::build_http_client(),
82 max_retries,
83 retry_delay_ms,
84 }
85 }
86
87 pub(crate) async fn send_event(
89 &self,
90 config: &TaskPushNotificationConfig,
91 event: StreamingEvent,
92 ) -> Result<(), WebhookError> {
93 let response = SendStreamingMessageResponse { event };
94 let json = serde_json::to_string(&response).map_err(|e| WebhookError::Serialization(e.to_string()))?;
95 let url = parse_webhook_url(&config.url).map_err(WebhookError::InvalidUrl)?;
96
97 self.send_with_retry(&url, &json, config.authentication.as_deref()).await
98 }
99
100 fn retry_delay(&self, attempt: u32) -> Duration {
103 let exponent = attempt.saturating_sub(1);
104 Duration::from_millis(self.retry_delay_ms.saturating_mul(2_u64.saturating_pow(exponent)))
105 }
106
107 async fn send_with_retry(&self, url: &Url, json: &str, auth: Option<&str>) -> Result<(), WebhookError> {
109 let mut last_error = None;
110
111 for attempt in 0..=self.max_retries {
112 if attempt > 0 {
113 let delay = self.retry_delay(attempt); debug!("Retrying webhook delivery after {:?} (attempt {})", delay, attempt);
115 tokio::time::sleep(delay).await;
116 }
117
118 match self.send_request(url, json, auth).await {
119 Ok(()) => {
120 debug!("Webhook delivered successfully");
121 return Ok(());
122 }
123 Err(e) => {
124 warn!("Webhook delivery attempt {} failed: {}", attempt + 1, e);
125 last_error = Some(e);
126 }
127 }
128 }
129
130 Err(last_error.unwrap_or(WebhookError::Unknown))
131 }
132
133 async fn send_request(&self, url: &Url, json: &str, auth: Option<&str>) -> Result<(), WebhookError> {
135 let Some(client) = self.client.as_ref() else {
136 return Err(WebhookError::ClientUnavailable);
137 };
138
139 let mut request = client
140 .post(url.clone())
141 .header("Content-Type", "application/json")
142 .header("User-Agent", "VT Code-A2A/1.0");
143
144 if let Some(auth_header) = auth {
145 request = request.header("Authorization", auth_header);
146 }
147
148 let response = request
149 .body(json.to_string())
150 .send()
151 .await
152 .map_err(|e| WebhookError::Network(e.to_string()))?;
153
154 if response.status().is_success() {
155 Ok(())
156 } else {
157 Err(WebhookError::HttpError(response.status().as_u16()))
158 }
159 }
160}
161
162#[derive(Debug, Clone, thiserror::Error)]
164pub enum WebhookError {
165 #[error("Network error: {0}")]
167 Network(String),
168 #[error("HTTP error: {0}")]
170 HttpError(u16),
171 #[error("Serialization error: {0}")]
173 Serialization(String),
174 #[error("Invalid webhook URL: {0}")]
176 InvalidUrl(String),
177 #[error("Webhook HTTP client is unavailable")]
179 ClientUnavailable,
180 #[error("Unknown error")]
182 Unknown,
183}
184
185#[cfg(test)]
186mod tests {
187 use super::*;
188 use crate::types::{TaskState, TaskStatus};
189
190 #[test]
191 fn test_webhook_notifier_creation() {
192 let notifier = WebhookNotifier::new();
193 assert_eq!(notifier.max_retries, 3);
194 assert_eq!(notifier.retry_delay_ms, 1000);
195 }
196
197 #[test]
198 fn test_webhook_notifier_with_settings() {
199 let notifier = WebhookNotifier::with_settings(5, 2000);
200 assert_eq!(notifier.max_retries, 5);
201 assert_eq!(notifier.retry_delay_ms, 2000);
202 }
203
204 #[test]
205 fn test_webhook_retry_delay_doubles_and_saturates() {
206 let notifier = WebhookNotifier::with_settings(3, 1_000);
207 assert_eq!(notifier.retry_delay(1), Duration::from_millis(1_000));
208 assert_eq!(notifier.retry_delay(2), Duration::from_millis(2_000));
209 assert_eq!(notifier.retry_delay(3), Duration::from_millis(4_000));
210 assert_eq!(
211 notifier.retry_delay(u32::MAX),
212 Duration::from_millis(u64::MAX),
213 "an overflowing exponent must saturate instead of panicking"
214 );
215 }
216
217 #[test]
218 fn test_parse_webhook_url_requires_safe_scheme_and_host() {
219 for url in [
220 "https://example.com/webhook",
221 "http://localhost/webhook",
222 "http://127.0.0.1:8080/webhook",
223 "http://[::1]:8080/webhook",
224 ] {
225 assert!(parse_webhook_url(url).is_ok(), "URL should be accepted: {url}");
226 }
227
228 for url in [
229 "http://localhost.evil.example/webhook",
230 "http://localhost@evil.example/webhook",
231 "http://example.com/webhook",
232 "ftp://example.com/webhook",
233 "https://user:password@example.com/webhook",
234 "https://example.com/webhook#fragment",
235 ] {
236 assert!(parse_webhook_url(url).is_err(), "URL should be rejected: {url}");
237 }
238 }
239
240 #[tokio::test]
241 async fn test_webhook_error_display() {
242 let err = WebhookError::Network("Connection refused".to_string());
243 assert!(err.to_string().contains("Network error"));
244
245 let err = WebhookError::HttpError(404);
246 assert!(err.to_string().contains("404"));
247 }
248
249 #[tokio::test]
250 async fn test_send_event_serialization() {
251 let notifier = WebhookNotifier::new();
252 let config = TaskPushNotificationConfig {
253 task_id: "task-1".to_string(),
254 url: "https://example.com/webhook".to_string(),
255 authentication: None,
256 };
257
258 let event = StreamingEvent::TaskStatus {
259 task_id: "task-1".to_string(),
260 context_id: None,
261 status: TaskStatus::new(TaskState::Completed),
262 kind: "status-update".to_string(),
263 r#final: true,
264 };
265
266 let result = notifier.send_event(&config, event).await;
269 assert!(result.is_err());
270
271 if let Err(WebhookError::Serialization(_)) = result {
272 panic!("Unexpected serialization error");
273 }
274 }
275
276 #[tokio::test]
277 async fn test_send_event_rejects_invalid_url_before_network_access() {
278 let notifier = WebhookNotifier::new();
279 let config = TaskPushNotificationConfig {
280 task_id: "task-1".to_string(),
281 url: "http://localhost.evil.example/webhook".to_string(),
282 authentication: None,
283 };
284 let event = StreamingEvent::Unknown;
285
286 let result = notifier.send_event(&config, event).await;
287 assert!(matches!(result, Err(WebhookError::InvalidUrl(_))));
288 }
289
290 #[tokio::test]
291 async fn test_webhook_client_does_not_follow_redirects() {
292 use tokio::io::{AsyncReadExt, AsyncWriteExt};
293 use tokio::net::TcpListener;
294
295 let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind test listener");
296 let address = listener.local_addr().expect("read test listener address");
297 let server = tokio::spawn(async move {
298 let (mut socket, _) = listener.accept().await.expect("accept initial webhook");
299 let mut request = [0; 1024];
300 let _ignored = socket.read(&mut request).await.expect("read initial webhook");
301 let redirect = format!(
302 "HTTP/1.1 302 Found\r\nLocation: http://{address}/redirect-target\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
303 );
304 socket.write_all(redirect.as_bytes()).await.expect("send redirect");
305
306 match tokio::time::timeout(Duration::from_millis(250), listener.accept()).await {
307 Ok(Ok((mut redirected_socket, _))) => {
308 let mut request = [0; 1024];
309 let _ignored = redirected_socket.read(&mut request).await.expect("read redirected webhook");
310 let response = b"HTTP/1.1 204 No Content\r\nContent-Length: 0\r\nConnection: close\r\n\r\n";
311 redirected_socket.write_all(response).await.expect("send redirected response");
312 true
313 }
314 Ok(Err(_)) | Err(_) => false,
315 }
316 });
317
318 let notifier = WebhookNotifier::with_settings(0, 0);
319 let config = TaskPushNotificationConfig {
320 task_id: "task-1".to_string(),
321 url: format!("http://{address}/hook"),
322 authentication: None,
323 };
324 let result = notifier.send_event(&config, StreamingEvent::Unknown).await;
325 assert!(matches!(result, Err(WebhookError::HttpError(302))));
326 assert!(!server.await.expect("join test server"));
327 }
328}