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        let secrets = Arc::new(self.config.secrets.clone());
199
200        self.poll_handle = Some(
201            std::thread::Builder::new()
202                .name("smoltcp-poll".into())
203                .spawn(move || {
204                    stack::smoltcp_poll_loop(
205                        shared,
206                        poll_config,
207                        network_policy,
208                        dns_config,
209                        tls_state,
210                        published_ports,
211                        max_connections,
212                        tokio_handle,
213                        secrets,
214                    );
215                })
216                .expect("failed to spawn smoltcp poll thread"),
217        );
218    }
219
220    /// Take the `NetBackend` for `VmBuilder::net()`. One-shot.
221    pub fn take_backend(&mut self) -> Box<dyn NetBackend + Send> {
222        Box::new(self.backend.take().expect("backend already taken"))
223    }
224
225    /// Guest MAC address for `VmBuilder::net().mac()`.
226    pub fn guest_mac(&self) -> [u8; 6] {
227        self.guest_mac
228    }
229
230    /// Generate `MSB_NET*` environment variables for the guest.
231    ///
232    /// The guest init (`agentd`) reads these to configure the network
233    /// interface via ioctls + netlink.
234    pub fn guest_env_vars(&self) -> Vec<(String, String)> {
235        let mut vars = vec![
236            (
237                ENV_NET.into(),
238                format!(
239                    "iface=eth0,mac={},mtu={}",
240                    format_mac(self.guest_mac),
241                    self.mtu,
242                ),
243            ),
244            (ENV_HOST_ALIAS.into(), crate::HOST_ALIAS.into()),
245        ];
246
247        if let (Some(guest), Some(gateway)) = (self.guest_ipv4, self.gateway_ipv4) {
248            vars.push((
249                ENV_NET_IPV4.into(),
250                format!("addr={guest}/30,gw={gateway},dns={gateway}"),
251            ));
252        }
253
254        if let (Some(guest), Some(gateway)) = (self.guest_ipv6, self.gateway_ipv6) {
255            vars.push((
256                ENV_NET_IPV6.into(),
257                format!("addr={guest}/64,gw={gateway},dns={gateway}"),
258            ));
259        }
260
261        // Auto-expose secret placeholders as environment variables.
262        for secret in &self.config.secrets.secrets {
263            vars.push((secret.env_var.clone(), secret.placeholder.clone()));
264        }
265
266        vars
267    }
268
269    /// CA certificate PEM bytes if TLS interception is enabled.
270    ///
271    /// Write to the runtime mount before VM boot so the guest can trust it.
272    pub fn ca_cert_pem(&self) -> Option<Vec<u8>> {
273        self.tls_state.as_ref().map(|s| s.ca_cert_pem())
274    }
275
276    /// Host-trusted CA bundle to ship into the guest, if
277    /// [`NetworkConfig::trust_host_cas`] is enabled.
278    ///
279    /// Returned PEM may concatenate CAs that the Mozilla root bundle in
280    /// the guest already trusts; duplicates are harmless and saved the
281    /// cost of computing a delta. Returns `None` when the host store is
282    /// empty or the feature is disabled.
283    pub fn host_cas_cert_pem(&self) -> Option<Vec<u8>> {
284        if !self.config.trust_host_cas {
285            return None;
286        }
287        crate::tls::host_cas::collect_host_cas()
288    }
289
290    /// Create a handle for wiring runtime termination into the network stack.
291    pub fn termination_handle(&self) -> TerminationHandle {
292        TerminationHandle {
293            shared: self.shared.clone(),
294        }
295    }
296
297    /// Create a handle for reading aggregate network byte counters.
298    pub fn metrics_handle(&self) -> MetricsHandle {
299        MetricsHandle {
300            shared: self.shared.clone(),
301        }
302    }
303}
304
305impl TerminationHandle {
306    /// Install the termination hook.
307    pub fn set_hook(&self, hook: Arc<dyn Fn() + Send + Sync>) {
308        self.shared.set_termination_hook(hook);
309    }
310}
311
312impl MetricsHandle {
313    /// Total guest -> runtime bytes observed at the virtio-net boundary.
314    pub fn tx_bytes(&self) -> u64 {
315        self.shared.tx_bytes()
316    }
317
318    /// Total runtime -> guest bytes observed at the virtio-net boundary.
319    pub fn rx_bytes(&self) -> u64 {
320        self.shared.rx_bytes()
321    }
322}
323
324//--------------------------------------------------------------------------------------------------
325// Functions
326//--------------------------------------------------------------------------------------------------
327
328/// Derive a guest MAC address from the sandbox slot.
329///
330/// Format: `02:ms:bx:SS:SS:02` where SS:SS encodes the slot.
331fn derive_guest_mac(slot: u64) -> [u8; 6] {
332    let s = slot.to_be_bytes();
333    [0x02, 0x6d, 0x73, s[6], s[7], 0x02]
334}
335
336/// Derive a gateway MAC address from the sandbox slot.
337///
338/// Format: `02:ms:bx:SS:SS:01`.
339fn derive_gateway_mac(slot: u64) -> [u8; 6] {
340    let s = slot.to_be_bytes();
341    [0x02, 0x6d, 0x73, s[6], s[7], 0x01]
342}
343
344/// Derive a guest IPv4 address from the sandbox slot.
345///
346/// Pool: `172.16.0.0/12` by default. Each slot gets a `/30` block (4 IPs).
347/// Guest is at offset +2 in the block.
348fn derive_guest_ipv4(pool: Ipv4Network, slot: u64) -> Ipv4Addr {
349    assert!(
350        pool.prefix() <= 30,
351        "IPv4 pool {pool} must be large enough to contain at least one /30 block"
352    );
353
354    let capacity = 1u64 << (30 - pool.prefix());
355    assert!(
356        slot < capacity,
357        "sandbox slot {slot} exceeds IPv4 pool {pool} capacity ({capacity} /30 blocks)"
358    );
359
360    let base = u32::from(pool.network());
361    let offset = (slot as u32) * 4 + 2; // +2 = guest within /30
362    Ipv4Addr::from(base + offset)
363}
364
365/// Gateway IPv4 from guest IPv4: guest - 1 (offset +1 in the /30 block).
366fn gateway_from_guest_ipv4(guest: Ipv4Addr) -> Ipv4Addr {
367    Ipv4Addr::from(u32::from(guest) - 1)
368}
369
370fn default_guest_ipv4_pool() -> Ipv4Network {
371    Ipv4Network::new(Ipv4Addr::new(172, 16, 0, 0), 12)
372        .expect("default IPv4 pool must be a valid network")
373}
374
375/// Derive a guest IPv6 address from the sandbox slot.
376///
377/// Pool: `fd42:6d73:62::/48`. Each slot gets a `/64` prefix.
378/// Guest is `::2` in its prefix.
379fn derive_guest_ipv6(pool: Ipv6Network, slot: u64) -> Ipv6Addr {
380    assert!(
381        pool.prefix() <= 64,
382        "IPv6 pool {pool} must be large enough to contain at least one /64 prefix"
383    );
384
385    let capacity = 1u128 << (64 - pool.prefix());
386    assert!(
387        (slot as u128) < capacity,
388        "sandbox slot {slot} exceeds IPv6 pool {pool} capacity ({capacity} /64 prefixes)"
389    );
390
391    let base = u128::from(pool.network());
392    let offset = (slot as u128) << 64;
393    Ipv6Addr::from(base + offset + 2)
394}
395
396/// Gateway IPv6 from guest IPv6: `::1` in the same prefix.
397fn gateway_from_guest_ipv6(guest: Ipv6Addr) -> Ipv6Addr {
398    let segs = guest.segments();
399    Ipv6Addr::new(segs[0], segs[1], segs[2], segs[3], 0, 0, 0, 1)
400}
401
402fn default_guest_ipv6_pool() -> Ipv6Network {
403    Ipv6Network::new(Ipv6Addr::new(0xfd42, 0x6d73, 0x0062, 0, 0, 0, 0, 0), 48)
404        .expect("default IPv6 pool must be a valid network")
405}
406
407/// Format a MAC address as `xx:xx:xx:xx:xx:xx`.
408fn format_mac(mac: [u8; 6]) -> String {
409    format!(
410        "{:02x}:{:02x}:{:02x}:{:02x}:{:02x}:{:02x}",
411        mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]
412    )
413}
414
415/// Returns true if the host kernel can select an IPv4 route.
416///
417/// `UdpSocket::connect` performs a local routing-table lookup against the
418/// TEST-NET-1 (`192.0.2.1`) address; it does not send packets or wait on
419/// the network.
420fn host_has_ipv4_route() -> bool {
421    UdpSocket::bind((Ipv4Addr::UNSPECIFIED, 0))
422        .and_then(|socket| socket.connect((Ipv4Addr::new(192, 0, 2, 1), 443)))
423        .is_ok()
424}
425
426/// Returns true if the host kernel can select an IPv6 route. Probes a
427/// `2001:db8::/32` documentation address via `UdpSocket::connect` (no packet
428/// is sent).
429fn host_has_ipv6_route() -> bool {
430    UdpSocket::bind((Ipv6Addr::UNSPECIFIED, 0))
431        .and_then(|socket| socket.connect((Ipv6Addr::new(0x2001, 0x0db8, 0, 0, 0, 0, 0, 1), 443)))
432        .is_ok()
433}
434
435//--------------------------------------------------------------------------------------------------
436// Tests
437//--------------------------------------------------------------------------------------------------
438
439#[cfg(test)]
440mod tests {
441    use super::*;
442
443    #[test]
444    fn derive_addresses_slot_0() {
445        assert_eq!(derive_guest_mac(0), [0x02, 0x6d, 0x73, 0x00, 0x00, 0x02]);
446        assert_eq!(derive_gateway_mac(0), [0x02, 0x6d, 0x73, 0x00, 0x00, 0x01]);
447        assert_eq!(
448            derive_guest_ipv4(default_guest_ipv4_pool(), 0),
449            Ipv4Addr::new(172, 16, 0, 2)
450        );
451        assert_eq!(
452            gateway_from_guest_ipv4(Ipv4Addr::new(172, 16, 0, 2)),
453            Ipv4Addr::new(172, 16, 0, 1)
454        );
455    }
456
457    #[test]
458    fn derive_addresses_slot_1() {
459        assert_eq!(
460            derive_guest_ipv4(default_guest_ipv4_pool(), 1),
461            Ipv4Addr::new(172, 16, 0, 6)
462        );
463        assert_eq!(
464            gateway_from_guest_ipv4(Ipv4Addr::new(172, 16, 0, 6)),
465            Ipv4Addr::new(172, 16, 0, 5)
466        );
467    }
468
469    #[test]
470    fn derive_addresses_custom_ipv4_pool() {
471        let pool = "172.31.240.0/24".parse::<Ipv4Network>().unwrap();
472        assert_eq!(derive_guest_ipv4(pool, 0), Ipv4Addr::new(172, 31, 240, 2));
473        assert_eq!(
474            derive_guest_ipv4(pool, 63),
475            Ipv4Addr::new(172, 31, 240, 254)
476        );
477    }
478
479    #[test]
480    fn derive_ipv6_slot_0() {
481        assert_eq!(
482            derive_guest_ipv6(default_guest_ipv6_pool(), 0),
483            "fd42:6d73:62:0::2".parse::<Ipv6Addr>().unwrap()
484        );
485        assert_eq!(
486            gateway_from_guest_ipv6(derive_guest_ipv6(default_guest_ipv6_pool(), 0)),
487            "fd42:6d73:62:0::1".parse::<Ipv6Addr>().unwrap()
488        );
489    }
490
491    #[test]
492    fn derive_addresses_custom_ipv6_pool() {
493        let pool = "fd7a:115c:a1e0:100::/56".parse::<Ipv6Network>().unwrap();
494        assert_eq!(
495            derive_guest_ipv6(pool, 0),
496            "fd7a:115c:a1e0:100::2".parse::<Ipv6Addr>().unwrap()
497        );
498        assert_eq!(
499            derive_guest_ipv6(pool, 3),
500            "fd7a:115c:a1e0:103::2".parse::<Ipv6Addr>().unwrap()
501        );
502    }
503
504    #[test]
505    fn format_mac_address() {
506        assert_eq!(
507            format_mac([0x02, 0x6d, 0x73, 0x00, 0x00, 0x01]),
508            "02:6d:73:00:00:01"
509        );
510    }
511
512    #[test]
513    fn guest_env_vars_includes_ipv4_when_host_has_v4_route() {
514        let net = SmoltcpNetwork::new_with_routes(NetworkConfig::default(), 0, true, false);
515        let vars = net.guest_env_vars();
516
517        assert_eq!(vars.len(), 3);
518        assert_eq!(vars[0].0, ENV_NET);
519        assert!(vars[0].1.contains("iface=eth0"));
520        assert_eq!(vars[1].0, ENV_HOST_ALIAS);
521        assert_eq!(vars[1].1, crate::HOST_ALIAS);
522        assert_eq!(vars[2].0, ENV_NET_IPV4);
523        assert!(vars[2].1.contains("/30"));
524    }
525
526    #[test]
527    fn guest_env_vars_includes_ipv6_when_host_has_v6_route() {
528        let net = SmoltcpNetwork::new_with_routes(NetworkConfig::default(), 0, true, true);
529        let vars = net.guest_env_vars();
530
531        assert_eq!(vars.len(), 4);
532        assert_eq!(vars[0].0, ENV_NET);
533        assert_eq!(vars[1].0, ENV_HOST_ALIAS);
534        assert_eq!(vars[2].0, ENV_NET_IPV4);
535        assert_eq!(vars[3].0, ENV_NET_IPV6);
536        assert!(vars[3].1.contains("/64"));
537    }
538
539    #[test]
540    fn guest_env_vars_omit_ipv6_without_host_route() {
541        let net = SmoltcpNetwork::new_with_routes(NetworkConfig::default(), 0, true, false);
542        let vars = net.guest_env_vars();
543
544        assert!(!vars.iter().any(|(k, _)| k == ENV_NET_IPV6));
545    }
546
547    #[test]
548    fn guest_env_vars_omit_ipv4_without_host_route() {
549        let net = SmoltcpNetwork::new_with_routes(NetworkConfig::default(), 0, false, true);
550        let vars = net.guest_env_vars();
551
552        assert_eq!(vars.len(), 3);
553        assert_eq!(vars[0].0, ENV_NET);
554        assert_eq!(vars[1].0, ENV_HOST_ALIAS);
555        assert_eq!(vars[2].0, ENV_NET_IPV6);
556    }
557
558    #[test]
559    fn explicit_ipv6_address_overrides_missing_host_v6_route() {
560        let mut config = NetworkConfig::default();
561        config.interface.ipv6_address = Some("fd42:6d73:62:99::2".parse().unwrap());
562        let net = SmoltcpNetwork::new_with_routes(config, 0, true, false);
563        let vars = net.guest_env_vars();
564
565        let v6 = vars
566            .iter()
567            .find(|(k, _)| k == ENV_NET_IPV6)
568            .expect("explicit ipv6 should publish env var even without host route");
569        assert!(v6.1.contains("fd42:6d73:62:99::2/64"));
570    }
571
572    #[test]
573    fn neither_family_active_emits_only_base_env_vars() {
574        let net = SmoltcpNetwork::new_with_routes(NetworkConfig::default(), 0, false, false);
575        let vars = net.guest_env_vars();
576
577        assert_eq!(vars.len(), 2);
578        assert_eq!(vars[0].0, ENV_NET);
579        assert_eq!(vars[1].0, ENV_HOST_ALIAS);
580    }
581}