Skip to main content

microsandbox_network/
network.rs

1//! `SmoltcpNetwork` — orchestration type that ties [`NetworkConfig`] to the
2//! smoltcp engine.
3//!
4//! This is the networking analog to `PassthroughFs`/`MemFs` on the filesystem side — the single
5//! type the runtime creates from config, wires into the VM builder, and starts
6//! the networking stack.
7
8use std::net::{Ipv4Addr, Ipv6Addr, UdpSocket};
9use std::sync::Arc;
10use std::thread::JoinHandle;
11
12use microsandbox_protocol::{ENV_HOST_ALIAS, ENV_NET, ENV_NET_IPV4, ENV_NET_IPV6};
13use msb_krun::backends::net::NetBackend;
14
15use crate::backend::SmoltcpBackend;
16use crate::config::NetworkConfig;
17use crate::shared::{DEFAULT_QUEUE_CAPACITY, SharedState};
18use crate::stack::{self, GatewayIps, PollLoopConfig};
19use crate::tls::state::TlsState;
20
21//--------------------------------------------------------------------------------------------------
22// Constants
23//--------------------------------------------------------------------------------------------------
24
25/// Maximum sandbox slot value. Limited by MAC/IPv6 encoding (16 bits = 65535).
26/// The IPv4 pool (100.96.0.0/11 with /30 blocks) supports up to 524287 slots,
27/// but MAC and IPv6 derivation only encode the low 16 bits, so 65535 is the
28/// effective maximum.
29const MAX_SLOT: u64 = u16::MAX as u64;
30
31//--------------------------------------------------------------------------------------------------
32// Types
33//--------------------------------------------------------------------------------------------------
34
35/// The networking engine. Created from [`NetworkConfig`] by the runtime.
36///
37/// Owns the smoltcp poll thread and provides:
38/// - [`take_backend()`](Self::take_backend) — the `NetBackend` for `VmBuilder::net()`
39/// - [`guest_env_vars()`](Self::guest_env_vars) — `MSB_NET*` env vars for the guest
40/// - [`ca_cert_pem()`](Self::ca_cert_pem) — CA certificate for TLS interception
41pub struct SmoltcpNetwork {
42    config: NetworkConfig,
43    shared: Arc<SharedState>,
44    backend: Option<SmoltcpBackend>,
45    poll_handle: Option<JoinHandle<()>>,
46
47    // Resolved from config + slot.
48    guest_mac: [u8; 6],
49    gateway_mac: [u8; 6],
50    mtu: u16,
51    // IPv4 / IPv6 are `Some` when active for this sandbox: the user supplied
52    // an explicit address, or the host has a route for that family.
53    guest_ipv4: Option<Ipv4Addr>,
54    gateway_ipv4: Option<Ipv4Addr>,
55    guest_ipv6: Option<Ipv6Addr>,
56    gateway_ipv6: Option<Ipv6Addr>,
57
58    // TLS state (if enabled). Created in new(), used for ca_cert_pem().
59    tls_state: Option<Arc<TlsState>>,
60}
61
62/// Handle for installing host-side termination behavior into the network stack.
63#[derive(Clone)]
64pub struct TerminationHandle {
65    shared: Arc<SharedState>,
66}
67
68/// Read-only view of aggregate network byte counters.
69#[derive(Clone)]
70pub struct MetricsHandle {
71    shared: Arc<SharedState>,
72}
73
74//--------------------------------------------------------------------------------------------------
75// Methods
76//--------------------------------------------------------------------------------------------------
77
78impl SmoltcpNetwork {
79    /// Create from user config + sandbox slot (for IP/MAC derivation).
80    ///
81    /// Each address family is enabled when either the user supplied an
82    /// explicit address or the host kernel has a route for that family;
83    /// otherwise the corresponding `guest_*`/`gateway_*` fields stay `None`
84    /// and the family is omitted from the smoltcp interface, env vars, and
85    /// downstream consumers.
86    ///
87    /// # Panics
88    ///
89    /// Panics if `slot` exceeds the address pool capacity (65535 for MAC/IPv6,
90    /// 524287 for IPv4).
91    pub fn new(config: NetworkConfig, slot: u64) -> Self {
92        Self::new_with_routes(config, slot, host_has_ipv4_route(), host_has_ipv6_route())
93    }
94
95    fn new_with_routes(
96        config: NetworkConfig,
97        slot: u64,
98        host_has_ipv4: bool,
99        host_has_ipv6: bool,
100    ) -> Self {
101        assert!(
102            slot <= MAX_SLOT,
103            "sandbox slot {slot} exceeds address pool capacity (max {MAX_SLOT})"
104        );
105
106        let guest_mac = config
107            .interface
108            .mac
109            .unwrap_or_else(|| derive_guest_mac(slot));
110        let gateway_mac = derive_gateway_mac(slot);
111        let mtu = config.interface.mtu.unwrap_or(1500);
112
113        let guest_ipv4 = config
114            .interface
115            .ipv4_address
116            .or_else(|| host_has_ipv4.then(|| derive_guest_ipv4(slot)));
117        let gateway_ipv4 = guest_ipv4.map(gateway_from_guest_ipv4);
118        let guest_ipv6 = config
119            .interface
120            .ipv6_address
121            .or_else(|| host_has_ipv6.then(|| derive_guest_ipv6(slot)));
122        let gateway_ipv6 = guest_ipv6.map(gateway_from_guest_ipv6);
123
124        let queue_capacity = config
125            .max_connections
126            .unwrap_or(DEFAULT_QUEUE_CAPACITY)
127            .max(DEFAULT_QUEUE_CAPACITY);
128        let shared = Arc::new(SharedState::new(queue_capacity));
129        let backend = SmoltcpBackend::new(shared.clone());
130
131        let tls_state = if config.tls.enabled {
132            Some(Arc::new(TlsState::new(
133                config.tls.clone(),
134                config.secrets.clone(),
135            )))
136        } else {
137            None
138        };
139
140        Self {
141            config,
142            shared,
143            backend: Some(backend),
144            poll_handle: None,
145            guest_mac,
146            gateway_mac,
147            mtu,
148            guest_ipv4,
149            gateway_ipv4,
150            guest_ipv6,
151            gateway_ipv6,
152            tls_state,
153        }
154    }
155
156    /// Get the gateway IPs for virtio-net configuration and domain-based policy rules.
157    fn gateway_ips(&self) -> GatewayIps {
158        GatewayIps {
159            ipv4: self.gateway_ipv4,
160            ipv6: self.gateway_ipv6,
161        }
162    }
163
164    /// Start the smoltcp poll thread.
165    ///
166    /// Must be called before VM boot. Requires a tokio runtime handle for
167    /// spawning proxy tasks, DNS resolution, and published port listeners.
168    pub fn start(&mut self, tokio_handle: tokio::runtime::Handle) {
169        let shared = self.shared.clone();
170        let poll_config = PollLoopConfig {
171            gateway_mac: self.gateway_mac,
172            guest_mac: self.guest_mac,
173            gateway: self.gateway_ips(),
174            guest_ipv4: self.guest_ipv4,
175            guest_ipv6: self.guest_ipv6,
176            mtu: self.mtu as usize,
177        };
178        let network_policy = self.config.policy.clone();
179        let dns_config = self.config.dns.clone();
180        let tls_state = self.tls_state.clone();
181        let published_ports = self.config.ports.clone();
182        let max_connections = self.config.max_connections;
183
184        self.poll_handle = Some(
185            std::thread::Builder::new()
186                .name("smoltcp-poll".into())
187                .spawn(move || {
188                    stack::smoltcp_poll_loop(
189                        shared,
190                        poll_config,
191                        network_policy,
192                        dns_config,
193                        tls_state,
194                        published_ports,
195                        max_connections,
196                        tokio_handle,
197                    );
198                })
199                .expect("failed to spawn smoltcp poll thread"),
200        );
201    }
202
203    /// Take the `NetBackend` for `VmBuilder::net()`. One-shot.
204    pub fn take_backend(&mut self) -> Box<dyn NetBackend + Send> {
205        Box::new(self.backend.take().expect("backend already taken"))
206    }
207
208    /// Guest MAC address for `VmBuilder::net().mac()`.
209    pub fn guest_mac(&self) -> [u8; 6] {
210        self.guest_mac
211    }
212
213    /// Generate `MSB_NET*` environment variables for the guest.
214    ///
215    /// The guest init (`agentd`) reads these to configure the network
216    /// interface via ioctls + netlink.
217    pub fn guest_env_vars(&self) -> Vec<(String, String)> {
218        let mut vars = vec![
219            (
220                ENV_NET.into(),
221                format!(
222                    "iface=eth0,mac={},mtu={}",
223                    format_mac(self.guest_mac),
224                    self.mtu,
225                ),
226            ),
227            (ENV_HOST_ALIAS.into(), crate::HOST_ALIAS.into()),
228        ];
229
230        if let (Some(guest), Some(gateway)) = (self.guest_ipv4, self.gateway_ipv4) {
231            vars.push((
232                ENV_NET_IPV4.into(),
233                format!("addr={guest}/30,gw={gateway},dns={gateway}"),
234            ));
235        }
236
237        if let (Some(guest), Some(gateway)) = (self.guest_ipv6, self.gateway_ipv6) {
238            vars.push((
239                ENV_NET_IPV6.into(),
240                format!("addr={guest}/64,gw={gateway},dns={gateway}"),
241            ));
242        }
243
244        // Auto-expose secret placeholders as environment variables.
245        for secret in &self.config.secrets.secrets {
246            vars.push((secret.env_var.clone(), secret.placeholder.clone()));
247        }
248
249        vars
250    }
251
252    /// CA certificate PEM bytes if TLS interception is enabled.
253    ///
254    /// Write to the runtime mount before VM boot so the guest can trust it.
255    pub fn ca_cert_pem(&self) -> Option<Vec<u8>> {
256        self.tls_state.as_ref().map(|s| s.ca_cert_pem())
257    }
258
259    /// Host-trusted CA bundle to ship into the guest, if
260    /// [`NetworkConfig::trust_host_cas`] is enabled.
261    ///
262    /// Returned PEM may concatenate CAs that the Mozilla root bundle in
263    /// the guest already trusts; duplicates are harmless and saved the
264    /// cost of computing a delta. Returns `None` when the host store is
265    /// empty or the feature is disabled.
266    pub fn host_cas_cert_pem(&self) -> Option<Vec<u8>> {
267        if !self.config.trust_host_cas {
268            return None;
269        }
270        crate::tls::host_cas::collect_host_cas()
271    }
272
273    /// Create a handle for wiring runtime termination into the network stack.
274    pub fn termination_handle(&self) -> TerminationHandle {
275        TerminationHandle {
276            shared: self.shared.clone(),
277        }
278    }
279
280    /// Create a handle for reading aggregate network byte counters.
281    pub fn metrics_handle(&self) -> MetricsHandle {
282        MetricsHandle {
283            shared: self.shared.clone(),
284        }
285    }
286}
287
288impl TerminationHandle {
289    /// Install the termination hook.
290    pub fn set_hook(&self, hook: Arc<dyn Fn() + Send + Sync>) {
291        self.shared.set_termination_hook(hook);
292    }
293}
294
295impl MetricsHandle {
296    /// Total guest -> runtime bytes observed at the virtio-net boundary.
297    pub fn tx_bytes(&self) -> u64 {
298        self.shared.tx_bytes()
299    }
300
301    /// Total runtime -> guest bytes observed at the virtio-net boundary.
302    pub fn rx_bytes(&self) -> u64 {
303        self.shared.rx_bytes()
304    }
305}
306
307//--------------------------------------------------------------------------------------------------
308// Functions
309//--------------------------------------------------------------------------------------------------
310
311/// Derive a guest MAC address from the sandbox slot.
312///
313/// Format: `02:ms:bx:SS:SS:02` where SS:SS encodes the slot.
314fn derive_guest_mac(slot: u64) -> [u8; 6] {
315    let s = slot.to_be_bytes();
316    [0x02, 0x6d, 0x73, s[6], s[7], 0x02]
317}
318
319/// Derive a gateway MAC address from the sandbox slot.
320///
321/// Format: `02:ms:bx:SS:SS:01`.
322fn derive_gateway_mac(slot: u64) -> [u8; 6] {
323    let s = slot.to_be_bytes();
324    [0x02, 0x6d, 0x73, s[6], s[7], 0x01]
325}
326
327/// Derive a guest IPv4 address from the sandbox slot.
328///
329/// Pool: `100.96.0.0/11`. Each slot gets a `/30` block (4 IPs).
330/// Guest is at offset +2 in the block.
331fn derive_guest_ipv4(slot: u64) -> Ipv4Addr {
332    let base: u32 = u32::from(Ipv4Addr::new(100, 96, 0, 0));
333    let offset = (slot as u32) * 4 + 2; // +2 = guest within /30
334    Ipv4Addr::from(base + offset)
335}
336
337/// Gateway IPv4 from guest IPv4: guest - 1 (offset +1 in the /30 block).
338fn gateway_from_guest_ipv4(guest: Ipv4Addr) -> Ipv4Addr {
339    Ipv4Addr::from(u32::from(guest) - 1)
340}
341
342/// Derive a guest IPv6 address from the sandbox slot.
343///
344/// Pool: `fd42:6d73:62::/48`. Each slot gets a `/64` prefix.
345/// Guest is `::2` in its prefix.
346fn derive_guest_ipv6(slot: u64) -> Ipv6Addr {
347    Ipv6Addr::new(0xfd42, 0x6d73, 0x0062, slot as u16, 0, 0, 0, 2)
348}
349
350/// Gateway IPv6 from guest IPv6: `::1` in the same prefix.
351fn gateway_from_guest_ipv6(guest: Ipv6Addr) -> Ipv6Addr {
352    let segs = guest.segments();
353    Ipv6Addr::new(segs[0], segs[1], segs[2], segs[3], 0, 0, 0, 1)
354}
355
356/// Format a MAC address as `xx:xx:xx:xx:xx:xx`.
357fn format_mac(mac: [u8; 6]) -> String {
358    format!(
359        "{:02x}:{:02x}:{:02x}:{:02x}:{:02x}:{:02x}",
360        mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]
361    )
362}
363
364/// Returns true if the host kernel can select an IPv4 route.
365///
366/// `UdpSocket::connect` performs a local routing-table lookup against the
367/// TEST-NET-1 (`192.0.2.1`) address; it does not send packets or wait on
368/// the network.
369fn host_has_ipv4_route() -> bool {
370    UdpSocket::bind((Ipv4Addr::UNSPECIFIED, 0))
371        .and_then(|socket| socket.connect((Ipv4Addr::new(192, 0, 2, 1), 443)))
372        .is_ok()
373}
374
375/// Returns true if the host kernel can select an IPv6 route. Probes a
376/// `2001:db8::/32` documentation address via `UdpSocket::connect` (no packet
377/// is sent).
378fn host_has_ipv6_route() -> bool {
379    UdpSocket::bind((Ipv6Addr::UNSPECIFIED, 0))
380        .and_then(|socket| socket.connect((Ipv6Addr::new(0x2001, 0x0db8, 0, 0, 0, 0, 0, 1), 443)))
381        .is_ok()
382}
383
384//--------------------------------------------------------------------------------------------------
385// Tests
386//--------------------------------------------------------------------------------------------------
387
388#[cfg(test)]
389mod tests {
390    use super::*;
391
392    #[test]
393    fn derive_addresses_slot_0() {
394        assert_eq!(derive_guest_mac(0), [0x02, 0x6d, 0x73, 0x00, 0x00, 0x02]);
395        assert_eq!(derive_gateway_mac(0), [0x02, 0x6d, 0x73, 0x00, 0x00, 0x01]);
396        assert_eq!(derive_guest_ipv4(0), Ipv4Addr::new(100, 96, 0, 2));
397        assert_eq!(
398            gateway_from_guest_ipv4(Ipv4Addr::new(100, 96, 0, 2)),
399            Ipv4Addr::new(100, 96, 0, 1)
400        );
401    }
402
403    #[test]
404    fn derive_addresses_slot_1() {
405        assert_eq!(derive_guest_ipv4(1), Ipv4Addr::new(100, 96, 0, 6));
406        assert_eq!(
407            gateway_from_guest_ipv4(Ipv4Addr::new(100, 96, 0, 6)),
408            Ipv4Addr::new(100, 96, 0, 5)
409        );
410    }
411
412    #[test]
413    fn derive_ipv6_slot_0() {
414        assert_eq!(
415            derive_guest_ipv6(0),
416            "fd42:6d73:62:0::2".parse::<Ipv6Addr>().unwrap()
417        );
418        assert_eq!(
419            gateway_from_guest_ipv6(derive_guest_ipv6(0)),
420            "fd42:6d73:62:0::1".parse::<Ipv6Addr>().unwrap()
421        );
422    }
423
424    #[test]
425    fn format_mac_address() {
426        assert_eq!(
427            format_mac([0x02, 0x6d, 0x73, 0x00, 0x00, 0x01]),
428            "02:6d:73:00:00:01"
429        );
430    }
431
432    #[test]
433    fn guest_env_vars_includes_ipv4_when_host_has_v4_route() {
434        let net = SmoltcpNetwork::new_with_routes(NetworkConfig::default(), 0, true, false);
435        let vars = net.guest_env_vars();
436
437        assert_eq!(vars.len(), 3);
438        assert_eq!(vars[0].0, ENV_NET);
439        assert!(vars[0].1.contains("iface=eth0"));
440        assert_eq!(vars[1].0, ENV_HOST_ALIAS);
441        assert_eq!(vars[1].1, crate::HOST_ALIAS);
442        assert_eq!(vars[2].0, ENV_NET_IPV4);
443        assert!(vars[2].1.contains("/30"));
444    }
445
446    #[test]
447    fn guest_env_vars_includes_ipv6_when_host_has_v6_route() {
448        let net = SmoltcpNetwork::new_with_routes(NetworkConfig::default(), 0, true, true);
449        let vars = net.guest_env_vars();
450
451        assert_eq!(vars.len(), 4);
452        assert_eq!(vars[0].0, ENV_NET);
453        assert_eq!(vars[1].0, ENV_HOST_ALIAS);
454        assert_eq!(vars[2].0, ENV_NET_IPV4);
455        assert_eq!(vars[3].0, ENV_NET_IPV6);
456        assert!(vars[3].1.contains("/64"));
457    }
458
459    #[test]
460    fn guest_env_vars_omit_ipv6_without_host_route() {
461        let net = SmoltcpNetwork::new_with_routes(NetworkConfig::default(), 0, true, false);
462        let vars = net.guest_env_vars();
463
464        assert!(!vars.iter().any(|(k, _)| k == ENV_NET_IPV6));
465    }
466
467    #[test]
468    fn guest_env_vars_omit_ipv4_without_host_route() {
469        let net = SmoltcpNetwork::new_with_routes(NetworkConfig::default(), 0, false, true);
470        let vars = net.guest_env_vars();
471
472        assert_eq!(vars.len(), 3);
473        assert_eq!(vars[0].0, ENV_NET);
474        assert_eq!(vars[1].0, ENV_HOST_ALIAS);
475        assert_eq!(vars[2].0, ENV_NET_IPV6);
476    }
477
478    #[test]
479    fn explicit_ipv6_address_overrides_missing_host_v6_route() {
480        let mut config = NetworkConfig::default();
481        config.interface.ipv6_address = Some("fd42:6d73:62:99::2".parse().unwrap());
482        let net = SmoltcpNetwork::new_with_routes(config, 0, true, false);
483        let vars = net.guest_env_vars();
484
485        let v6 = vars
486            .iter()
487            .find(|(k, _)| k == ENV_NET_IPV6)
488            .expect("explicit ipv6 should publish env var even without host route");
489        assert!(v6.1.contains("fd42:6d73:62:99::2/64"));
490    }
491
492    #[test]
493    fn neither_family_active_emits_only_base_env_vars() {
494        let net = SmoltcpNetwork::new_with_routes(NetworkConfig::default(), 0, false, false);
495        let vars = net.guest_env_vars();
496
497        assert_eq!(vars.len(), 2);
498        assert_eq!(vars[0].0, ENV_NET);
499        assert_eq!(vars[1].0, ENV_HOST_ALIAS);
500    }
501}