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(Debug, 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
78#[derive(Clone)]
83pub struct ServerTlsConfig {
84 pub cert_path: String,
86 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 #[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}