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