Skip to main content

camel_component_http/
config.rs

1use serde::Deserialize;
2
3use camel_component_api::CamelError;
4
5#[derive(Debug, Clone, PartialEq, Deserialize)]
6pub struct HttpConfig {
7    #[serde(default = "default_connect_timeout_ms")]
8    pub connect_timeout_ms: u64,
9    #[serde(default = "default_pool_max_idle_per_host")]
10    pub pool_max_idle_per_host: usize,
11    #[serde(default = "default_pool_idle_timeout_ms")]
12    pub pool_idle_timeout_ms: u64,
13    #[serde(default)]
14    pub follow_redirects: bool,
15    #[serde(default)]
16    pub max_redirects: Option<usize>,
17    #[serde(default = "default_response_timeout_ms")]
18    pub response_timeout_ms: u64,
19    #[serde(default = "default_read_timeout_ms")]
20    pub read_timeout_ms: u64,
21    #[serde(default = "default_max_body_size")]
22    pub max_body_size: usize,
23    #[serde(default = "default_max_response_bytes")]
24    pub max_response_bytes: usize,
25    #[serde(default = "default_max_request_body")]
26    pub max_request_body: usize,
27    #[serde(default)]
28    pub allow_internal: bool,
29    #[serde(default)]
30    pub blocked_hosts: Vec<String>,
31    #[serde(default)]
32    pub ok_status_code_range: Option<String>,
33    #[serde(default)]
34    pub tls: Option<TlsConfig>,
35    #[serde(default)]
36    pub proxy_url: Option<String>,
37}
38
39/// TLS configuration for HTTP/HTTPS client connections.
40#[derive(Debug, Clone, PartialEq, Deserialize)]
41pub struct TlsConfig {
42    /// Enables TLS customization for client connections.
43    pub enabled: bool,
44    /// Verifies peer certificates when true. Defaults to true.
45    #[serde(default = "default_verify_peer")]
46    pub verify_peer: bool,
47    /// Optional path to custom CA certificate bundle (PEM or DER).
48    #[serde(default)]
49    pub ca_cert_path: Option<String>,
50    /// Optional path to client certificate for mTLS (PEM).
51    #[serde(default)]
52    pub client_cert_path: Option<String>,
53    /// Optional path to client private key for mTLS (PEM).
54    #[serde(default)]
55    pub client_key_path: Option<String>,
56    /// If true, skips certificate verification (discouraged).
57    #[serde(default)]
58    pub insecure: bool,
59}
60
61fn default_verify_peer() -> bool {
62    true
63}
64
65impl Default for TlsConfig {
66    fn default() -> Self {
67        Self {
68            enabled: false,
69            verify_peer: default_verify_peer(),
70            ca_cert_path: None,
71            client_cert_path: None,
72            client_key_path: None,
73            insecure: false,
74        }
75    }
76}
77
78/// Server-side TLS configuration for HTTP consumer endpoints.
79///
80/// When present, the HTTP server binds with TLS via `axum_server::from_tcp_rustls`.
81/// When absent, the server binds plain HTTP via `axum::serve`.
82#[derive(Clone)]
83pub struct ServerTlsConfig {
84    /// Path to the PEM-encoded server certificate chain.
85    pub cert_path: String,
86    /// Path to the PEM-encoded server private key.
87    pub key_path: String,
88}
89
90impl std::fmt::Debug for ServerTlsConfig {
91    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
92        f.debug_struct("ServerTlsConfig")
93            .field("cert_path", &"[REDACTED]")
94            .field("key_path", &"[REDACTED]")
95            .finish()
96    }
97}
98
99fn default_connect_timeout_ms() -> u64 {
100    5_000
101}
102
103fn default_pool_max_idle_per_host() -> usize {
104    100
105}
106
107fn default_pool_idle_timeout_ms() -> u64 {
108    90_000
109}
110
111fn default_response_timeout_ms() -> u64 {
112    30_000
113}
114
115fn default_read_timeout_ms() -> u64 {
116    30_000
117}
118
119fn default_max_body_size() -> usize {
120    10_485_760
121}
122
123fn default_max_response_bytes() -> usize {
124    10_485_760
125}
126
127fn default_max_request_body() -> usize {
128    2_097_152
129}
130
131impl Default for HttpConfig {
132    fn default() -> Self {
133        Self {
134            connect_timeout_ms: default_connect_timeout_ms(),
135            pool_max_idle_per_host: default_pool_max_idle_per_host(),
136            pool_idle_timeout_ms: default_pool_idle_timeout_ms(),
137            follow_redirects: false,
138            max_redirects: None,
139            response_timeout_ms: default_response_timeout_ms(),
140            read_timeout_ms: default_read_timeout_ms(),
141            max_body_size: default_max_body_size(),
142            max_response_bytes: default_max_response_bytes(),
143            max_request_body: default_max_request_body(),
144            allow_internal: false,
145            blocked_hosts: Vec::new(),
146            ok_status_code_range: None,
147            tls: None,
148            proxy_url: None,
149        }
150    }
151}
152
153impl HttpConfig {
154    pub fn validate(&self) -> Result<(), CamelError> {
155        if let Some(max_redirects) = self.max_redirects
156            && max_redirects > 20
157        {
158            return Err(CamelError::Config(
159                "max_redirects must be <= 20".to_string(),
160            ));
161        }
162
163        if let Some(range) = &self.ok_status_code_range {
164            parse_ok_status_code_range(range)?;
165        }
166
167        if let Some(ref proxy) = self.proxy_url
168            && url::Url::parse(proxy).is_err()
169        {
170            return Err(CamelError::Config(format!(
171                "proxy_url '{proxy}' is not a valid URL"
172            )));
173        }
174
175        Ok(())
176    }
177
178    pub fn with_connect_timeout_ms(mut self, ms: u64) -> Self {
179        self.connect_timeout_ms = ms;
180        self
181    }
182    pub fn with_pool_max_idle_per_host(mut self, n: usize) -> Self {
183        self.pool_max_idle_per_host = n;
184        self
185    }
186    pub fn with_pool_idle_timeout_ms(mut self, ms: u64) -> Self {
187        self.pool_idle_timeout_ms = ms;
188        self
189    }
190    pub fn with_follow_redirects(mut self, follow: bool) -> Self {
191        self.follow_redirects = follow;
192        self
193    }
194    pub fn with_max_redirects(mut self, max_redirects: Option<usize>) -> Self {
195        self.max_redirects = max_redirects;
196        self
197    }
198    pub fn with_response_timeout_ms(mut self, ms: u64) -> Self {
199        self.response_timeout_ms = ms;
200        self
201    }
202    pub fn with_read_timeout_ms(mut self, ms: u64) -> Self {
203        self.read_timeout_ms = ms;
204        self
205    }
206    pub fn with_max_body_size(mut self, n: usize) -> Self {
207        self.max_body_size = n;
208        self
209    }
210    pub fn with_max_response_bytes(mut self, n: usize) -> Self {
211        self.max_response_bytes = n;
212        self
213    }
214    pub fn with_max_request_body(mut self, n: usize) -> Self {
215        self.max_request_body = n;
216        self
217    }
218    pub fn with_allow_internal(mut self, allow: bool) -> Self {
219        self.allow_internal = allow;
220        self
221    }
222    pub fn with_blocked_hosts(mut self, hosts: Vec<String>) -> Self {
223        self.blocked_hosts = hosts;
224        self
225    }
226    pub fn with_ok_status_code_range(mut self, range: Option<String>) -> Self {
227        self.ok_status_code_range = range;
228        self
229    }
230    pub fn with_tls(mut self, tls: Option<TlsConfig>) -> Self {
231        self.tls = tls;
232        self
233    }
234    /// Setting `proxy_url` is rejected at validation time because it
235    /// defeats DNS pinning. The field is kept for serde backward-compat
236    /// with existing TOML configs; new code must not set it.
237    #[deprecated(note = "proxy_url is incompatible with SSRF DNS pinning")]
238    pub fn with_proxy_url(mut self, proxy_url: Option<String>) -> Self {
239        self.proxy_url = proxy_url;
240        self
241    }
242}
243
244pub(crate) fn parse_ok_status_code_range(range: &str) -> Result<(u16, u16), CamelError> {
245    let (start_str, end_str) = range.split_once('-').ok_or_else(|| {
246        CamelError::Config("ok_status_code_range must be in NNN-NNN format".to_string())
247    })?;
248
249    if start_str.len() != 3
250        || end_str.len() != 3
251        || !start_str.chars().all(|c| c.is_ascii_digit())
252        || !end_str.chars().all(|c| c.is_ascii_digit())
253    {
254        return Err(CamelError::Config(
255            "ok_status_code_range must be in NNN-NNN format".to_string(),
256        ));
257    }
258
259    let start = start_str
260        .parse::<u16>()
261        .map_err(|_| CamelError::Config("ok_status_code_range start is invalid".to_string()))?;
262    let end = end_str
263        .parse::<u16>()
264        .map_err(|_| CamelError::Config("ok_status_code_range end is invalid".to_string()))?;
265
266    if start > end {
267        return Err(CamelError::Config(
268            "ok_status_code_range start must be <= end".to_string(),
269        ));
270    }
271
272    Ok((start, end))
273}
274
275#[cfg(test)]
276mod tests {
277    use super::*;
278
279    #[test]
280    fn test_http_config_defaults() {
281        let cfg = HttpConfig::default();
282        assert_eq!(cfg.connect_timeout_ms, 5_000);
283        assert_eq!(cfg.pool_max_idle_per_host, 100);
284        assert_eq!(cfg.pool_idle_timeout_ms, 90_000);
285        assert!(!cfg.follow_redirects);
286        assert_eq!(cfg.max_redirects, None);
287        assert_eq!(cfg.response_timeout_ms, 30_000);
288        assert_eq!(cfg.max_body_size, 10_485_760);
289        assert_eq!(cfg.max_request_body, 2_097_152);
290        assert!(!cfg.allow_internal);
291        assert!(cfg.blocked_hosts.is_empty());
292        assert!(cfg.tls.is_none());
293        assert!(cfg.proxy_url.is_none());
294    }
295
296    #[test]
297    fn test_http_config_builder() {
298        let cfg = HttpConfig::default()
299            .with_connect_timeout_ms(1_000)
300            .with_pool_max_idle_per_host(50)
301            .with_follow_redirects(true)
302            .with_allow_internal(true)
303            .with_blocked_hosts(vec!["evil.com".to_string()]);
304        assert_eq!(cfg.connect_timeout_ms, 1_000);
305        assert_eq!(cfg.pool_max_idle_per_host, 50);
306        assert!(cfg.follow_redirects);
307        assert!(cfg.allow_internal);
308        assert_eq!(cfg.blocked_hosts, vec!["evil.com".to_string()]);
309        assert_eq!(cfg.response_timeout_ms, 30_000);
310    }
311
312    #[test]
313    fn test_rejects_max_redirects_over_limit() {
314        let cfg = HttpConfig {
315            max_redirects: Some(21),
316            ..HttpConfig::default()
317        };
318        assert!(cfg.validate().is_err());
319    }
320
321    #[test]
322    fn test_accepts_valid_max_redirects() {
323        let cfg = HttpConfig {
324            max_redirects: Some(10),
325            ..HttpConfig::default()
326        };
327        assert!(cfg.validate().is_ok());
328    }
329
330    #[test]
331    fn test_rejects_malformed_status_range() {
332        let cfg = HttpConfig {
333            ok_status_code_range: Some("abc-xyz".into()),
334            ..HttpConfig::default()
335        };
336        assert!(cfg.validate().is_err());
337    }
338
339    #[test]
340    fn test_accepts_valid_status_range() {
341        let cfg = HttpConfig {
342            ok_status_code_range: Some("200-299".into()),
343            ..HttpConfig::default()
344        };
345        assert!(cfg.validate().is_ok());
346    }
347
348    #[test]
349    fn test_rejects_invalid_proxy_url() {
350        let cfg = HttpConfig {
351            proxy_url: Some("::not-a-proxy::".into()),
352            ..HttpConfig::default()
353        };
354        assert!(cfg.validate().is_err());
355    }
356
357    #[test]
358    fn test_accepts_valid_proxy_url() {
359        let cfg = HttpConfig {
360            proxy_url: Some("http://proxy:8080".into()),
361            ..HttpConfig::default()
362        };
363        assert!(cfg.validate().is_ok());
364    }
365
366    #[test]
367    fn server_tls_config_debug_redacts_paths() {
368        let cfg = super::ServerTlsConfig {
369            cert_path: "/secret/cert.pem".to_string(),
370            key_path: "/secret/key.pem".to_string(),
371        };
372        let debug = format!("{:?}", cfg);
373        assert!(
374            !debug.contains("/secret"),
375            "paths must be redacted: {debug}"
376        );
377        assert!(debug.contains("REDACTED"), "must show REDACTED: {debug}");
378    }
379}