Skip to main content

microsandbox_network/
config.rs

1//! Serializable network configuration types.
2//!
3//! These types represent the user-facing declarative network configuration
4//! for sandbox networking. Designed for the smoltcp in-process engine.
5
6use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
7
8use ipnetwork::{Ipv4Network, Ipv6Network};
9use serde::{Deserialize, Serialize};
10
11use crate::dns::Nameserver;
12
13use crate::policy::NetworkPolicy;
14use crate::secrets::config::SecretsConfig;
15use microsandbox_types::{NetworkRateLimiterConfig, TlsConfig};
16
17//--------------------------------------------------------------------------------------------------
18// Constants
19//--------------------------------------------------------------------------------------------------
20
21/// Maximum accepted value for [`NetworkConfig::max_connections`].
22///
23/// The smoltcp stack allocates per-connection socket buffers, so unusually
24/// large values can become a host-memory footgun before policy has a chance
25/// to reject traffic.
26pub const MAX_NETWORK_CONNECTIONS: usize = 4096;
27
28//--------------------------------------------------------------------------------------------------
29// Types
30//--------------------------------------------------------------------------------------------------
31
32/// Complete network configuration for a sandbox.
33///
34/// Narrowed for the smoltcp in-process engine. Gateway, prefix length, and
35/// other host-backend details are engine internals derived from the sandbox
36/// slot — the user only specifies what matters: interface overrides, ports,
37/// policy, DNS, TLS, and connection limits.
38#[derive(Debug, Clone, Serialize, Deserialize)]
39pub struct NetworkConfig {
40    /// Whether networking is enabled for this sandbox.
41    #[serde(default = "default_true")]
42    pub enabled: bool,
43
44    /// Guest interface overrides. Unset fields derived from sandbox slot.
45    #[serde(default)]
46    pub interface: InterfaceOverrides,
47
48    /// Host → guest port mappings.
49    #[serde(default)]
50    pub ports: Vec<PublishedPort>,
51
52    /// Egress/ingress policy rules.
53    #[serde(default)]
54    pub policy: NetworkPolicy,
55
56    /// DNS interception and filtering settings.
57    #[serde(default)]
58    pub dns: DnsConfig,
59
60    /// TLS interception settings.
61    #[serde(default)]
62    pub tls: TlsConfig,
63
64    /// Secret injection settings.
65    #[serde(default)]
66    pub secrets: SecretsConfig,
67
68    /// Max concurrent guest connections. Default: 256, maximum: 4096.
69    #[serde(default)]
70    pub max_connections: Option<usize>,
71
72    /// Egress and ingress rate limits. `None` means unlimited in both directions.
73    #[serde(default)]
74    pub rate_limiter: Option<NetworkRateLimiterConfig>,
75
76    /// Ship the host's trusted root CAs into the guest at boot so outbound
77    /// TLS works behind corporate MITM proxies (Cloudflare Warp Zero
78    /// Trust, Zscaler, Netskope, etc.) whose gateway CA is installed on
79    /// the host but not shipped in the Mozilla root bundle the guest OS
80    /// uses. Opt-in: host trust is not copied into the guest unless
81    /// this is explicitly enabled. Default: false.
82    #[serde(default)]
83    pub trust_host_cas: bool,
84}
85
86/// Optional overrides for the guest interface.
87///
88/// If omitted, values are derived deterministically from the sandbox slot.
89#[derive(Debug, Clone, Default, Serialize, Deserialize)]
90pub struct InterfaceOverrides {
91    /// Guest MAC address. Default: derived from slot.
92    #[serde(default)]
93    pub mac: Option<[u8; 6]>,
94
95    /// Interface MTU. Default: 1500.
96    #[serde(default)]
97    pub mtu: Option<u16>,
98
99    /// Guest IPv4 address. Default: derived from slot within `ipv4_pool`.
100    #[serde(default)]
101    pub ipv4_address: Option<Ipv4Addr>,
102
103    /// Guest IPv4 pool. Default: derived from slot (172.16.0.0/12 pool).
104    #[serde(default)]
105    pub ipv4_pool: Option<Ipv4Network>,
106
107    /// Guest IPv6 address. Default: derived from slot within `ipv6_pool`.
108    #[serde(default)]
109    pub ipv6_address: Option<Ipv6Addr>,
110
111    /// Guest IPv6 pool. Default: derived from slot (fd42:6d73:62::/48 pool).
112    #[serde(default)]
113    pub ipv6_pool: Option<Ipv6Network>,
114}
115
116/// DNS interception settings for the sandbox.
117#[derive(Debug, Clone, Serialize, Deserialize)]
118pub struct DnsConfig {
119    /// Whether DNS rebinding protection is enabled.
120    #[serde(default = "default_true")]
121    pub rebind_protection: bool,
122
123    /// Nameservers to forward DNS queries to. When empty, fall back to
124    /// the `nameserver` entries in the host's `/etc/resolv.conf`. Set
125    /// this to pin specific resolvers (e.g. `1.1.1.1:53`, `dns.google`)
126    /// or to work around split-DNS / VPN setups where the host's
127    /// resolv.conf is incomplete. Accepts IPs, `IP:PORT`, or hostnames
128    /// (resolved once at startup via the host's OS resolver).
129    #[serde(default)]
130    pub nameservers: Vec<Nameserver>,
131
132    /// Per-query timeout in milliseconds. Default: 5000.
133    #[serde(default = "default_query_timeout_ms")]
134    pub query_timeout_ms: u64,
135}
136
137/// A published port mapping between host and guest.
138#[derive(Debug, Clone, Serialize, Deserialize)]
139pub struct PublishedPort {
140    /// Host-side port to bind.
141    pub host_port: u16,
142
143    /// Guest-side port to forward to.
144    pub guest_port: u16,
145
146    /// Protocol (TCP or UDP).
147    #[serde(default)]
148    pub protocol: PortProtocol,
149
150    /// Host address to bind. Defaults to loopback.
151    #[serde(default = "default_host_bind")]
152    pub host_bind: IpAddr,
153}
154
155/// Protocol for a published port.
156#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
157pub enum PortProtocol {
158    /// TCP (default).
159    #[default]
160    #[serde(rename = "tcp", alias = "Tcp")]
161    Tcp,
162
163    /// UDP.
164    #[serde(rename = "udp", alias = "Udp")]
165    Udp,
166}
167
168//--------------------------------------------------------------------------------------------------
169// Trait Implementations
170//--------------------------------------------------------------------------------------------------
171
172impl Default for NetworkConfig {
173    fn default() -> Self {
174        Self {
175            enabled: true,
176            interface: InterfaceOverrides::default(),
177            ports: Vec::new(),
178            policy: NetworkPolicy::default(),
179            dns: DnsConfig::default(),
180            tls: TlsConfig::default(),
181            secrets: SecretsConfig::default(),
182            max_connections: None,
183            rate_limiter: None,
184            trust_host_cas: false,
185        }
186    }
187}
188
189impl Default for DnsConfig {
190    fn default() -> Self {
191        Self {
192            rebind_protection: true,
193            nameservers: Vec::new(),
194            query_timeout_ms: default_query_timeout_ms(),
195        }
196    }
197}
198
199//--------------------------------------------------------------------------------------------------
200// Functions
201//--------------------------------------------------------------------------------------------------
202
203fn default_true() -> bool {
204    true
205}
206
207fn default_host_bind() -> IpAddr {
208    IpAddr::V4(Ipv4Addr::LOCALHOST)
209}
210
211fn default_query_timeout_ms() -> u64 {
212    5000
213}
214
215//--------------------------------------------------------------------------------------------------
216// Tests
217//--------------------------------------------------------------------------------------------------
218
219#[cfg(test)]
220mod tests {
221    use super::{InterfaceOverrides, NetworkConfig, PortProtocol};
222    use crate::dns::Nameserver;
223    use crate::policy::{Destination, NetworkPolicy, Rule};
224
225    /// The engine's `policy`/`dns`/`interface` subdocuments must remain
226    /// serde-compatible with the wire twins in `microsandbox_types` that the
227    /// cloud `NetworkSpec` now carries concretely (replacing `Option<Value>`).
228    /// This guards against drift between the two representations.
229    #[test]
230    fn engine_network_subdocs_round_trip_through_wire_types() {
231        let mut config = NetworkConfig::default();
232        // Exercise the tricky leaves: a domain rule (validated `DomainName`), a
233        // CIDR rule (`IpNetwork`), group rules, parsed nameservers, and the
234        // interface IP/MAC/pool.
235        let mut policy = NetworkPolicy::default()
236            .allow_domain("example.com")
237            .expect("valid domain")
238            .allow_domain_suffix("staging.example.com")
239            .expect("valid suffix");
240        policy.rules.push(Rule::allow_egress(Destination::Cidr(
241            "151.101.0.0/16".parse().unwrap(),
242        )));
243        config.policy = policy;
244        config.dns.nameservers = vec![
245            "1.1.1.1:53".parse::<Nameserver>().unwrap(),
246            "dns.google".parse::<Nameserver>().unwrap(),
247        ];
248        config.interface.ipv4_address = Some("172.16.0.2".parse().unwrap());
249        config.interface.ipv4_pool = Some("172.16.0.0/12".parse().unwrap());
250        config.interface.mac = Some([0x02, 0, 0, 0, 0, 0x01]);
251
252        // The engine's real serialization of each subdocument.
253        let policy_json = serde_json::to_value(&config.policy).unwrap();
254        let dns_json = serde_json::to_value(&config.dns).unwrap();
255        let iface_json = serde_json::to_value(&config.interface).unwrap();
256
257        // It must deserialize into the wire types and re-serialize losslessly
258        // (policy/dns serialize every field on both sides, so compare raw JSON).
259        let wire_policy: microsandbox_types::NetworkPolicy =
260            serde_json::from_value(policy_json.clone()).unwrap();
261        let wire_dns: microsandbox_types::DnsConfig =
262            serde_json::from_value(dns_json.clone()).unwrap();
263        assert_eq!(policy_json, serde_json::to_value(&wire_policy).unwrap());
264        assert_eq!(dns_json, serde_json::to_value(&wire_dns).unwrap());
265
266        // `InterfaceOverrides` skips `None` fields on the wire side, so prove
267        // losslessness by round-tripping back into the engine type.
268        let wire_iface: microsandbox_types::InterfaceOverrides =
269            serde_json::from_value(iface_json.clone()).unwrap();
270        let back: InterfaceOverrides =
271            serde_json::from_value(serde_json::to_value(&wire_iface).unwrap()).unwrap();
272        assert_eq!(iface_json, serde_json::to_value(&back).unwrap());
273
274        // Snake_case is the canonical serialized form.
275        assert_eq!(
276            serde_json::to_string(&Destination::DomainSuffix(
277                "staging.example.com".parse().unwrap()
278            ))
279            .unwrap(),
280            r#"{"domain_suffix":"staging.example.com"}"#
281        );
282        let legacy: microsandbox_types::Destination =
283            serde_json::from_str(r#"{"domain_suffix":"old.example.com"}"#).unwrap();
284        assert!(matches!(
285            legacy,
286            microsandbox_types::Destination::DomainSuffix(_)
287        ));
288        let legacy_group: microsandbox_types::DestinationGroup =
289            serde_json::from_str(r#""link_local""#).unwrap();
290        assert_eq!(
291            legacy_group,
292            microsandbox_types::DestinationGroup::LinkLocal
293        );
294    }
295
296    /// A config persisted before rate limiters existed must keep
297    /// deserializing, defaulting both directions to unlimited.
298    #[test]
299    fn config_without_rate_limiter_fields_stays_unlimited() {
300        let config: NetworkConfig = serde_json::from_value(serde_json::json!({})).unwrap();
301        assert!(config.rate_limiter.is_none());
302    }
303
304    #[test]
305    fn rate_limiters_survive_the_wire_spec_round_trip() {
306        use microsandbox_types::{NetworkRateLimiterConfig, RateLimiterConfig, TokenBucketConfig};
307
308        let config = NetworkConfig {
309            rate_limiter: Some(NetworkRateLimiterConfig {
310                egress: Some(RateLimiterConfig {
311                    bandwidth: Some(TokenBucketConfig {
312                        size: 1024 * 1024,
313                        refill_time_ms: 1000,
314                        one_time_burst: 512 * 1024,
315                    }),
316                    ops: None,
317                }),
318                ingress: Some(RateLimiterConfig {
319                    bandwidth: None,
320                    ops: Some(TokenBucketConfig {
321                        size: 1000,
322                        refill_time_ms: 1000,
323                        one_time_burst: 0,
324                    }),
325                }),
326            }),
327            ..NetworkConfig::default()
328        };
329
330        let spec: microsandbox_types::NetworkSpec =
331            serde_json::from_value(serde_json::to_value(&config).unwrap()).unwrap();
332        assert_eq!(spec.rate_limiter, config.rate_limiter);
333
334        let back: NetworkConfig =
335            serde_json::from_value(serde_json::to_value(&spec).unwrap()).unwrap();
336        assert_eq!(back.rate_limiter, config.rate_limiter);
337    }
338
339    #[test]
340    fn port_protocol_serializes_lowercase_and_accepts_legacy_case() {
341        assert_eq!(
342            serde_json::to_string(&PortProtocol::Tcp).unwrap(),
343            "\"tcp\""
344        );
345        assert_eq!(
346            serde_json::to_string(&PortProtocol::Udp).unwrap(),
347            "\"udp\""
348        );
349        assert_eq!(
350            serde_json::from_str::<PortProtocol>("\"Tcp\"").unwrap(),
351            PortProtocol::Tcp
352        );
353        assert_eq!(
354            serde_json::from_str::<PortProtocol>("\"Udp\"").unwrap(),
355            PortProtocol::Udp
356        );
357    }
358}