1use std::time::Duration;
30
31use reqwest::{Client, Url};
32use serde::{Deserialize, Serialize};
33
34use crate::error::{Result, SeerError};
35
36const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10);
38
39const CONNECT_TIMEOUT: Duration = Duration::from_secs(5);
42
43#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
45pub struct WebhookDelivery {
46 pub status: u16,
48}
49
50#[derive(Debug, Clone)]
55pub struct WebhookClient {
56 timeout: Duration,
58 allow_private: bool,
62}
63
64impl Default for WebhookClient {
65 fn default() -> Self {
66 Self::new()
67 }
68}
69
70impl WebhookClient {
71 pub fn new() -> Self {
73 Self {
74 timeout: DEFAULT_TIMEOUT,
75 allow_private: false,
76 }
77 }
78
79 pub fn from_config(config: &crate::config::SeerConfig) -> Self {
86 Self::new().with_timeout(config.http_timeout())
87 }
88
89 pub fn with_timeout(mut self, timeout: Duration) -> Self {
91 self.timeout = timeout;
92 self
93 }
94
95 #[cfg(test)]
97 pub(crate) fn allowing_private_hosts(mut self) -> Self {
98 self.allow_private = true;
99 self
100 }
101
102 pub async fn post_json<T: Serialize + ?Sized>(
116 &self,
117 url: &str,
118 payload: &T,
119 ) -> Result<WebhookDelivery> {
120 let (host, port) = parse_webhook_url(url)?;
121
122 let body = serde_json::to_vec(payload)?;
125
126 let connect_timeout = CONNECT_TIMEOUT.min(self.timeout);
127 let mut builder = Client::builder()
128 .timeout(self.timeout)
129 .connect_timeout(connect_timeout)
130 .user_agent(concat!("Seer/", env!("CARGO_PKG_VERSION")))
131 .redirect(reqwest::redirect::Policy::none());
135
136 if !self.allow_private {
137 let resolved = crate::net::resolve_public_host(&host, port).await?;
143 builder = builder.resolve_to_addrs(&host, &resolved);
144 }
145
146 let client = builder
147 .build()
148 .map_err(|e| SeerError::HttpError(format!("failed to build HTTP client: {}", e)))?;
149
150 let response = client
151 .post(url)
152 .header(reqwest::header::CONTENT_TYPE, "application/json")
153 .body(body)
154 .send()
155 .await?;
156
157 let status = response.status();
158 if !status.is_success() {
159 return Err(SeerError::HttpError(format!(
162 "webhook delivery failed: HTTP {}",
163 status
164 )));
165 }
166
167 Ok(WebhookDelivery {
168 status: status.as_u16(),
169 })
170 }
171}
172
173fn parse_webhook_url(url: &str) -> Result<(String, u16)> {
180 let parsed = Url::parse(url)
181 .map_err(|e| SeerError::InvalidInput(format!("invalid webhook URL '{}': {}", url, e)))?;
182
183 let scheme = parsed.scheme();
184 if scheme != "http" && scheme != "https" {
185 return Err(SeerError::InvalidInput(format!(
186 "webhook URL must use http or https, got '{}'",
187 scheme
188 )));
189 }
190
191 let host = match parsed.host() {
192 Some(url::Host::Domain(d)) => d.to_string(),
193 Some(url::Host::Ipv4(ip)) => ip.to_string(),
194 Some(url::Host::Ipv6(ip)) => ip.to_string(),
195 None => {
196 return Err(SeerError::InvalidInput(format!(
197 "webhook URL '{}' has no host",
198 url
199 )))
200 }
201 };
202
203 let port = parsed
206 .port_or_known_default()
207 .unwrap_or(if scheme == "http" { 80 } else { 443 });
208
209 Ok((host, port))
210}
211
212#[cfg(test)]
213#[allow(clippy::unwrap_used)]
214mod tests {
215 use super::*;
221 use wiremock::matchers::{body_json, header, method, path};
222 use wiremock::{Mock, MockServer, ResponseTemplate};
223
224 #[derive(Serialize)]
225 struct Payload {
226 domain: String,
227 event: String,
228 }
229
230 struct Unserializable;
233
234 impl Serialize for Unserializable {
235 fn serialize<S: serde::Serializer>(
236 &self,
237 _serializer: S,
238 ) -> std::result::Result<S::Ok, S::Error> {
239 Err(serde::ser::Error::custom("cannot serialize"))
240 }
241 }
242
243 #[tokio::test]
244 async fn delivers_json_payload_and_returns_status() {
245 let server = MockServer::start().await;
246 Mock::given(method("POST"))
250 .and(path("/hook"))
251 .and(header("content-type", "application/json"))
252 .and(body_json(serde_json::json!({
253 "domain": "example.com",
254 "event": "expiry",
255 })))
256 .respond_with(ResponseTemplate::new(200))
257 .expect(1)
258 .mount(&server)
259 .await;
260
261 let client = WebhookClient::new().allowing_private_hosts();
262 let payload = Payload {
263 domain: "example.com".to_string(),
264 event: "expiry".to_string(),
265 };
266 let delivery = client
267 .post_json(&format!("{}/hook", server.uri()), &payload)
268 .await
269 .unwrap();
270 assert_eq!(delivery.status, 200);
271 server.verify().await;
272 }
273
274 #[tokio::test]
275 async fn non_2xx_is_error_naming_status_not_body() {
276 let server = MockServer::start().await;
277 Mock::given(method("POST"))
278 .and(path("/hook"))
279 .respond_with(ResponseTemplate::new(500).set_body_string("internal-secret-detail"))
280 .mount(&server)
281 .await;
282
283 let client = WebhookClient::new().allowing_private_hosts();
284 let err = client
285 .post_json(&format!("{}/hook", server.uri()), &serde_json::json!({}))
286 .await
287 .unwrap_err();
288 assert!(matches!(err, SeerError::HttpError(_)), "got {err:?}");
289 let msg = err.to_string();
290 assert!(msg.contains("500"), "error must name the status: {msg}");
291 assert!(
292 !msg.contains("internal-secret-detail"),
293 "untrusted response body must not leak into the error: {msg}"
294 );
295 }
296
297 #[tokio::test]
298 async fn redirect_is_not_followed() {
299 let server = MockServer::start().await;
300 Mock::given(method("POST"))
301 .and(path("/hook"))
302 .respond_with(
303 ResponseTemplate::new(301)
304 .insert_header("location", format!("{}/elsewhere", server.uri()).as_str()),
305 )
306 .expect(1)
307 .mount(&server)
308 .await;
309 Mock::given(path("/elsewhere"))
311 .respond_with(ResponseTemplate::new(200))
312 .expect(0)
313 .mount(&server)
314 .await;
315
316 let client = WebhookClient::new().allowing_private_hosts();
317 let err = client
318 .post_json(&format!("{}/hook", server.uri()), &serde_json::json!({}))
319 .await
320 .unwrap_err();
321 assert!(matches!(err, SeerError::HttpError(_)), "got {err:?}");
322 assert!(
323 err.to_string().contains("301"),
324 "error must carry the 3xx status: {err}"
325 );
326 server.verify().await;
327 }
328
329 #[tokio::test]
330 async fn production_client_refuses_loopback_url() {
331 let server = MockServer::start().await;
332 Mock::given(method("POST"))
334 .respond_with(ResponseTemplate::new(200))
335 .expect(0)
336 .mount(&server)
337 .await;
338
339 let client = WebhookClient::new(); let err = client
341 .post_json(&format!("{}/hook", server.uri()), &serde_json::json!({}))
342 .await
343 .unwrap_err();
344 assert!(matches!(err, SeerError::InvalidInput(_)), "got {err:?}");
345 server.verify().await;
346 }
347
348 #[tokio::test]
349 async fn production_client_refuses_private_ip_literal() {
350 let client = WebhookClient::new();
352 for url in [
353 "http://10.0.0.1/hook",
354 "https://169.254.169.254/latest/meta-data",
355 "http://[::1]/hook",
356 ] {
357 let err = client
358 .post_json(url, &serde_json::json!({}))
359 .await
360 .unwrap_err();
361 assert!(
362 matches!(err, SeerError::InvalidInput(_)),
363 "{url} must be refused, got {err:?}"
364 );
365 }
366 }
367
368 #[tokio::test]
369 async fn rejects_non_http_scheme() {
370 let client = WebhookClient::new();
371 let err = client
372 .post_json("ftp://example.com/hook", &serde_json::json!({}))
373 .await
374 .unwrap_err();
375 assert!(matches!(err, SeerError::InvalidInput(_)), "got {err:?}");
376 assert!(
377 err.to_string().contains("http"),
378 "error should point at the scheme requirement: {err}"
379 );
380 }
381
382 #[tokio::test]
383 async fn rejects_hostless_url() {
384 let client = WebhookClient::new();
385 let err = client
386 .post_json("http:///hook", &serde_json::json!({}))
387 .await
388 .unwrap_err();
389 assert!(matches!(err, SeerError::InvalidInput(_)), "got {err:?}");
390 }
391
392 #[tokio::test]
393 async fn serialization_failure_fails_fast_before_any_network() {
394 let client = WebhookClient::new();
398 let err = client
399 .post_json("https://webhook.example.com/hook", &Unserializable)
400 .await
401 .unwrap_err();
402 assert!(matches!(err, SeerError::JsonError(_)), "got {err:?}");
403 }
404
405 #[tokio::test]
406 async fn timeout_bounds_delivery() {
407 let server = MockServer::start().await;
408 Mock::given(method("POST"))
409 .and(path("/hook"))
410 .respond_with(ResponseTemplate::new(200).set_delay(Duration::from_secs(5)))
411 .mount(&server)
412 .await;
413
414 let client = WebhookClient::new()
415 .allowing_private_hosts()
416 .with_timeout(Duration::from_millis(100));
417 let err = client
418 .post_json(&format!("{}/hook", server.uri()), &serde_json::json!({}))
419 .await
420 .unwrap_err();
421 match err {
422 SeerError::ReqwestError { transient, .. } => {
423 assert!(transient, "timeouts must classify as transient");
424 }
425 other => panic!("expected ReqwestError from timeout, got {other:?}"),
426 }
427 }
428}