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