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#[derive(Clone, PartialEq, Deserialize)]
41pub struct TlsConfig {
42 pub enabled: bool,
44 #[serde(default = "default_verify_peer")]
46 pub verify_peer: bool,
47 #[serde(default)]
49 pub ca_cert_path: Option<String>,
50 #[serde(default)]
52 pub client_cert_path: Option<String>,
53 #[serde(default)]
55 pub client_key_path: Option<String>,
56 #[serde(default)]
58 pub insecure: bool,
59 #[serde(default)]
71 pub strict: bool,
72}
73
74fn default_verify_peer() -> bool {
75 true
76}
77
78impl Default for TlsConfig {
79 fn default() -> Self {
80 Self {
81 enabled: false,
82 verify_peer: default_verify_peer(),
83 ca_cert_path: None,
84 client_cert_path: None,
85 client_key_path: None,
86 insecure: false,
87 strict: false,
88 }
89 }
90}
91
92impl std::fmt::Debug for TlsConfig {
96 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
97 f.debug_struct("TlsConfig")
98 .field("enabled", &self.enabled)
99 .field("verify_peer", &self.verify_peer)
100 .field("ca_cert_path", &"[REDACTED]")
101 .field("client_cert_path", &"[REDACTED]")
102 .field("client_key_path", &"[REDACTED]")
103 .field("insecure", &self.insecure)
104 .field("strict", &self.strict)
105 .finish()
106 }
107}
108
109#[derive(Clone)]
114pub struct ServerTlsConfig {
115 pub cert_path: String,
117 pub key_path: String,
119}
120
121impl std::fmt::Debug for ServerTlsConfig {
122 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
123 f.debug_struct("ServerTlsConfig")
124 .field("cert_path", &"[REDACTED]")
125 .field("key_path", &"[REDACTED]")
126 .finish()
127 }
128}
129
130fn default_connect_timeout_ms() -> u64 {
131 5_000
132}
133
134fn default_pool_max_idle_per_host() -> usize {
135 100
136}
137
138fn default_pool_idle_timeout_ms() -> u64 {
139 90_000
140}
141
142fn default_response_timeout_ms() -> u64 {
143 30_000
144}
145
146fn default_read_timeout_ms() -> u64 {
147 30_000
148}
149
150fn default_max_body_size() -> usize {
151 10_485_760
152}
153
154fn default_max_response_bytes() -> usize {
155 10_485_760
156}
157
158fn default_max_request_body() -> usize {
159 2_097_152
160}
161
162impl Default for HttpConfig {
163 fn default() -> Self {
164 Self {
165 connect_timeout_ms: default_connect_timeout_ms(),
166 pool_max_idle_per_host: default_pool_max_idle_per_host(),
167 pool_idle_timeout_ms: default_pool_idle_timeout_ms(),
168 follow_redirects: false,
169 max_redirects: None,
170 response_timeout_ms: default_response_timeout_ms(),
171 read_timeout_ms: default_read_timeout_ms(),
172 max_body_size: default_max_body_size(),
173 max_response_bytes: default_max_response_bytes(),
174 max_request_body: default_max_request_body(),
175 allow_internal: false,
176 blocked_hosts: Vec::new(),
177 ok_status_code_range: None,
178 tls: None,
179 proxy_url: None,
180 }
181 }
182}
183
184impl HttpConfig {
185 pub fn validate(&self) -> Result<(), CamelError> {
186 if let Some(max_redirects) = self.max_redirects
187 && max_redirects > 20
188 {
189 return Err(CamelError::Config(
190 "max_redirects must be <= 20".to_string(),
191 ));
192 }
193
194 if let Some(range) = &self.ok_status_code_range {
195 parse_ok_status_code_range(range)?;
196 }
197
198 if self.proxy_url.is_some() {
199 return Err(CamelError::Config(
200 "proxy_url is incompatible with SSRF DNS pinning and cannot be used".to_string(),
201 ));
202 }
203
204 Ok(())
205 }
206
207 pub fn with_connect_timeout_ms(mut self, ms: u64) -> Self {
208 self.connect_timeout_ms = ms;
209 self
210 }
211 pub fn with_pool_max_idle_per_host(mut self, n: usize) -> Self {
212 self.pool_max_idle_per_host = n;
213 self
214 }
215 pub fn with_pool_idle_timeout_ms(mut self, ms: u64) -> Self {
216 self.pool_idle_timeout_ms = ms;
217 self
218 }
219 pub fn with_follow_redirects(mut self, follow: bool) -> Self {
220 self.follow_redirects = follow;
221 self
222 }
223 pub fn with_max_redirects(mut self, max_redirects: Option<usize>) -> Self {
224 self.max_redirects = max_redirects;
225 self
226 }
227 pub fn with_response_timeout_ms(mut self, ms: u64) -> Self {
228 self.response_timeout_ms = ms;
229 self
230 }
231 pub fn with_read_timeout_ms(mut self, ms: u64) -> Self {
232 self.read_timeout_ms = ms;
233 self
234 }
235 pub fn with_max_body_size(mut self, n: usize) -> Self {
236 self.max_body_size = n;
237 self
238 }
239 pub fn with_max_response_bytes(mut self, n: usize) -> Self {
240 self.max_response_bytes = n;
241 self
242 }
243 pub fn with_max_request_body(mut self, n: usize) -> Self {
244 self.max_request_body = n;
245 self
246 }
247 pub fn with_allow_internal(mut self, allow: bool) -> Self {
248 self.allow_internal = allow;
249 self
250 }
251 pub fn with_blocked_hosts(mut self, hosts: Vec<String>) -> Self {
252 self.blocked_hosts = hosts;
253 self
254 }
255 pub fn with_ok_status_code_range(mut self, range: Option<String>) -> Self {
256 self.ok_status_code_range = range;
257 self
258 }
259 pub fn with_tls(mut self, tls: Option<TlsConfig>) -> Self {
260 self.tls = tls;
261 self
262 }
263}
264
265pub(crate) fn parse_ok_status_code_range(range: &str) -> Result<(u16, u16), CamelError> {
266 let (start_str, end_str) = range.split_once('-').ok_or_else(|| {
267 CamelError::Config("ok_status_code_range must be in NNN-NNN format".to_string())
268 })?;
269
270 if start_str.len() != 3
271 || end_str.len() != 3
272 || !start_str.chars().all(|c| c.is_ascii_digit())
273 || !end_str.chars().all(|c| c.is_ascii_digit())
274 {
275 return Err(CamelError::Config(
276 "ok_status_code_range must be in NNN-NNN format".to_string(),
277 ));
278 }
279
280 let start = start_str
281 .parse::<u16>()
282 .map_err(|_| CamelError::Config("ok_status_code_range start is invalid".to_string()))?;
283 let end = end_str
284 .parse::<u16>()
285 .map_err(|_| CamelError::Config("ok_status_code_range end is invalid".to_string()))?;
286
287 if start > end {
288 return Err(CamelError::Config(
289 "ok_status_code_range start must be <= end".to_string(),
290 ));
291 }
292
293 Ok((start, end))
294}
295
296#[cfg(test)]
297mod tests {
298 use super::*;
299
300 #[test]
301 fn test_http_config_defaults() {
302 let cfg = HttpConfig::default();
303 assert_eq!(cfg.connect_timeout_ms, 5_000);
304 assert_eq!(cfg.pool_max_idle_per_host, 100);
305 assert_eq!(cfg.pool_idle_timeout_ms, 90_000);
306 assert!(!cfg.follow_redirects);
307 assert_eq!(cfg.max_redirects, None);
308 assert_eq!(cfg.response_timeout_ms, 30_000);
309 assert_eq!(cfg.max_body_size, 10_485_760);
310 assert_eq!(cfg.max_request_body, 2_097_152);
311 assert!(!cfg.allow_internal);
312 assert!(cfg.blocked_hosts.is_empty());
313 assert!(cfg.tls.is_none());
314 assert!(cfg.proxy_url.is_none());
315 }
316
317 #[test]
318 fn test_http_config_builder() {
319 let cfg = HttpConfig::default()
320 .with_connect_timeout_ms(1_000)
321 .with_pool_max_idle_per_host(50)
322 .with_follow_redirects(true)
323 .with_allow_internal(true)
324 .with_blocked_hosts(vec!["evil.com".to_string()]);
325 assert_eq!(cfg.connect_timeout_ms, 1_000);
326 assert_eq!(cfg.pool_max_idle_per_host, 50);
327 assert!(cfg.follow_redirects);
328 assert!(cfg.allow_internal);
329 assert_eq!(cfg.blocked_hosts, vec!["evil.com".to_string()]);
330 assert_eq!(cfg.response_timeout_ms, 30_000);
331 }
332
333 #[test]
334 fn test_rejects_max_redirects_over_limit() {
335 let cfg = HttpConfig {
336 max_redirects: Some(21),
337 ..HttpConfig::default()
338 };
339 assert!(cfg.validate().is_err());
340 }
341
342 #[test]
343 fn test_accepts_valid_max_redirects() {
344 let cfg = HttpConfig {
345 max_redirects: Some(10),
346 ..HttpConfig::default()
347 };
348 assert!(cfg.validate().is_ok());
349 }
350
351 #[test]
352 fn test_rejects_malformed_status_range() {
353 let cfg = HttpConfig {
354 ok_status_code_range: Some("abc-xyz".into()),
355 ..HttpConfig::default()
356 };
357 assert!(cfg.validate().is_err());
358 }
359
360 #[test]
361 fn test_accepts_valid_status_range() {
362 let cfg = HttpConfig {
363 ok_status_code_range: Some("200-299".into()),
364 ..HttpConfig::default()
365 };
366 assert!(cfg.validate().is_ok());
367 }
368
369 #[test]
370 fn test_rejects_proxy_url_with_invalid_url() {
371 let cfg = HttpConfig {
374 proxy_url: Some("::not-a-proxy::".into()),
375 ..HttpConfig::default()
376 };
377 let err = cfg.validate().expect_err("proxy_url must be rejected");
378 assert!(
379 err.to_string()
380 .contains("incompatible with SSRF DNS pinning"),
381 "expected SSRF rejection message, got: {err}"
382 );
383 }
384
385 #[test]
386 fn test_rejects_proxy_url_with_valid_url() {
387 let cfg = HttpConfig {
388 proxy_url: Some("http://proxy:8080".into()),
389 ..HttpConfig::default()
390 };
391 let err = cfg
392 .validate()
393 .expect_err("valid proxy_url must also be rejected");
394 assert!(
395 err.to_string()
396 .contains("incompatible with SSRF DNS pinning"),
397 "expected SSRF rejection message, got: {err}"
398 );
399 }
400
401 #[test]
402 fn test_proxy_url_toml_deserialize_then_reject() {
403 let toml_src = r#"
407 connect_timeout_ms = 5000
408 proxy_url = "http://proxy:8080"
409 "#;
410 let cfg: HttpConfig = toml::from_str(toml_src).expect("toml must deserialize");
411 assert_eq!(cfg.proxy_url.as_deref(), Some("http://proxy:8080"));
412
413 let err = cfg
414 .validate()
415 .expect_err("validate must reject deserialized proxy_url");
416 assert!(
417 err.to_string()
418 .contains("incompatible with SSRF DNS pinning"),
419 "expected SSRF rejection message, got: {err}"
420 );
421 }
422
423 #[test]
424 fn server_tls_config_debug_redacts_paths() {
425 let cfg = super::ServerTlsConfig {
426 cert_path: "/secret/cert.pem".to_string(),
427 key_path: "/secret/key.pem".to_string(),
428 };
429 let debug = format!("{:?}", cfg);
430 assert!(
431 !debug.contains("/secret"),
432 "paths must be redacted: {debug}"
433 );
434 assert!(debug.contains("REDACTED"), "must show REDACTED: {debug}");
435 }
436
437 #[test]
438 fn tls_config_debug_redacts_paths() {
439 let cfg = super::TlsConfig {
440 enabled: true,
441 verify_peer: true,
442 ca_cert_path: Some("/secret/ca.pem".to_string()),
443 client_cert_path: Some("/secret/cert.pem".to_string()),
444 client_key_path: Some("/secret/key.pem".to_string()),
445 insecure: false,
446 strict: false,
447 };
448 let debug = format!("{:?}", cfg);
449 assert!(
450 !debug.contains("/secret"),
451 "sensitive paths must be redacted: {debug}"
452 );
453 assert!(debug.contains("REDACTED"), "must show REDACTED: {debug}");
454 assert!(
455 debug.contains("enabled: true"),
456 "non-sensitive fields must stay visible: {debug}"
457 );
458 }
459}