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