Skip to main content

ralph/contracts/config/
webhook.rs

1//! Webhook configuration for HTTP task event notifications.
2//!
3//! Responsibilities:
4//! - Define webhook config structs and backpressure policy enum.
5//! - Provide merge behavior and event filtering.
6//! - Define valid webhook event subscription types for config validation.
7//!
8//! Not handled here:
9//! - Actual webhook delivery (see `crate::webhook` module).
10
11use anyhow::{Context, bail};
12use schemars::JsonSchema;
13use serde::{Deserialize, Serialize};
14use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
15use url::{Host, Url};
16
17/// Webhook event subscription type for config.
18/// Each variant corresponds to a WebhookEventType, plus Wildcard for "all events".
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
20#[serde(rename_all = "snake_case")]
21pub enum WebhookEventSubscription {
22    /// Task was created/added to queue.
23    TaskCreated,
24    /// Task status changed to Doing (execution started).
25    TaskStarted,
26    /// Task completed successfully (status Done).
27    TaskCompleted,
28    /// Task failed or was rejected.
29    TaskFailed,
30    /// Generic status change.
31    TaskStatusChanged,
32    /// Run loop started.
33    LoopStarted,
34    /// Run loop stopped.
35    LoopStopped,
36    /// Phase started for a task.
37    PhaseStarted,
38    /// Phase completed for a task.
39    PhaseCompleted,
40    /// Queue became unblocked.
41    QueueUnblocked,
42    /// Wildcard: subscribe to all events.
43    #[serde(rename = "*")]
44    Wildcard,
45}
46
47impl WebhookEventSubscription {
48    /// Convert to the string representation used in event matching.
49    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/// Backpressure policy for webhook delivery queue.
67#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default, JsonSchema)]
68#[serde(rename_all = "snake_case")]
69pub enum WebhookQueuePolicy {
70    /// Drop new webhooks when queue is full, preserving existing queue contents.
71    /// This is functionally equivalent to `drop_new` due to channel constraints.
72    #[default]
73    DropOldest,
74    /// Drop the new webhook if queue is full.
75    DropNew,
76    /// Block sender briefly, then drop if queue is still full.
77    BlockWithTimeout,
78}
79
80/// Webhook configuration for HTTP task event notifications.
81#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
82#[serde(default, deny_unknown_fields)]
83pub struct WebhookConfig {
84    /// Enable webhook notifications (default: false).
85    pub enabled: Option<bool>,
86
87    /// Webhook endpoint URL (required when enabled).
88    pub url: Option<String>,
89
90    /// When `true`, allow `http://` webhook URLs. Default is HTTPS-only (`false` / unset).
91    #[schemars(
92        description = "Opt-in to allow plaintext http:// webhook URLs (default: HTTPS only)."
93    )]
94    pub allow_insecure_http: Option<bool>,
95
96    /// When `true`, allow loopback, link-local, and common cloud metadata hostnames/IPs.
97    /// Default blocks these SSRF-adjacent targets unless explicitly opted in.
98    #[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    /// Secret key for HMAC-SHA256 signature generation.
104    /// When set, webhooks include an X-Ralph-Signature header.
105    pub secret: Option<String>,
106
107    /// Events to subscribe to (default: legacy task events only).
108    pub events: Option<Vec<WebhookEventSubscription>>,
109
110    /// Request timeout in seconds (default: 30, max: 300).
111    #[schemars(range(min = 1, max = 300))]
112    pub timeout_secs: Option<u32>,
113
114    /// Number of retry attempts for failed deliveries (default: 3, max: 10).
115    #[schemars(range(min = 0, max = 10))]
116    pub retry_count: Option<u32>,
117
118    /// Base interval for exponential webhook retry delays in milliseconds (default: 1000, max: 30000).
119    /// Actual delays apply bounded jitter and cap at 30 seconds between attempts.
120    #[schemars(range(min = 100, max = 30000))]
121    pub retry_backoff_ms: Option<u32>,
122
123    /// Maximum number of pending webhooks in the delivery queue (default: 500, range: 10-10000).
124    #[schemars(range(min = 10, max = 10000))]
125    pub queue_capacity: Option<u32>,
126
127    /// Multiplier for queue capacity in parallel mode (default: 2.0, range: 1.0-10.0).
128    /// When running with N parallel workers, effective capacity = queue_capacity * max(1, workers * multiplier).
129    /// Set higher (e.g., 3.0) if webhook endpoint is slow or unreliable.
130    #[schemars(range(min = 1.0, max = 10.0))]
131    pub parallel_queue_multiplier: Option<f32>,
132
133    /// Backpressure policy when queue is full (default: drop_oldest).
134    /// - drop_oldest: Drop new webhooks when full (preserves existing queue contents)
135    /// - drop_new: Drop the new webhook if queue is full
136    /// - block_with_timeout: Block sender briefly (100ms), then drop if still full
137    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    /// Legacy default events that are enabled when `events` is not specified.
181    /// New events (loop_*, phase_*) are opt-in and require explicit configuration.
182    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    /// Check if a specific event type is enabled.
191    ///
192    /// Event filtering behavior:
193    /// - If webhooks are disabled, no events are sent.
194    /// - If `events` is `None`: only legacy task events are enabled (backward compatible).
195    /// - If `events` is `Some([...])`: only those events are enabled; use `["*"]` to enable all.
196    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
209/// Validate webhook URL when `agent.webhook.enabled` is true (requires non-empty URL and safety rules).
210pub(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
230/// Validate a webhook destination URL for delivery or config-time checks.
231///
232/// - Only `http` and `https` schemes are accepted.
233/// - `http` requires `allow_insecure_http`.
234/// - Loopback, IPv4 link-local (`169.254.0.0/16`), IPv6 link-local, and common metadata hostnames
235///   are rejected unless `allow_private_targets` is true.
236pub(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        // Test snake_case serialization
327        let sub = WebhookEventSubscription::TaskCreated;
328        assert_eq!(serde_json::to_string(&sub).unwrap(), "\"task_created\"");
329
330        // Test wildcard serialization
331        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")); // via wildcard
362    }
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")); // not in default set
374    }
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}