1use anyhow::{Context, bail};
12use schemars::JsonSchema;
13use serde::{Deserialize, Serialize};
14use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
15use url::{Host, Url};
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
20#[serde(rename_all = "snake_case")]
21pub enum WebhookEventSubscription {
22 TaskCreated,
24 TaskStarted,
26 TaskCompleted,
28 TaskFailed,
30 TaskStatusChanged,
32 LoopStarted,
34 LoopStopped,
36 PhaseStarted,
38 PhaseCompleted,
40 QueueUnblocked,
42 #[serde(rename = "*")]
44 Wildcard,
45}
46
47impl WebhookEventSubscription {
48 pub fn as_str(&self) -> &'static str {
50 match self {
51 Self::TaskCreated => "task_created",
52 Self::TaskStarted => "task_started",
53 Self::TaskCompleted => "task_completed",
54 Self::TaskFailed => "task_failed",
55 Self::TaskStatusChanged => "task_status_changed",
56 Self::LoopStarted => "loop_started",
57 Self::LoopStopped => "loop_stopped",
58 Self::PhaseStarted => "phase_started",
59 Self::PhaseCompleted => "phase_completed",
60 Self::QueueUnblocked => "queue_unblocked",
61 Self::Wildcard => "*",
62 }
63 }
64}
65
66#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default, JsonSchema)]
68#[serde(rename_all = "snake_case")]
69pub enum WebhookQueuePolicy {
70 #[default]
73 DropOldest,
74 DropNew,
76 BlockWithTimeout,
78}
79
80#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
82#[serde(default, deny_unknown_fields)]
83pub struct WebhookConfig {
84 pub enabled: Option<bool>,
86
87 pub url: Option<String>,
89
90 #[schemars(
92 description = "Opt-in to allow plaintext http:// webhook URLs (default: HTTPS only)."
93 )]
94 pub allow_insecure_http: Option<bool>,
95
96 #[schemars(
99 description = "Opt-in to allow loopback, link-local (169.254/…), and metadata-style hosts."
100 )]
101 pub allow_private_targets: Option<bool>,
102
103 pub secret: Option<String>,
106
107 pub events: Option<Vec<WebhookEventSubscription>>,
109
110 #[schemars(range(min = 1, max = 300))]
112 pub timeout_secs: Option<u32>,
113
114 #[schemars(range(min = 0, max = 10))]
116 pub retry_count: Option<u32>,
117
118 #[schemars(range(min = 100, max = 30000))]
121 pub retry_backoff_ms: Option<u32>,
122
123 #[schemars(range(min = 10, max = 10000))]
125 pub queue_capacity: Option<u32>,
126
127 #[schemars(range(min = 1.0, max = 10.0))]
131 pub parallel_queue_multiplier: Option<f32>,
132
133 pub queue_policy: Option<WebhookQueuePolicy>,
138}
139
140impl WebhookConfig {
141 pub fn merge_from(&mut self, other: Self) {
142 if other.enabled.is_some() {
143 self.enabled = other.enabled;
144 }
145 if other.url.is_some() {
146 self.url = other.url;
147 }
148 if other.allow_insecure_http.is_some() {
149 self.allow_insecure_http = other.allow_insecure_http;
150 }
151 if other.allow_private_targets.is_some() {
152 self.allow_private_targets = other.allow_private_targets;
153 }
154 if other.secret.is_some() {
155 self.secret = other.secret;
156 }
157 if other.events.is_some() {
158 self.events = other.events;
159 }
160 if other.timeout_secs.is_some() {
161 self.timeout_secs = other.timeout_secs;
162 }
163 if other.retry_count.is_some() {
164 self.retry_count = other.retry_count;
165 }
166 if other.retry_backoff_ms.is_some() {
167 self.retry_backoff_ms = other.retry_backoff_ms;
168 }
169 if other.queue_capacity.is_some() {
170 self.queue_capacity = other.queue_capacity;
171 }
172 if other.parallel_queue_multiplier.is_some() {
173 self.parallel_queue_multiplier = other.parallel_queue_multiplier;
174 }
175 if other.queue_policy.is_some() {
176 self.queue_policy = other.queue_policy;
177 }
178 }
179
180 const DEFAULT_EVENTS_V1: [&'static str; 5] = [
183 "task_created",
184 "task_started",
185 "task_completed",
186 "task_failed",
187 "task_status_changed",
188 ];
189
190 pub fn is_event_enabled(&self, event: &str) -> bool {
197 if !self.enabled.unwrap_or(false) {
198 return false;
199 }
200 match &self.events {
201 None => Self::DEFAULT_EVENTS_V1.contains(&event),
202 Some(events) => events
203 .iter()
204 .any(|e| e.as_str() == event || e.as_str() == "*"),
205 }
206 }
207}
208
209pub(crate) fn validate_webhook_settings(cfg: &WebhookConfig) -> anyhow::Result<()> {
211 if !cfg.enabled.unwrap_or(false) {
212 return Ok(());
213 }
214 let Some(raw) = cfg.url.as_deref() else {
215 bail!(
216 "agent.webhook.enabled=true requires agent.webhook.url to be set to an absolute https:// URL"
217 );
218 };
219 let trimmed = raw.trim();
220 if trimmed.is_empty() {
221 bail!("agent.webhook.enabled=true requires a non-empty agent.webhook.url");
222 }
223 validate_webhook_destination_url(
224 trimmed,
225 cfg.allow_insecure_http.unwrap_or(false),
226 cfg.allow_private_targets.unwrap_or(false),
227 )
228}
229
230pub(crate) fn validate_webhook_destination_url(
237 raw_url: &str,
238 allow_insecure_http: bool,
239 allow_private_targets: bool,
240) -> anyhow::Result<()> {
241 let trimmed = raw_url.trim();
242 if trimmed.is_empty() {
243 bail!("webhook URL is empty");
244 }
245
246 let parsed = Url::parse(trimmed).context("webhook URL must be a valid absolute URL")?;
247
248 match parsed.scheme() {
249 "https" => {}
250 "http" => {
251 if !allow_insecure_http {
252 bail!(
253 "webhook URL uses http://; only https:// is allowed by default. \
254 Set agent.webhook.allow_insecure_http=true to permit plaintext HTTP (not recommended)."
255 );
256 }
257 }
258 other => {
259 bail!(
260 "webhook URL scheme {other:?} is not allowed; only http:// and https:// are supported"
261 );
262 }
263 }
264
265 if parsed.host_str().is_none_or(|h| h.is_empty()) {
266 bail!("webhook URL must include a non-empty host");
267 }
268
269 if !allow_private_targets && url_host_is_ssrf_risk(&parsed) {
270 bail!(
271 "webhook URL targets a loopback, link-local, or cloud-metadata-style host, which is blocked by default. \
272 Set agent.webhook.allow_private_targets=true only if you intentionally send webhooks to such a destination."
273 );
274 }
275
276 Ok(())
277}
278
279fn url_host_is_ssrf_risk(url: &Url) -> bool {
280 match url.host() {
281 Some(Host::Ipv4(ip)) => ip_is_blocked_private_adjacent(IpAddr::V4(ip)),
282 Some(Host::Ipv6(ip)) => ip_is_blocked_private_adjacent(IpAddr::V6(ip)),
283 Some(Host::Domain(domain)) => domain_host_is_risky(domain),
284 None => true,
285 }
286}
287
288fn ip_is_blocked_private_adjacent(ip: IpAddr) -> bool {
289 match ip {
290 IpAddr::V4(v4) => ipv4_is_risky(v4),
291 IpAddr::V6(v6) => ipv6_is_risky(v6),
292 }
293}
294
295fn ipv4_is_risky(ip: Ipv4Addr) -> bool {
296 ip.is_loopback() || ip.is_link_local() || ip.is_unspecified()
297}
298
299fn ipv6_is_risky(ip: Ipv6Addr) -> bool {
300 if let Some(mapped) = ip.to_ipv4_mapped() {
301 return ipv4_is_risky(mapped);
302 }
303 ip.is_loopback() || ip.is_unicast_link_local() || ip.is_unspecified()
304}
305
306fn domain_host_is_risky(domain: &str) -> bool {
307 if let Ok(ip) = domain.parse::<IpAddr>() {
308 return ip_is_blocked_private_adjacent(ip);
309 }
310 let lower = domain.to_ascii_lowercase();
311 if lower == "localhost" || lower.ends_with(".localhost") {
312 return true;
313 }
314 if lower == "metadata.google.internal" {
315 return true;
316 }
317 false
318}
319
320#[cfg(test)]
321mod tests {
322 use super::*;
323
324 #[test]
325 fn test_event_subscription_serialization() {
326 let sub = WebhookEventSubscription::TaskCreated;
328 assert_eq!(serde_json::to_string(&sub).unwrap(), "\"task_created\"");
329
330 let wild = WebhookEventSubscription::Wildcard;
332 assert_eq!(serde_json::to_string(&wild).unwrap(), "\"*\"");
333 }
334
335 #[test]
336 fn test_event_subscription_deserialization() {
337 let sub: WebhookEventSubscription = serde_json::from_str("\"task_created\"").unwrap();
338 assert_eq!(sub, WebhookEventSubscription::TaskCreated);
339
340 let wild: WebhookEventSubscription = serde_json::from_str("\"*\"").unwrap();
341 assert_eq!(wild, WebhookEventSubscription::Wildcard);
342 }
343
344 #[test]
345 fn test_invalid_event_rejected() {
346 let result: Result<WebhookEventSubscription, _> = serde_json::from_str("\"task_creatd\"");
347 assert!(result.is_err());
348 }
349
350 #[test]
351 fn test_is_event_enabled_with_subscription_type() {
352 let config = WebhookConfig {
353 enabled: Some(true),
354 events: Some(vec![
355 WebhookEventSubscription::TaskCreated,
356 WebhookEventSubscription::Wildcard,
357 ]),
358 ..Default::default()
359 };
360 assert!(config.is_event_enabled("task_created"));
361 assert!(config.is_event_enabled("loop_started")); }
363
364 #[test]
365 fn test_is_event_enabled_default_events_when_none() {
366 let config = WebhookConfig {
367 enabled: Some(true),
368 events: None,
369 ..Default::default()
370 };
371 assert!(config.is_event_enabled("task_created"));
372 assert!(config.is_event_enabled("task_started"));
373 assert!(!config.is_event_enabled("loop_started")); }
375
376 #[test]
377 fn test_is_event_enabled_disabled_when_not_enabled() {
378 let config = WebhookConfig {
379 enabled: Some(false),
380 events: Some(vec![WebhookEventSubscription::TaskCreated]),
381 ..Default::default()
382 };
383 assert!(!config.is_event_enabled("task_created"));
384 }
385
386 #[test]
387 fn validate_destination_accepts_public_https() {
388 validate_webhook_destination_url("https://hooks.example.com/ralph", false, false).unwrap();
389 }
390
391 #[test]
392 fn validate_destination_rejects_http_by_default() {
393 let err = validate_webhook_destination_url("http://hooks.example.com/ralph", false, false)
394 .unwrap_err();
395 assert!(err.to_string().contains("http://"));
396 }
397
398 #[test]
399 fn validate_destination_allows_http_when_opted_in() {
400 validate_webhook_destination_url("http://hooks.example.com/ralph", true, false).unwrap();
401 }
402
403 #[test]
404 fn validate_destination_rejects_loopback_https() {
405 assert!(validate_webhook_destination_url("https://127.0.0.1/hook", false, false).is_err());
406 assert!(validate_webhook_destination_url("https://[::1]/hook", false, false).is_err());
407 }
408
409 #[test]
410 fn validate_destination_rejects_link_local_ipv4() {
411 assert!(
412 validate_webhook_destination_url("https://169.254.169.254/latest", false, false)
413 .is_err()
414 );
415 }
416
417 #[test]
418 fn validate_destination_rejects_metadata_hostname() {
419 assert!(
420 validate_webhook_destination_url("https://metadata.google.internal/", false, false)
421 .is_err()
422 );
423 }
424
425 #[test]
426 fn validate_destination_allows_risky_targets_when_opted_in() {
427 validate_webhook_destination_url("https://127.0.0.1/hook", false, true).unwrap();
428 validate_webhook_destination_url("http://127.0.0.1/hook", true, true).unwrap();
429 }
430
431 #[test]
432 fn validate_settings_skips_url_when_disabled() {
433 let cfg = WebhookConfig {
434 enabled: Some(false),
435 url: Some("https://127.0.0.1/nope".to_string()),
436 ..Default::default()
437 };
438 validate_webhook_settings(&cfg).unwrap();
439 }
440
441 #[test]
442 fn validate_settings_requires_url_when_enabled() {
443 let cfg = WebhookConfig {
444 enabled: Some(true),
445 url: None,
446 ..Default::default()
447 };
448 assert!(validate_webhook_settings(&cfg).is_err());
449 }
450}