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