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