Skip to main content

microsandbox_network/
builder.rs

1//! Fluent builder API for [`NetworkConfig`].
2//!
3//! Used by `SandboxBuilder::network(|n| n.port(8080, 80).policy(...))`.
4
5use std::net::IpAddr;
6use std::path::PathBuf;
7
8use crate::config::{DnsConfig, InterfaceOverrides, NetworkConfig, PortProtocol, PublishedPort};
9use crate::dns::Nameserver;
10use crate::policy::{BuildError, DomainName, NetworkPolicy};
11use crate::secrets::config::{HostPattern, SecretEntry, SecretInjection, ViolationAction};
12use crate::tls::TlsConfig;
13
14//--------------------------------------------------------------------------------------------------
15// Types
16//--------------------------------------------------------------------------------------------------
17
18/// Fluent builder for [`NetworkConfig`].
19pub struct NetworkBuilder {
20    config: NetworkConfig,
21    errors: Vec<BuildError>,
22}
23
24/// Fluent builder for [`DnsConfig`].
25pub struct DnsBuilder {
26    config: DnsConfig,
27    errors: Vec<BuildError>,
28}
29
30/// Fluent builder for [`TlsConfig`].
31pub struct TlsBuilder {
32    config: TlsConfig,
33}
34
35/// Fluent builder for a single [`SecretEntry`].
36///
37/// ```ignore
38/// SecretBuilder::new()
39///     .env("OPENAI_API_KEY")
40///     .value(api_key)
41///     .allow_host("api.openai.com")
42///     .build()
43/// ```
44pub struct SecretBuilder {
45    env_var: Option<String>,
46    value: Option<String>,
47    placeholder: Option<String>,
48    allowed_hosts: Vec<HostPattern>,
49    injection: SecretInjection,
50    require_tls_identity: bool,
51}
52
53//--------------------------------------------------------------------------------------------------
54// Methods
55//--------------------------------------------------------------------------------------------------
56
57impl NetworkBuilder {
58    /// Start building a network configuration with defaults.
59    pub fn new() -> Self {
60        Self {
61            config: NetworkConfig::default(),
62            errors: Vec::new(),
63        }
64    }
65
66    /// Start building from an existing network configuration.
67    pub fn from_config(config: NetworkConfig) -> Self {
68        Self {
69            config,
70            errors: Vec::new(),
71        }
72    }
73
74    /// Enable or disable networking.
75    pub fn enabled(mut self, enabled: bool) -> Self {
76        self.config.enabled = enabled;
77        self
78    }
79
80    /// Publish a TCP port: `host_port` on the host maps to `guest_port` in the guest.
81    pub fn port(self, host_port: u16, guest_port: u16) -> Self {
82        self.add_port(host_port, guest_port, PortProtocol::Tcp)
83    }
84
85    /// Publish a UDP port.
86    pub fn port_udp(self, host_port: u16, guest_port: u16) -> Self {
87        self.add_port(host_port, guest_port, PortProtocol::Udp)
88    }
89
90    fn add_port(mut self, host_port: u16, guest_port: u16, protocol: PortProtocol) -> Self {
91        self.config.ports.push(PublishedPort {
92            host_port,
93            guest_port,
94            protocol,
95            host_bind: IpAddr::V4(std::net::Ipv4Addr::LOCALHOST),
96        });
97        self
98    }
99
100    /// Set the network policy.
101    pub fn policy(mut self, policy: NetworkPolicy) -> Self {
102        self.config.policy = policy;
103        self
104    }
105
106    /// Configure DNS interception via a closure.
107    ///
108    /// ```ignore
109    /// .dns(|d| d
110    ///     .block_domain("malware.example.com")
111    ///     .block_domain_suffix(".tracking.com")
112    ///     .nameservers(["1.1.1.1".parse::<Nameserver>()?])
113    /// )
114    /// ```
115    pub fn dns(mut self, f: impl FnOnce(DnsBuilder) -> DnsBuilder) -> Self {
116        let dns_builder = f(DnsBuilder::new());
117        match dns_builder.build() {
118            Ok(config) => self.config.dns = config,
119            Err(err) => self.errors.push(err),
120        }
121        self
122    }
123
124    /// Configure TLS interception via a closure.
125    pub fn tls(mut self, f: impl FnOnce(TlsBuilder) -> TlsBuilder) -> Self {
126        self.config.tls = f(TlsBuilder::new()).build();
127        self
128    }
129
130    /// Add a secret via a closure builder.
131    ///
132    /// ```ignore
133    /// .secret(|s| s
134    ///     .env("OPENAI_API_KEY")
135    ///     .value(api_key)
136    ///     .allow_host("api.openai.com")
137    /// )
138    /// ```
139    pub fn secret(mut self, f: impl FnOnce(SecretBuilder) -> SecretBuilder) -> Self {
140        self.config
141            .secrets
142            .secrets
143            .push(f(SecretBuilder::new()).build());
144        self
145    }
146
147    /// Shorthand: add a secret with env var, value, placeholder, and allowed host.
148    pub fn secret_env(
149        mut self,
150        env_var: impl Into<String>,
151        value: impl Into<String>,
152        placeholder: impl Into<String>,
153        allowed_host: impl Into<String>,
154    ) -> Self {
155        self.config.secrets.secrets.push(SecretEntry {
156            env_var: env_var.into(),
157            value: value.into(),
158            placeholder: placeholder.into(),
159            allowed_hosts: vec![HostPattern::Exact(allowed_host.into())],
160            injection: SecretInjection::default(),
161            require_tls_identity: true,
162        });
163        self
164    }
165
166    /// Set the violation action for secrets.
167    pub fn on_secret_violation(mut self, action: ViolationAction) -> Self {
168        self.config.secrets.on_violation = action;
169        self
170    }
171
172    /// Set the maximum number of concurrent connections.
173    pub fn max_connections(mut self, max: usize) -> Self {
174        self.config.max_connections = Some(max);
175        self
176    }
177
178    /// Set guest interface overrides.
179    pub fn interface(mut self, overrides: InterfaceOverrides) -> Self {
180        self.config.interface = overrides;
181        self
182    }
183
184    /// Whether to ship the host's trusted root CAs into the guest at
185    /// boot. Default: false. Opt in when running behind a corporate
186    /// TLS-inspecting proxy (Cloudflare Warp Zero Trust, Zscaler,
187    /// Netskope, ...) whose gateway CA is trusted on the host but
188    /// unknown to the guest's stock Mozilla bundle.
189    pub fn trust_host_cas(mut self, enabled: bool) -> Self {
190        self.config.trust_host_cas = enabled;
191        self
192    }
193
194    /// Consume the builder and return the configuration.
195    ///
196    /// Surfaces the first [`BuildError`] accumulated by any nested
197    /// builder (currently [`DnsBuilder`]). Errors stored on the
198    /// network builder itself flow through here too.
199    pub fn build(mut self) -> Result<NetworkConfig, BuildError> {
200        if let Some(err) = self.errors.drain(..).next() {
201            return Err(err);
202        }
203        Ok(self.config)
204    }
205}
206
207impl DnsBuilder {
208    /// Start building DNS configuration with defaults.
209    pub fn new() -> Self {
210        Self {
211            config: DnsConfig::default(),
212            errors: Vec::new(),
213        }
214    }
215
216    /// Block a specific domain via DNS interception (returns REFUSED).
217    ///
218    /// Accepts any string-like input. The string is stored raw and
219    /// parsed via [`DomainName`] at [`Self::build`] time. Invalid
220    /// names accumulate as
221    /// [`BuildError::InvalidBlockedDomain`] and surface from the
222    /// outermost `.build()` in the chain — the chain itself stays
223    /// infallible (no `?` per call).
224    pub fn block_domain(mut self, domain: impl Into<String>) -> Self {
225        let raw: String = domain.into();
226        match raw.parse::<DomainName>() {
227            Ok(name) => self.config.blocked_domains.push(name.into()),
228            Err(source) => self
229                .errors
230                .push(BuildError::InvalidBlockedDomain { raw, source }),
231        }
232        self
233    }
234
235    /// Block a domain suffix via DNS interception (returns REFUSED).
236    ///
237    /// Same string-input + lazy-parse + accumulate behavior as
238    /// [`Self::block_domain`]. Invalid suffixes accumulate as
239    /// [`BuildError::InvalidBlockedDomainSuffix`].
240    pub fn block_domain_suffix(mut self, suffix: impl Into<String>) -> Self {
241        let raw: String = suffix.into();
242        match raw.parse::<DomainName>() {
243            Ok(name) => self.config.blocked_suffixes.push(name.into()),
244            Err(source) => self
245                .errors
246                .push(BuildError::InvalidBlockedDomainSuffix { raw, source }),
247        }
248        self
249    }
250
251    /// Enable or disable DNS rebinding protection. Default: true.
252    pub fn rebind_protection(mut self, enabled: bool) -> Self {
253        self.config.rebind_protection = enabled;
254        self
255    }
256
257    /// Set the upstream nameservers to forward queries to. When one or
258    /// more are set, the interceptor uses these instead of the
259    /// nameservers in the host's `/etc/resolv.conf`. Replaces any
260    /// previously-set nameservers. Each element is any type convertible
261    /// into [`Nameserver`] (`SocketAddr`, `IpAddr`, or a parsed
262    /// string via `"dns.google:53".parse::<Nameserver>()?`).
263    pub fn nameservers<I>(mut self, nameservers: I) -> Self
264    where
265        I: IntoIterator,
266        I::Item: Into<Nameserver>,
267    {
268        self.config.nameservers = nameservers.into_iter().map(Into::into).collect();
269        self
270    }
271
272    /// Set the per-DNS-query timeout in milliseconds. Default: 5000.
273    pub fn query_timeout_ms(mut self, ms: u64) -> Self {
274        self.config.query_timeout_ms = ms;
275        self
276    }
277
278    /// Consume the builder and return the configuration.
279    ///
280    /// Surfaces the first parse error accumulated by `block_domain` /
281    /// `block_domain_suffix` (or any future lazy-parse method on this
282    /// builder). Successful chains return the populated `DnsConfig`.
283    pub fn build(mut self) -> Result<DnsConfig, BuildError> {
284        if let Some(err) = self.errors.drain(..).next() {
285            return Err(err);
286        }
287        Ok(self.config)
288    }
289}
290
291impl Default for DnsBuilder {
292    fn default() -> Self {
293        Self::new()
294    }
295}
296
297impl TlsBuilder {
298    /// Start building TLS configuration.
299    pub fn new() -> Self {
300        Self {
301            config: TlsConfig {
302                enabled: true,
303                ..TlsConfig::default()
304            },
305        }
306    }
307
308    /// Add a domain to the bypass list (no MITM). Supports `*.suffix` wildcards.
309    pub fn bypass(mut self, pattern: impl Into<String>) -> Self {
310        self.config.bypass.push(pattern.into());
311        self
312    }
313
314    /// Enable or disable upstream server certificate verification.
315    pub fn verify_upstream(mut self, verify: bool) -> Self {
316        self.config.verify_upstream = verify;
317        self
318    }
319
320    /// Set the ports to intercept.
321    pub fn intercepted_ports(mut self, ports: Vec<u16>) -> Self {
322        self.config.intercepted_ports = ports;
323        self
324    }
325
326    /// Enable or disable QUIC blocking on intercepted ports.
327    pub fn block_quic(mut self, block: bool) -> Self {
328        self.config.block_quic_on_intercept = block;
329        self
330    }
331
332    /// Add a CA certificate PEM file to trust for upstream server verification.
333    ///
334    /// Useful when the upstream server uses a self-signed or private CA certificate.
335    /// Can be called multiple times to add several CAs.
336    pub fn upstream_ca_cert(mut self, path: impl Into<PathBuf>) -> Self {
337        self.config.upstream_ca_cert.push(path.into());
338        self
339    }
340
341    /// Set a custom interception CA certificate PEM file path.
342    pub fn intercept_ca_cert(mut self, path: impl Into<PathBuf>) -> Self {
343        self.config.intercept_ca.cert_path = Some(path.into());
344        self
345    }
346
347    /// Set a custom interception CA private key PEM file path.
348    pub fn intercept_ca_key(mut self, path: impl Into<PathBuf>) -> Self {
349        self.config.intercept_ca.key_path = Some(path.into());
350        self
351    }
352
353    /// Consume the builder and return the configuration.
354    pub fn build(self) -> TlsConfig {
355        self.config
356    }
357}
358
359impl SecretBuilder {
360    /// Start building a secret.
361    pub fn new() -> Self {
362        Self {
363            env_var: None,
364            value: None,
365            placeholder: None,
366            allowed_hosts: Vec::new(),
367            injection: SecretInjection::default(),
368            require_tls_identity: true,
369        }
370    }
371
372    /// Set the environment variable to expose the placeholder as (required).
373    pub fn env(mut self, var: impl Into<String>) -> Self {
374        self.env_var = Some(var.into());
375        self
376    }
377
378    /// Set the secret value (required).
379    pub fn value(mut self, value: impl Into<String>) -> Self {
380        self.value = Some(value.into());
381        self
382    }
383
384    /// Set a custom placeholder string.
385    /// If not set, auto-generated as `$MSB_<env_var>`.
386    pub fn placeholder(mut self, placeholder: impl Into<String>) -> Self {
387        self.placeholder = Some(placeholder.into());
388        self
389    }
390
391    /// Add an allowed host (exact match).
392    pub fn allow_host(mut self, host: impl Into<String>) -> Self {
393        self.allowed_hosts.push(HostPattern::Exact(host.into()));
394        self
395    }
396
397    /// Add an allowed host with wildcard pattern (e.g., `*.openai.com`).
398    pub fn allow_host_pattern(mut self, pattern: impl Into<String>) -> Self {
399        self.allowed_hosts
400            .push(HostPattern::Wildcard(pattern.into()));
401        self
402    }
403
404    /// Allow for any host. **Dangerous**: secret can be exfiltrated to any
405    /// destination. Requires explicit acknowledgment.
406    pub fn allow_any_host_dangerous(mut self, i_understand_the_risk: bool) -> Self {
407        if i_understand_the_risk {
408            self.allowed_hosts.push(HostPattern::Any);
409        }
410        self
411    }
412
413    /// Require verified TLS identity before substituting (default: true).
414    pub fn require_tls_identity(mut self, enabled: bool) -> Self {
415        self.require_tls_identity = enabled;
416        self
417    }
418
419    /// Configure header injection (default: true).
420    pub fn inject_headers(mut self, enabled: bool) -> Self {
421        self.injection.headers = enabled;
422        self
423    }
424
425    /// Configure Basic Auth injection (default: true).
426    pub fn inject_basic_auth(mut self, enabled: bool) -> Self {
427        self.injection.basic_auth = enabled;
428        self
429    }
430
431    /// Configure query parameter injection (default: false).
432    pub fn inject_query(mut self, enabled: bool) -> Self {
433        self.injection.query_params = enabled;
434        self
435    }
436
437    /// Configure body injection (default: false).
438    pub fn inject_body(mut self, enabled: bool) -> Self {
439        self.injection.body = enabled;
440        self
441    }
442
443    /// Consume the builder and return a [`SecretEntry`].
444    ///
445    /// # Panics
446    /// Panics if `env` or `value` was not set.
447    pub fn build(self) -> SecretEntry {
448        let env_var = self.env_var.expect("SecretBuilder: .env() is required");
449        let value = self.value.expect("SecretBuilder: .value() is required");
450        let placeholder = self
451            .placeholder
452            .unwrap_or_else(|| format!("$MSB_{env_var}"));
453
454        SecretEntry {
455            env_var,
456            value,
457            placeholder,
458            allowed_hosts: self.allowed_hosts,
459            injection: self.injection,
460            require_tls_identity: self.require_tls_identity,
461        }
462    }
463}
464
465//--------------------------------------------------------------------------------------------------
466// Trait Implementations
467//--------------------------------------------------------------------------------------------------
468
469impl Default for NetworkBuilder {
470    fn default() -> Self {
471        Self::new()
472    }
473}
474
475impl Default for TlsBuilder {
476    fn default() -> Self {
477        Self::new()
478    }
479}
480
481impl Default for SecretBuilder {
482    fn default() -> Self {
483        Self::new()
484    }
485}
486
487//--------------------------------------------------------------------------------------------------
488// Tests
489//--------------------------------------------------------------------------------------------------
490
491#[cfg(test)]
492mod tests {
493    use super::*;
494
495    /// Valid block-domain entries land in the config. No errors on
496    /// the happy path.
497    #[test]
498    fn block_domain_happy_path() {
499        let cfg = DnsBuilder::new()
500            .block_domain("evil.com")
501            .block_domain_suffix(".tracking.example")
502            .build()
503            .unwrap();
504        assert_eq!(cfg.blocked_domains.len(), 1);
505        assert_eq!(cfg.blocked_suffixes.len(), 1);
506    }
507
508    /// Invalid block-domain accumulates as
509    /// `BuildError::InvalidBlockedDomain`; surfaces from `.build()`.
510    #[test]
511    fn block_domain_invalid_surfaces_at_build() {
512        let result = DnsBuilder::new().block_domain("not a domain!").build();
513        match result {
514            Err(BuildError::InvalidBlockedDomain { raw, .. }) => {
515                assert_eq!(raw, "not a domain!");
516            }
517            other => panic!("expected InvalidBlockedDomain, got {other:?}"),
518        }
519    }
520
521    /// Invalid suffix accumulates as
522    /// `BuildError::InvalidBlockedDomainSuffix`.
523    #[test]
524    fn block_domain_suffix_invalid_surfaces_at_build() {
525        let result = DnsBuilder::new()
526            .block_domain_suffix("...invalid!!!")
527            .build();
528        match result {
529            Err(BuildError::InvalidBlockedDomainSuffix { raw, .. }) => {
530                assert_eq!(raw, "...invalid!!!");
531            }
532            other => panic!("expected InvalidBlockedDomainSuffix, got {other:?}"),
533        }
534    }
535
536    /// First-error semantics: multiple bad inputs surface only the
537    /// first one (matching `NetworkPolicyBuilder`).
538    #[test]
539    fn block_domain_first_error_wins() {
540        let result = DnsBuilder::new()
541            .block_domain("first bad!")
542            .block_domain("second bad!")
543            .build();
544        match result {
545            Err(BuildError::InvalidBlockedDomain { raw, .. }) => {
546                assert_eq!(raw, "first bad!");
547            }
548            other => panic!("expected first-error InvalidBlockedDomain, got {other:?}"),
549        }
550    }
551
552    /// Errors accumulated by a `DnsBuilder` cascade up through
553    /// `NetworkBuilder::dns()` and surface from
554    /// `NetworkBuilder::build()`.
555    #[test]
556    fn dns_error_cascades_through_network_builder() {
557        let result = NetworkBuilder::new()
558            .dns(|d| d.block_domain("not a domain!"))
559            .build();
560        match result {
561            Err(BuildError::InvalidBlockedDomain { raw, .. }) => {
562                assert_eq!(raw, "not a domain!");
563            }
564            other => panic!("expected cascaded InvalidBlockedDomain, got {other:?}"),
565        }
566    }
567
568    /// Network builder happy path returns the config unchanged.
569    #[test]
570    fn network_builder_happy_path_returns_config() {
571        let cfg = NetworkBuilder::new()
572            .dns(|d| d.block_domain("evil.com"))
573            .build()
574            .unwrap();
575        assert_eq!(cfg.dns.blocked_domains.len(), 1);
576    }
577}