Skip to main content

soth_mitm/
config.rs

1use std::net::SocketAddr;
2use std::path::PathBuf;
3
4use crate::destination::parse_destination_rule;
5use crate::MitmError;
6use crate::TlsVersion;
7
8/// Controls whether the proxy runs in observe-only or store-and-forward mode.
9///
10/// See [`MitmConfig::intercept_mode`].
11#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
12#[serde(rename_all = "snake_case")]
13pub enum InterceptMode {
14    /// Streaming tee: forward request to upstream immediately while capturing
15    /// a copy for the handler. Handler observes but cannot block.
16    Monitor,
17    /// Store-and-forward: buffer request body, call handler, then forward or block.
18    Enforce,
19}
20
21#[derive(Debug, Clone, PartialEq, Eq)]
22/// Top-level proxy configuration.
23///
24/// Use [`MitmConfig::default()`] and override individual fields.
25/// At minimum, [`interception.destinations`](InterceptionScope::destinations)
26/// must contain at least one entry before the config will pass validation.
27#[non_exhaustive]
28pub struct MitmConfig {
29    pub bind: SocketAddr,
30    pub unix_socket_path: Option<PathBuf>,
31    pub interception: InterceptionScope,
32    pub process_attribution: ProcessAttributionConfig,
33    pub tls: TlsConfig,
34    pub http2_enabled: bool,
35    pub http2_max_header_list_size: u32,
36    pub http3_passthrough: bool,
37    pub max_http_head_bytes: usize,
38    pub accept_retry_backoff_ms: u64,
39    pub max_flow_event_backlog: usize,
40    pub max_in_flight_bytes: usize,
41    pub max_concurrent_flows: usize,
42    pub upstream: UpstreamConfig,
43    pub connection_pool: ConnectionPoolConfig,
44    pub body: BodyConfig,
45    pub intercept_mode: InterceptMode,
46    pub handler: HandlerConfig,
47    pub flow_runtime: FlowRuntimeConfig,
48}
49
50#[derive(Debug, Clone, PartialEq, Eq)]
51#[non_exhaustive]
52pub struct InterceptionScope {
53    pub destinations: Vec<String>,
54    pub passthrough_unlisted: bool,
55}
56
57#[derive(Debug, Clone, PartialEq, Eq)]
58#[non_exhaustive]
59pub struct TlsConfig {
60    pub ca_cert_path: PathBuf,
61    pub ca_key_path: PathBuf,
62    pub min_version: TlsVersion,
63    pub capture_fingerprint: bool,
64}
65
66#[derive(Debug, Clone, PartialEq, Eq)]
67#[non_exhaustive]
68pub struct ProcessAttributionConfig {
69    pub enabled: bool,
70    pub lookup_timeout_ms: u64,
71    pub cache_capacity: usize,
72    pub cache_ttl_ms: Option<u64>,
73}
74
75#[derive(Debug, Clone, PartialEq, Eq)]
76#[non_exhaustive]
77pub struct UpstreamConfig {
78    pub timeout_ms: u64,
79    pub h2_header_stage_timeout_ms: u64,
80    pub h2_body_idle_timeout_ms: u64,
81    pub h2_response_overflow_mode: H2ResponseOverflowMode,
82    pub connect_timeout_ms: u64,
83    pub retry_on_failure: bool,
84    pub retry_delay_ms: u64,
85    pub verify_upstream_tls: bool,
86    pub dns_nameservers: Option<Vec<String>>,
87}
88
89#[derive(Debug, Clone, Copy, PartialEq, Eq)]
90pub enum H2ResponseOverflowMode {
91    TruncateContinue,
92    StrictFail,
93}
94
95#[derive(Debug, Clone, PartialEq, Eq)]
96#[non_exhaustive]
97pub struct ConnectionPoolConfig {
98    pub max_connections_per_host: u32,
99    pub idle_timeout_ms: u64,
100    pub max_idle_per_host: u32,
101}
102
103#[derive(Debug, Clone, PartialEq, Eq)]
104#[non_exhaustive]
105pub struct BodyConfig {
106    pub max_size_bytes: usize,
107    pub buffer_request_bodies: bool,
108}
109
110#[derive(Debug, Clone, PartialEq, Eq)]
111#[non_exhaustive]
112pub struct HandlerConfig {
113    pub request_timeout_ms: u64,
114    pub response_timeout_ms: u64,
115    pub recover_from_panics: bool,
116}
117
118#[derive(Debug, Clone, PartialEq, Eq)]
119#[non_exhaustive]
120pub struct FlowRuntimeConfig {
121    pub dispatch_queue_capacity: Option<usize>,
122    pub closed_flow_lru_capacity: Option<usize>,
123    pub stale_flow_ttl_ms: Option<u64>,
124    pub stale_reap_max_batch: Option<usize>,
125    pub dispatch_queue_send_timeout_ms: Option<u64>,
126    pub dispatch_close_join_timeout_ms: Option<u64>,
127}
128
129impl Default for MitmConfig {
130    fn default() -> Self {
131        Self {
132            bind: "127.0.0.1:8080"
133                .parse()
134                .expect("default bind address must parse"),
135            unix_socket_path: None,
136            interception: InterceptionScope::default(),
137            process_attribution: ProcessAttributionConfig::default(),
138            tls: TlsConfig::default(),
139            http2_enabled: true,
140            http2_max_header_list_size: 64 * 1024,
141            http3_passthrough: true,
142            max_http_head_bytes: 64 * 1024,
143            accept_retry_backoff_ms: 100,
144            max_flow_event_backlog: 8 * 1024,
145            max_in_flight_bytes: 64 * 1024 * 1024,
146            max_concurrent_flows: 2_048,
147            upstream: UpstreamConfig::default(),
148            connection_pool: ConnectionPoolConfig::default(),
149            body: BodyConfig::default(),
150            intercept_mode: InterceptMode::Monitor,
151            handler: HandlerConfig::default(),
152            flow_runtime: FlowRuntimeConfig::default(),
153        }
154    }
155}
156
157impl Default for InterceptionScope {
158    fn default() -> Self {
159        Self {
160            destinations: Vec::new(),
161            passthrough_unlisted: true,
162        }
163    }
164}
165
166impl Default for TlsConfig {
167    fn default() -> Self {
168        Self {
169            ca_cert_path: PathBuf::from("./certs/soth-mitm-ca.pem"),
170            ca_key_path: PathBuf::from("./certs/soth-mitm-ca-key.pem"),
171            min_version: TlsVersion::Tls12,
172            capture_fingerprint: true,
173        }
174    }
175}
176
177impl Default for ProcessAttributionConfig {
178    fn default() -> Self {
179        Self {
180            enabled: true,
181            lookup_timeout_ms: 5_000,
182            cache_capacity: 4_096,
183            cache_ttl_ms: Some(300_000), // 5-min TTL; prevents stale entries under high PID churn
184        }
185    }
186}
187
188impl Default for UpstreamConfig {
189    fn default() -> Self {
190        Self {
191            timeout_ms: 30_000,
192            h2_header_stage_timeout_ms: 30_000,
193            h2_body_idle_timeout_ms: 120_000,
194            h2_response_overflow_mode: H2ResponseOverflowMode::TruncateContinue,
195            connect_timeout_ms: 10_000,
196            retry_on_failure: false,
197            retry_delay_ms: 200,
198            verify_upstream_tls: true,
199            dns_nameservers: None,
200        }
201    }
202}
203
204impl Default for ConnectionPoolConfig {
205    fn default() -> Self {
206        Self {
207            max_connections_per_host: 32,
208            idle_timeout_ms: 600_000,
209            max_idle_per_host: 8,
210        }
211    }
212}
213
214impl Default for BodyConfig {
215    fn default() -> Self {
216        Self {
217            max_size_bytes: 32 * 1024 * 1024,
218            buffer_request_bodies: false,
219        }
220    }
221}
222
223impl Default for HandlerConfig {
224    fn default() -> Self {
225        Self {
226            request_timeout_ms: 15_000,
227            response_timeout_ms: 15_000,
228            recover_from_panics: true,
229        }
230    }
231}
232
233impl Default for FlowRuntimeConfig {
234    fn default() -> Self {
235        Self {
236            dispatch_queue_capacity: None, // auto-calculated from expected_live_flows
237            closed_flow_lru_capacity: Some(4_096), // match soth-proxy
238            stale_flow_ttl_ms: Some(60_000), // 60 s; match soth-proxy
239            stale_reap_max_batch: Some(50), // small batches; match soth-proxy
240            dispatch_queue_send_timeout_ms: None, // auto-tune
241            dispatch_close_join_timeout_ms: None, // auto-tune
242        }
243    }
244}
245
246impl MitmConfig {
247    pub fn validate(&self) -> Result<(), MitmError> {
248        if self.interception.destinations.is_empty() {
249            return Err(MitmError::InvalidConfig(
250                "interception.destinations must not be empty".to_string(),
251            ));
252        }
253        for destination in &self.interception.destinations {
254            parse_destination_rule(destination)?;
255        }
256        if self.process_attribution.enabled && self.process_attribution.lookup_timeout_ms == 0 {
257            return Err(MitmError::InvalidConfig(
258                "process_attribution.lookup_timeout_ms must be greater than zero".to_string(),
259            ));
260        }
261        if self.process_attribution.cache_capacity == 0 {
262            return Err(MitmError::InvalidConfig(
263                "process_attribution.cache_capacity must be greater than zero".to_string(),
264            ));
265        }
266        if self.process_attribution.cache_ttl_ms == Some(0) {
267            return Err(MitmError::InvalidConfig(
268                "process_attribution.cache_ttl_ms must be greater than zero when set".to_string(),
269            ));
270        }
271        if self.max_http_head_bytes == 0 {
272            return Err(MitmError::InvalidConfig(
273                "max_http_head_bytes must be greater than zero".to_string(),
274            ));
275        }
276        if self.accept_retry_backoff_ms == 0 {
277            return Err(MitmError::InvalidConfig(
278                "accept_retry_backoff_ms must be greater than zero".to_string(),
279            ));
280        }
281        if self.http2_max_header_list_size == 0 {
282            return Err(MitmError::InvalidConfig(
283                "http2_max_header_list_size must be greater than zero".to_string(),
284            ));
285        }
286        if self.max_flow_event_backlog == 0 {
287            return Err(MitmError::InvalidConfig(
288                "max_flow_event_backlog must be greater than zero".to_string(),
289            ));
290        }
291        if self.max_in_flight_bytes == 0 {
292            return Err(MitmError::InvalidConfig(
293                "max_in_flight_bytes must be greater than zero".to_string(),
294            ));
295        }
296        if self.max_concurrent_flows == 0 {
297            return Err(MitmError::InvalidConfig(
298                "max_concurrent_flows must be greater than zero".to_string(),
299            ));
300        }
301        if self.upstream.timeout_ms == 0 {
302            return Err(MitmError::InvalidConfig(
303                "upstream.timeout_ms must be greater than zero".to_string(),
304            ));
305        }
306        if self.upstream.h2_header_stage_timeout_ms == 0 {
307            return Err(MitmError::InvalidConfig(
308                "upstream.h2_header_stage_timeout_ms must be greater than zero".to_string(),
309            ));
310        }
311        if self.upstream.h2_body_idle_timeout_ms == 0 {
312            return Err(MitmError::InvalidConfig(
313                "upstream.h2_body_idle_timeout_ms must be greater than zero".to_string(),
314            ));
315        }
316        if self.upstream.connect_timeout_ms == 0 {
317            return Err(MitmError::InvalidConfig(
318                "upstream.connect_timeout_ms must be greater than zero".to_string(),
319            ));
320        }
321        if self.body.max_size_bytes == 0 {
322            return Err(MitmError::InvalidConfig(
323                "body.max_size_bytes must be greater than zero".to_string(),
324            ));
325        }
326        if self.handler.request_timeout_ms == 0 {
327            return Err(MitmError::InvalidConfig(
328                "handler.request_timeout_ms must be greater than zero".to_string(),
329            ));
330        }
331        if self.handler.response_timeout_ms == 0 {
332            return Err(MitmError::InvalidConfig(
333                "handler.response_timeout_ms must be greater than zero".to_string(),
334            ));
335        }
336        if self.flow_runtime.dispatch_queue_capacity == Some(0) {
337            return Err(MitmError::InvalidConfig(
338                "flow_runtime.dispatch_queue_capacity must be greater than zero when set"
339                    .to_string(),
340            ));
341        }
342        if self.flow_runtime.closed_flow_lru_capacity == Some(0) {
343            return Err(MitmError::InvalidConfig(
344                "flow_runtime.closed_flow_lru_capacity must be greater than zero when set"
345                    .to_string(),
346            ));
347        }
348        if self.flow_runtime.stale_flow_ttl_ms == Some(0) {
349            return Err(MitmError::InvalidConfig(
350                "flow_runtime.stale_flow_ttl_ms must be greater than zero when set".to_string(),
351            ));
352        }
353        if self.flow_runtime.stale_reap_max_batch == Some(0) {
354            return Err(MitmError::InvalidConfig(
355                "flow_runtime.stale_reap_max_batch must be greater than zero when set".to_string(),
356            ));
357        }
358        if self.flow_runtime.dispatch_queue_send_timeout_ms == Some(0) {
359            return Err(MitmError::InvalidConfig(
360                "flow_runtime.dispatch_queue_send_timeout_ms must be greater than zero when set"
361                    .to_string(),
362            ));
363        }
364        if self.flow_runtime.dispatch_close_join_timeout_ms == Some(0) {
365            return Err(MitmError::InvalidConfig(
366                "flow_runtime.dispatch_close_join_timeout_ms must be greater than zero when set"
367                    .to_string(),
368            ));
369        }
370        if self.connection_pool.max_connections_per_host == 0 {
371            return Err(MitmError::InvalidConfig(
372                "connection_pool.max_connections_per_host must be greater than zero".to_string(),
373            ));
374        }
375        if self.connection_pool.idle_timeout_ms == 0 {
376            return Err(MitmError::InvalidConfig(
377                "connection_pool.idle_timeout_ms must be greater than zero".to_string(),
378            ));
379        }
380        if self.connection_pool.max_idle_per_host == 0 {
381            return Err(MitmError::InvalidConfig(
382                "connection_pool.max_idle_per_host must be greater than zero".to_string(),
383            ));
384        }
385        Ok(())
386    }
387}
388
389#[cfg(test)]
390mod tests {
391    use super::MitmConfig;
392
393    fn valid_config() -> MitmConfig {
394        let mut config = MitmConfig::default();
395        config
396            .interception
397            .destinations
398            .push("api.example.com:443".to_string());
399        config
400    }
401
402    #[test]
403    fn default_runtime_knobs_match_expected_values() {
404        let config = MitmConfig::default();
405        assert!(config.http2_enabled);
406        assert_eq!(config.http2_max_header_list_size, 64 * 1024);
407        assert!(config.http3_passthrough);
408        assert_eq!(config.max_http_head_bytes, 64 * 1024);
409        assert_eq!(config.accept_retry_backoff_ms, 100);
410        assert_eq!(config.max_flow_event_backlog, 8 * 1024);
411        assert_eq!(config.max_in_flight_bytes, 64 * 1024 * 1024);
412        assert_eq!(config.max_concurrent_flows, 2_048);
413        assert_eq!(config.process_attribution.cache_capacity, 4_096);
414        assert_eq!(config.process_attribution.cache_ttl_ms, Some(300_000));
415        assert_eq!(config.upstream.h2_header_stage_timeout_ms, 30_000);
416        assert_eq!(config.upstream.h2_body_idle_timeout_ms, 120_000);
417        assert_eq!(config.body.max_size_bytes, 32 * 1024 * 1024);
418        assert_eq!(config.handler.request_timeout_ms, 15_000);
419        assert_eq!(config.handler.response_timeout_ms, 15_000);
420    }
421
422    #[test]
423    fn validate_rejects_zero_core_runtime_knobs() {
424        let mut config = valid_config();
425        config.max_concurrent_flows = 0;
426        let error = config
427            .validate()
428            .expect_err("zero runtime budget must fail");
429        let message = error.to_string();
430        assert!(message.contains("max_concurrent_flows"));
431    }
432
433    #[test]
434    fn validate_rejects_zero_h2_timeout_knobs() {
435        let mut config = valid_config();
436        config.upstream.h2_header_stage_timeout_ms = 0;
437        let error = config
438            .validate()
439            .expect_err("zero h2 header timeout must fail");
440        assert!(error.to_string().contains("h2_header_stage_timeout_ms"));
441
442        config.upstream.h2_header_stage_timeout_ms = 30_000;
443        config.upstream.h2_body_idle_timeout_ms = 0;
444        let error = config
445            .validate()
446            .expect_err("zero h2 body idle timeout must fail");
447        assert!(error.to_string().contains("h2_body_idle_timeout_ms"));
448    }
449
450    #[test]
451    fn validate_rejects_zero_flow_runtime_overrides() {
452        let mut config = valid_config();
453        config.flow_runtime.dispatch_queue_capacity = Some(0);
454        let error = config.validate().expect_err("zero flow override must fail");
455        let message = error.to_string();
456        assert!(message.contains("flow_runtime.dispatch_queue_capacity"));
457    }
458}