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