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::bootstrap::{
14    BootstrapEnvVar, BootstrapIpv4, BootstrapIpv6, BootstrapNetwork,
15};
16use microsandbox_protocol::{ENV_HOST_ALIAS, ENV_NET, ENV_NET_IPV4, ENV_NET_IPV6};
17use microsandbox_types::{
18    DeploymentProfile, NetworkRateLimitDirection, RateLimitConfigError, RateLimiterConfig,
19};
20use msb_krun::backends::net::NetBackend;
21
22use crate::config::{MAX_NETWORK_CONNECTIONS, NetworkConfig};
23use crate::netstack::{
24    backend::SmoltcpBackend,
25    poll::{self, GatewayIps, PollLoopConfig},
26    shared::{DEFAULT_QUEUE_CAPACITY, SharedState},
27};
28use crate::policy::{NetworkPolicy, NetworkProfile};
29use crate::secrets::handle::SecretsHandle;
30use crate::tls::state::{TlsState, TlsStateError};
31
32//--------------------------------------------------------------------------------------------------
33// Constants
34//--------------------------------------------------------------------------------------------------
35
36/// Hard ceiling for concurrent connections on shared, multi-tenant hosts.
37///
38/// This matches the network engine's existing default, preventing a tenant
39/// override from increasing host-side socket state above the normal budget.
40const MULTI_TENANT_MAX_CONNECTIONS: usize = 256;
41
42//--------------------------------------------------------------------------------------------------
43// Types
44//--------------------------------------------------------------------------------------------------
45
46/// The networking engine. Created from [`NetworkConfig`] by the runtime.
47///
48/// Owns the smoltcp poll thread and provides:
49/// - [`take_backend()`](Self::take_backend) — the `NetBackend` for `VmBuilder::net()`
50/// - [`guest_bootstrap_network()`](Self::guest_bootstrap_network) — typed guest network setup
51/// - [`ca_cert_pem()`](Self::ca_cert_pem) — CA certificate for TLS interception
52pub struct SmoltcpNetwork {
53    config: NetworkConfig,
54    deployment_profile: DeploymentProfile,
55    shared: Arc<SharedState>,
56    backend: Option<SmoltcpBackend>,
57    poll_handle: Option<JoinHandle<()>>,
58
59    // Resolved from config + slot.
60    guest_mac: [u8; 6],
61    gateway_mac: [u8; 6],
62    mtu: u16,
63    // IPv4 / IPv6 are `Some` when active for this sandbox: the user supplied
64    // an explicit address, or the host has a route for that family.
65    guest_ipv4: Option<Ipv4Addr>,
66    gateway_ipv4: Option<Ipv4Addr>,
67    guest_ipv6: Option<Ipv6Addr>,
68    gateway_ipv6: Option<Ipv6Addr>,
69
70    // TLS state (if enabled). Created in new(), used for ca_cert_pem().
71    tls_state: Option<Arc<TlsState>>,
72
73    // Live-swappable secrets view shared with the poll loop and TLS state.
74    secrets: SecretsHandle,
75}
76
77/// Errors that prevent the smoltcp network from being created safely.
78#[derive(Debug, thiserror::Error)]
79pub enum NetworkInitError {
80    /// The configured connection cap is above the hard safety limit.
81    #[error("max_connections {configured} exceeds hard limit {limit}")]
82    MaxConnectionsExceeded {
83        /// Requested connection limit.
84        configured: usize,
85        /// Hard cap enforced by the network stack.
86        limit: usize,
87    },
88
89    /// The configured IPv4 pool cannot provide a `/30` for this slot.
90    #[error("IPv4 pool {pool} cannot assign network slot {slot}")]
91    Ipv4PoolCapacity {
92        /// Configured IPv4 pool.
93        pool: Ipv4Network,
94        /// Requested sandbox slot.
95        slot: u16,
96    },
97
98    /// The configured IPv6 pool cannot provide a `/64` for this slot.
99    #[error("IPv6 pool {pool} cannot assign network slot {slot}")]
100    Ipv6PoolCapacity {
101        /// Configured IPv6 pool.
102        pool: Ipv6Network,
103        /// Requested sandbox slot.
104        slot: u16,
105    },
106
107    /// TLS interception state failed to initialize.
108    #[error("TLS initialization failed: {0}")]
109    Tls(#[from] TlsStateError),
110
111    /// A stored rate limiter configuration failed validation.
112    #[error("invalid {direction} rate limiter: {source}")]
113    InvalidRateLimit {
114        /// Which limiter is invalid: `egress` or `ingress`.
115        direction: NetworkRateLimitDirection,
116        /// Underlying validation error.
117        #[source]
118        source: RateLimitConfigError,
119    },
120
121    /// A stored network rate limiter has neither direction configured.
122    #[error("invalid network rate limiter: at least one of egress or ingress is required")]
123    EmptyNetworkRateLimiter,
124}
125
126/// Handle for installing host-side termination behavior into the network stack.
127#[derive(Clone)]
128pub struct TerminationHandle {
129    shared: Arc<SharedState>,
130}
131
132/// Read-only view of aggregate network byte counters.
133#[derive(Clone)]
134pub struct MetricsHandle {
135    shared: Arc<SharedState>,
136}
137
138//--------------------------------------------------------------------------------------------------
139// Methods
140//--------------------------------------------------------------------------------------------------
141
142impl SmoltcpNetwork {
143    /// Create from user config + sandbox slot (for IP/MAC derivation).
144    ///
145    /// Each address family is enabled when either the user supplied an
146    /// explicit address or the host kernel has a route for that family;
147    /// otherwise the corresponding `guest_*`/`gateway_*` fields stay `None`
148    /// and the family is omitted from the smoltcp interface, env vars, and
149    /// downstream consumers.
150    ///
151    /// # Errors
152    ///
153    /// Returns an error when network configuration would allocate unsafe
154    /// resources or TLS interception cannot initialize.
155    ///
156    pub fn new(config: NetworkConfig, slot: u16) -> Result<Self, NetworkInitError> {
157        Self::new_with_profile(config, slot, DeploymentProfile::SingleTenant)
158    }
159
160    /// Create the network backend with an explicit host-runtime deployment profile.
161    ///
162    /// `MultiTenant` applies platform-owned configuration floors before any
163    /// sockets, resolvers, or TLS state are created. The requested tenant policy
164    /// remains separate and is intersected with the platform's public-network
165    /// policy by the poll loop.
166    ///
167    /// # Errors
168    ///
169    /// Returns an error when the effective network configuration would allocate
170    /// unsafe resources or TLS interception cannot initialize.
171    pub fn new_with_profile(
172        mut config: NetworkConfig,
173        slot: u16,
174        deployment_profile: DeploymentProfile,
175    ) -> Result<Self, NetworkInitError> {
176        enforce_deployment_profile(&mut config, deployment_profile);
177        Self::new_with_profile_and_routes(
178            config,
179            slot,
180            deployment_profile,
181            host_has_ipv4_route(),
182            host_has_ipv6_route(),
183        )
184    }
185
186    #[cfg(test)]
187    fn new_with_routes(
188        config: NetworkConfig,
189        slot: u16,
190        host_has_ipv4: bool,
191        host_has_ipv6: bool,
192    ) -> Result<Self, NetworkInitError> {
193        Self::new_with_profile_and_routes(
194            config,
195            slot,
196            DeploymentProfile::SingleTenant,
197            host_has_ipv4,
198            host_has_ipv6,
199        )
200    }
201
202    fn new_with_profile_and_routes(
203        config: NetworkConfig,
204        slot: u16,
205        deployment_profile: DeploymentProfile,
206        host_has_ipv4: bool,
207        host_has_ipv6: bool,
208    ) -> Result<Self, NetworkInitError> {
209        if let Some(configured) = config.max_connections
210            && configured > MAX_NETWORK_CONNECTIONS
211        {
212            return Err(NetworkInitError::MaxConnectionsExceeded {
213                configured,
214                limit: MAX_NETWORK_CONNECTIONS,
215            });
216        }
217
218        let guest_mac = config
219            .interface
220            .mac
221            .unwrap_or_else(|| derive_guest_mac(slot));
222        let gateway_mac = derive_gateway_mac(slot);
223        let mtu = config.interface.mtu.unwrap_or(1500);
224
225        let guest_ipv4 = match config.interface.ipv4_address {
226            Some(address) => Some(address),
227            None if host_has_ipv4 => Some(derive_guest_ipv4(
228                config
229                    .interface
230                    .ipv4_pool
231                    .unwrap_or_else(default_guest_ipv4_pool),
232                slot,
233            )?),
234            None => None,
235        };
236        let gateway_ipv4 = guest_ipv4.map(gateway_from_guest_ipv4);
237        let guest_ipv6 = match config.interface.ipv6_address {
238            Some(address) => Some(address),
239            None if host_has_ipv6 => Some(derive_guest_ipv6(
240                config
241                    .interface
242                    .ipv6_pool
243                    .unwrap_or_else(default_guest_ipv6_pool),
244                slot,
245            )?),
246            None => None,
247        };
248        let gateway_ipv6 = guest_ipv6.map(gateway_from_guest_ipv6);
249
250        let queue_capacity = config
251            .max_connections
252            .unwrap_or(DEFAULT_QUEUE_CAPACITY)
253            .max(DEFAULT_QUEUE_CAPACITY);
254        let shared = Arc::new(SharedState::new(queue_capacity));
255        // Every write path validates rate limiters (`NetworkBuilder::build`),
256        // but a stored config bypasses the builder: fail startup cleanly
257        // instead of panicking on a corrupted spec.
258        if config.rate_limiter.as_ref().is_some_and(|rate_limiter| {
259            rate_limiter.egress.is_none() && rate_limiter.ingress.is_none()
260        }) {
261            return Err(NetworkInitError::EmptyNetworkRateLimiter);
262        }
263        config
264            .rate_limiter
265            .as_ref()
266            .and_then(|rate_limiter| rate_limiter.ingress.as_ref())
267            .map(RateLimiterConfig::validate)
268            .transpose()
269            .map_err(|source| NetworkInitError::InvalidRateLimit {
270                direction: NetworkRateLimitDirection::Ingress,
271                source,
272            })?;
273        config
274            .rate_limiter
275            .as_ref()
276            .and_then(|rate_limiter| rate_limiter.egress.as_ref())
277            .map(RateLimiterConfig::validate)
278            .transpose()
279            .map_err(|source| NetworkInitError::InvalidRateLimit {
280                direction: NetworkRateLimitDirection::Egress,
281                source,
282            })?;
283        let backend = SmoltcpBackend::new(shared.clone());
284
285        let secrets = SecretsHandle::new(config.secrets.clone());
286        let tls_state = if config.tls.enabled {
287            Some(Arc::new(TlsState::new(
288                config.tls.clone(),
289                secrets.clone(),
290            )?))
291        } else {
292            None
293        };
294
295        Ok(Self {
296            config,
297            deployment_profile,
298            shared,
299            backend: Some(backend),
300            poll_handle: None,
301            guest_mac,
302            gateway_mac,
303            mtu,
304            guest_ipv4,
305            gateway_ipv4,
306            guest_ipv6,
307            gateway_ipv6,
308            tls_state,
309            secrets,
310        })
311    }
312
313    /// Get the gateway IPs for virtio-net configuration and domain-based policy rules.
314    fn gateway_ips(&self) -> GatewayIps {
315        GatewayIps {
316            ipv4: self.gateway_ipv4,
317            ipv6: self.gateway_ipv6,
318        }
319    }
320
321    /// Start the smoltcp poll thread.
322    ///
323    /// Must be called before VM boot. Requires a tokio runtime handle for
324    /// spawning proxy tasks, DNS resolution, and published port listeners.
325    pub fn start(&mut self, tokio_handle: tokio::runtime::Handle) {
326        let shared = self.shared.clone();
327        let poll_config = PollLoopConfig {
328            gateway_mac: self.gateway_mac,
329            guest_mac: self.guest_mac,
330            gateway: self.gateway_ips(),
331            guest_ipv4: self.guest_ipv4,
332            guest_ipv6: self.guest_ipv6,
333            mtu: self.mtu as usize,
334        };
335        let network_policy = self.config.policy.clone();
336        let platform_policy = match self.deployment_profile {
337            DeploymentProfile::SingleTenant => None,
338            DeploymentProfile::MultiTenant => {
339                Some(NetworkPolicy::from_profiles([NetworkProfile::Public]))
340            }
341        };
342        let dns_config = self.config.dns.clone();
343        let tls_state = self.tls_state.clone();
344        let published_ports = self.config.ports.clone();
345        let max_connections = self.config.max_connections;
346        let secrets = self.secrets.clone();
347
348        self.poll_handle = Some(
349            std::thread::Builder::new()
350                .name("smoltcp-poll".into())
351                .spawn(move || {
352                    poll::smoltcp_poll_loop(
353                        shared,
354                        poll_config,
355                        network_policy,
356                        platform_policy,
357                        dns_config,
358                        tls_state,
359                        published_ports,
360                        max_connections,
361                        tokio_handle,
362                        secrets,
363                    );
364                })
365                .expect("failed to spawn smoltcp poll thread"),
366        );
367    }
368
369    /// Take the `NetBackend` for `VmBuilder::net()`. One-shot.
370    pub fn take_backend(&mut self) -> Box<dyn NetBackend + Send> {
371        Box::new(self.backend.take().expect("backend already taken"))
372    }
373
374    /// Guest MAC address for `VmBuilder::net().mac()`.
375    pub fn guest_mac(&self) -> [u8; 6] {
376        self.guest_mac
377    }
378
379    /// Generate `MSB_NET*` environment variables for the guest.
380    ///
381    /// The guest init (`agentd`) reads these to configure the network
382    /// interface via ioctls + netlink.
383    pub fn guest_env_vars(&self) -> Vec<(String, String)> {
384        let mut vars = vec![
385            (
386                ENV_NET.into(),
387                format!(
388                    "iface=eth0,mac={},mtu={}",
389                    format_mac(self.guest_mac),
390                    self.mtu,
391                ),
392            ),
393            (ENV_HOST_ALIAS.into(), crate::HOST_ALIAS.into()),
394        ];
395
396        if let (Some(guest), Some(gateway)) = (self.guest_ipv4, self.gateway_ipv4) {
397            vars.push((
398                ENV_NET_IPV4.into(),
399                format!("addr={guest}/30,gw={gateway},dns={gateway}"),
400            ));
401        }
402
403        if let (Some(guest), Some(gateway)) = (self.guest_ipv6, self.gateway_ipv6) {
404            vars.push((
405                ENV_NET_IPV6.into(),
406                format!("addr={guest}/64,gw={gateway},dns={gateway}"),
407            ));
408        }
409
410        // Auto-expose secret placeholders as environment variables.
411        for secret in &self.config.secrets.secrets {
412            vars.push((secret.env_var.clone(), secret.placeholder.clone()));
413        }
414
415        vars
416    }
417
418    /// Build the typed network payload consumed by agentd during bootstrap.
419    pub fn guest_bootstrap_network(&self) -> BootstrapNetwork {
420        BootstrapNetwork {
421            interface: "eth0".to_string(),
422            mac: self.guest_mac,
423            mtu: self.mtu,
424            ipv4: self
425                .guest_ipv4
426                .zip(self.gateway_ipv4)
427                .map(|(address, gateway)| BootstrapIpv4 {
428                    address,
429                    prefix_len: 30,
430                    gateway,
431                    dns: Some(gateway),
432                }),
433            ipv6: self
434                .guest_ipv6
435                .zip(self.gateway_ipv6)
436                .map(|(address, gateway)| BootstrapIpv6 {
437                    address,
438                    prefix_len: 64,
439                    gateway,
440                    dns: Some(gateway),
441                }),
442        }
443    }
444
445    /// Return the stable hostname used by guests to address the host gateway.
446    pub fn guest_host_alias(&self) -> &'static str {
447        crate::HOST_ALIAS
448    }
449
450    /// Return guest-visible secret placeholders for the baseline environment.
451    ///
452    /// Real secret values stay in the host-side network handler and never
453    /// enter this payload.
454    pub fn guest_secret_env(&self) -> Vec<BootstrapEnvVar> {
455        self.config
456            .secrets
457            .secrets
458            .iter()
459            .map(|secret| BootstrapEnvVar {
460                key: secret.env_var.clone(),
461                value: secret.placeholder.clone(),
462            })
463            .collect()
464    }
465
466    /// CA certificate PEM bytes if TLS interception is enabled.
467    ///
468    /// Write to the runtime mount before VM boot so the guest can trust it.
469    pub fn ca_cert_pem(&self) -> Option<Vec<u8>> {
470        self.tls_state.as_ref().map(|s| s.ca_cert_pem())
471    }
472
473    /// Host-trusted CA bundle to ship into the guest, if
474    /// [`NetworkConfig::trust_host_cas`] is enabled.
475    ///
476    /// Returned PEM may concatenate CAs that the Mozilla root bundle in
477    /// the guest already trusts; duplicates are harmless and saved the
478    /// cost of computing a delta. Returns `None` when the host store is
479    /// empty or the feature is disabled.
480    pub fn host_cas_cert_pem(&self) -> Option<Vec<u8>> {
481        if !self.config.trust_host_cas {
482            return None;
483        }
484        crate::tls::host_cas::collect_host_cas()
485    }
486
487    /// Create a handle for wiring runtime termination into the network stack.
488    pub fn termination_handle(&self) -> TerminationHandle {
489        TerminationHandle {
490            shared: self.shared.clone(),
491        }
492    }
493
494    /// Create a handle for reading aggregate network byte counters.
495    pub fn metrics_handle(&self) -> MetricsHandle {
496        MetricsHandle {
497            shared: self.shared.clone(),
498        }
499    }
500
501    /// Live-swappable view of the secrets configuration. The runtime control
502    /// socket uses it to apply secret rotation, removal, and allowed-host
503    /// updates without restarting the sandbox.
504    pub fn secrets_handle(&self) -> SecretsHandle {
505        self.secrets.clone()
506    }
507}
508
509impl TerminationHandle {
510    /// Install the termination hook.
511    pub fn set_hook(&self, hook: Arc<dyn Fn() + Send + Sync>) {
512        self.shared.set_termination_hook(hook);
513    }
514}
515
516impl MetricsHandle {
517    /// Total guest -> runtime bytes observed at the virtio-net boundary.
518    pub fn tx_bytes(&self) -> u64 {
519        self.shared.tx_bytes()
520    }
521
522    /// Total runtime -> guest bytes observed at the virtio-net boundary.
523    pub fn rx_bytes(&self) -> u64 {
524        self.shared.rx_bytes()
525    }
526}
527
528//--------------------------------------------------------------------------------------------------
529// Functions
530//--------------------------------------------------------------------------------------------------
531
532/// Apply the platform-owned configuration floor before network resources are created.
533///
534/// Policy rules are deliberately not flattened here. The poll loop evaluates
535/// the platform public-network policy and the tenant policy independently so a
536/// broad tenant allow can never outrank the platform floor, while a tenant deny
537/// still remains effective.
538fn enforce_deployment_profile(config: &mut NetworkConfig, profile: DeploymentProfile) {
539    if profile == DeploymentProfile::SingleTenant {
540        return;
541    }
542
543    let interface_overridden = config.interface.mac.is_some()
544        || config.interface.mtu.is_some()
545        || config.interface.ipv4_address.is_some()
546        || config.interface.ipv4_pool.is_some()
547        || config.interface.ipv6_address.is_some()
548        || config.interface.ipv6_pool.is_some();
549    let had_published_ports = !config.ports.is_empty();
550    let had_custom_nameservers = !config.dns.nameservers.is_empty();
551    let disabled_rebind_protection = !config.dns.rebind_protection;
552    let trusted_host_cas = config.trust_host_cas;
553    let connection_limit_clamped = config
554        .max_connections
555        .is_some_and(|limit| limit > MULTI_TENANT_MAX_CONNECTIONS);
556
557    config.interface = Default::default();
558    config.ports.clear();
559    config.dns.nameservers.clear();
560    config.dns.rebind_protection = true;
561    config.trust_host_cas = false;
562    config.max_connections = Some(
563        config
564            .max_connections
565            .unwrap_or(MULTI_TENANT_MAX_CONNECTIONS)
566            .min(MULTI_TENANT_MAX_CONNECTIONS),
567    );
568
569    if interface_overridden
570        || had_published_ports
571        || had_custom_nameservers
572        || disabled_rebind_protection
573        || trusted_host_cas
574        || connection_limit_clamped
575    {
576        tracing::warn!(
577            interface_overridden,
578            had_published_ports,
579            had_custom_nameservers,
580            disabled_rebind_protection,
581            trusted_host_cas,
582            connection_limit_clamped,
583            "multi-tenant deployment profile overrode unsafe network configuration"
584        );
585    }
586}
587
588/// Derive a guest MAC address from the sandbox slot.
589///
590/// Format: `02:ms:bx:SS:SS:02` where SS:SS encodes the slot.
591fn derive_guest_mac(slot: u16) -> [u8; 6] {
592    let s = slot.to_be_bytes();
593    [0x02, 0x6d, 0x73, s[0], s[1], 0x02]
594}
595
596/// Derive a gateway MAC address from the sandbox slot.
597///
598/// Format: `02:ms:bx:SS:SS:01`.
599fn derive_gateway_mac(slot: u16) -> [u8; 6] {
600    let s = slot.to_be_bytes();
601    [0x02, 0x6d, 0x73, s[0], s[1], 0x01]
602}
603
604/// Derive a guest IPv4 address from the sandbox slot.
605///
606/// Pool: `172.16.0.0/12` by default. Each slot gets a `/30` block (4 IPs).
607/// Guest is at offset +2 in the block.
608fn derive_guest_ipv4(pool: Ipv4Network, slot: u16) -> Result<Ipv4Addr, NetworkInitError> {
609    let capacity = 30_u8
610        .checked_sub(pool.prefix())
611        .map(|host_bits| 1_u32 << host_bits)
612        .ok_or(NetworkInitError::Ipv4PoolCapacity { pool, slot })?;
613    if u32::from(slot) >= capacity {
614        return Err(NetworkInitError::Ipv4PoolCapacity { pool, slot });
615    }
616
617    let base = u32::from(pool.network());
618    let offset = u32::from(slot) * 4 + 2; // +2 = guest within /30
619    Ok(Ipv4Addr::from(base + offset))
620}
621
622/// Gateway IPv4 from guest IPv4: guest - 1 (offset +1 in the /30 block).
623fn gateway_from_guest_ipv4(guest: Ipv4Addr) -> Ipv4Addr {
624    Ipv4Addr::from(u32::from(guest) - 1)
625}
626
627fn default_guest_ipv4_pool() -> Ipv4Network {
628    Ipv4Network::new(Ipv4Addr::new(172, 16, 0, 0), 12)
629        .expect("default IPv4 pool must be a valid network")
630}
631
632/// Derive a guest IPv6 address from the sandbox slot.
633///
634/// Pool: `fd42:6d73:62::/48`. Each slot gets a `/64` prefix.
635/// Guest is `::2` in its prefix.
636fn derive_guest_ipv6(pool: Ipv6Network, slot: u16) -> Result<Ipv6Addr, NetworkInitError> {
637    let capacity = 64_u8
638        .checked_sub(pool.prefix())
639        .map(|host_bits| 1_u128 << host_bits)
640        .ok_or(NetworkInitError::Ipv6PoolCapacity { pool, slot })?;
641    if u128::from(slot) >= capacity {
642        return Err(NetworkInitError::Ipv6PoolCapacity { pool, slot });
643    }
644
645    let base = u128::from(pool.network());
646    let offset = u128::from(slot) << 64;
647    Ok(Ipv6Addr::from(base + offset + 2))
648}
649
650/// Gateway IPv6 from guest IPv6: `::1` in the same prefix.
651fn gateway_from_guest_ipv6(guest: Ipv6Addr) -> Ipv6Addr {
652    let segs = guest.segments();
653    Ipv6Addr::new(segs[0], segs[1], segs[2], segs[3], 0, 0, 0, 1)
654}
655
656fn default_guest_ipv6_pool() -> Ipv6Network {
657    Ipv6Network::new(Ipv6Addr::new(0xfd42, 0x6d73, 0x0062, 0, 0, 0, 0, 0), 48)
658        .expect("default IPv6 pool must be a valid network")
659}
660
661/// Format a MAC address as `xx:xx:xx:xx:xx:xx`.
662fn format_mac(mac: [u8; 6]) -> String {
663    format!(
664        "{:02x}:{:02x}:{:02x}:{:02x}:{:02x}:{:02x}",
665        mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]
666    )
667}
668
669/// Returns true if the host kernel can select an IPv4 route.
670///
671/// `UdpSocket::connect` performs a local routing-table lookup against the
672/// TEST-NET-1 (`192.0.2.1`) address; it does not send packets or wait on
673/// the network.
674fn host_has_ipv4_route() -> bool {
675    UdpSocket::bind((Ipv4Addr::UNSPECIFIED, 0))
676        .and_then(|socket| socket.connect((Ipv4Addr::new(192, 0, 2, 1), 443)))
677        .is_ok()
678}
679
680/// Returns true if the host kernel can select an IPv6 route. Probes a
681/// `2001:db8::/32` documentation address via `UdpSocket::connect` (no packet
682/// is sent).
683fn host_has_ipv6_route() -> bool {
684    UdpSocket::bind((Ipv6Addr::UNSPECIFIED, 0))
685        .and_then(|socket| socket.connect((Ipv6Addr::new(0x2001, 0x0db8, 0, 0, 0, 0, 0, 1), 443)))
686        .is_ok()
687}
688
689//--------------------------------------------------------------------------------------------------
690// Tests
691//--------------------------------------------------------------------------------------------------
692
693#[cfg(test)]
694mod tests {
695    use super::*;
696    use crate::config::{PortProtocol, PublishedPort};
697    use crate::dns::Nameserver;
698
699    #[test]
700    fn derive_addresses_slot_0() {
701        assert_eq!(derive_guest_mac(0), [0x02, 0x6d, 0x73, 0x00, 0x00, 0x02]);
702        assert_eq!(derive_gateway_mac(0), [0x02, 0x6d, 0x73, 0x00, 0x00, 0x01]);
703        assert_eq!(
704            derive_guest_ipv4(default_guest_ipv4_pool(), 0).unwrap(),
705            Ipv4Addr::new(172, 16, 0, 2)
706        );
707        assert_eq!(
708            gateway_from_guest_ipv4(Ipv4Addr::new(172, 16, 0, 2)),
709            Ipv4Addr::new(172, 16, 0, 1)
710        );
711    }
712
713    #[test]
714    fn multi_tenant_profile_sanitizes_host_owned_network_controls() {
715        let mut config = NetworkConfig::default();
716        config.interface.mac = Some([2, 3, 4, 5, 6, 7]);
717        config.interface.mtu = Some(9000);
718        config.ports.push(PublishedPort {
719            host_port: 8080,
720            guest_port: 80,
721            protocol: PortProtocol::Tcp,
722            host_bind: Ipv4Addr::UNSPECIFIED.into(),
723        });
724        config.dns.nameservers = vec!["10.0.0.53".parse::<Nameserver>().unwrap()];
725        config.dns.rebind_protection = false;
726        config.trust_host_cas = true;
727        config.max_connections = Some(MULTI_TENANT_MAX_CONNECTIONS + 1);
728        config.policy = NetworkPolicy::allow_all();
729
730        enforce_deployment_profile(&mut config, DeploymentProfile::MultiTenant);
731
732        assert!(config.interface.mac.is_none());
733        assert!(config.interface.mtu.is_none());
734        assert!(config.ports.is_empty());
735        assert!(config.dns.nameservers.is_empty());
736        assert!(config.dns.rebind_protection);
737        assert!(!config.trust_host_cas);
738        assert_eq!(config.max_connections, Some(MULTI_TENANT_MAX_CONNECTIONS));
739        // Tenant policy stays intact and is intersected with the platform
740        // policy at evaluation time instead of being reordered or flattened.
741        assert!(config.policy.default_egress.is_allow());
742    }
743
744    #[test]
745    fn single_tenant_profile_preserves_requested_network_controls() {
746        let mut config = NetworkConfig::default();
747        config.interface.mtu = Some(9000);
748        config.dns.rebind_protection = false;
749        config.trust_host_cas = true;
750
751        enforce_deployment_profile(&mut config, DeploymentProfile::SingleTenant);
752
753        assert_eq!(config.interface.mtu, Some(9000));
754        assert!(!config.dns.rebind_protection);
755        assert!(config.trust_host_cas);
756    }
757
758    #[test]
759    fn derive_addresses_slot_1() {
760        assert_eq!(
761            derive_guest_ipv4(default_guest_ipv4_pool(), 1).unwrap(),
762            Ipv4Addr::new(172, 16, 0, 6)
763        );
764        assert_eq!(
765            gateway_from_guest_ipv4(Ipv4Addr::new(172, 16, 0, 6)),
766            Ipv4Addr::new(172, 16, 0, 5)
767        );
768    }
769
770    #[test]
771    fn derive_addresses_max_slot() {
772        assert_eq!(
773            derive_guest_mac(u16::MAX),
774            [0x02, 0x6d, 0x73, 0xff, 0xff, 0x02]
775        );
776        assert_eq!(
777            derive_guest_ipv4(default_guest_ipv4_pool(), u16::MAX).unwrap(),
778            Ipv4Addr::new(172, 19, 255, 254)
779        );
780        assert_eq!(
781            derive_guest_ipv6(default_guest_ipv6_pool(), u16::MAX).unwrap(),
782            "fd42:6d73:62:ffff::2".parse::<Ipv6Addr>().unwrap()
783        );
784    }
785
786    #[test]
787    fn derive_addresses_custom_ipv4_pool() {
788        let pool = "172.31.240.0/24".parse::<Ipv4Network>().unwrap();
789        assert_eq!(
790            derive_guest_ipv4(pool, 0).unwrap(),
791            Ipv4Addr::new(172, 31, 240, 2)
792        );
793        assert_eq!(
794            derive_guest_ipv4(pool, 63).unwrap(),
795            Ipv4Addr::new(172, 31, 240, 254)
796        );
797    }
798
799    #[test]
800    fn custom_ipv4_pool_capacity_is_a_typed_error() {
801        let pool = "172.31.240.0/24".parse::<Ipv4Network>().unwrap();
802        assert!(matches!(
803            derive_guest_ipv4(pool, 64),
804            Err(NetworkInitError::Ipv4PoolCapacity { slot: 64, .. })
805        ));
806
807        let pool = "172.31.240.0/31".parse::<Ipv4Network>().unwrap();
808        assert!(matches!(
809            derive_guest_ipv4(pool, 0),
810            Err(NetworkInitError::Ipv4PoolCapacity { slot: 0, .. })
811        ));
812    }
813
814    #[test]
815    fn derive_ipv6_slot_0() {
816        assert_eq!(
817            derive_guest_ipv6(default_guest_ipv6_pool(), 0).unwrap(),
818            "fd42:6d73:62:0::2".parse::<Ipv6Addr>().unwrap()
819        );
820        assert_eq!(
821            gateway_from_guest_ipv6(derive_guest_ipv6(default_guest_ipv6_pool(), 0).unwrap()),
822            "fd42:6d73:62:0::1".parse::<Ipv6Addr>().unwrap()
823        );
824    }
825
826    #[test]
827    fn derive_addresses_custom_ipv6_pool() {
828        let pool = "fd7a:115c:a1e0:100::/56".parse::<Ipv6Network>().unwrap();
829        assert_eq!(
830            derive_guest_ipv6(pool, 0).unwrap(),
831            "fd7a:115c:a1e0:100::2".parse::<Ipv6Addr>().unwrap()
832        );
833        assert_eq!(
834            derive_guest_ipv6(pool, 3).unwrap(),
835            "fd7a:115c:a1e0:103::2".parse::<Ipv6Addr>().unwrap()
836        );
837    }
838
839    #[test]
840    fn custom_ipv6_pool_capacity_is_a_typed_error() {
841        let pool = "fd7a:115c:a1e0:100::/62".parse::<Ipv6Network>().unwrap();
842        assert!(matches!(
843            derive_guest_ipv6(pool, 4),
844            Err(NetworkInitError::Ipv6PoolCapacity { slot: 4, .. })
845        ));
846
847        let pool = "fd7a:115c:a1e0:100::/65".parse::<Ipv6Network>().unwrap();
848        assert!(matches!(
849            derive_guest_ipv6(pool, 0),
850            Err(NetworkInitError::Ipv6PoolCapacity { slot: 0, .. })
851        ));
852    }
853
854    #[test]
855    fn format_mac_address() {
856        assert_eq!(
857            format_mac([0x02, 0x6d, 0x73, 0x00, 0x00, 0x01]),
858            "02:6d:73:00:00:01"
859        );
860    }
861
862    #[test]
863    fn guest_env_vars_includes_ipv4_when_host_has_v4_route() {
864        let net =
865            SmoltcpNetwork::new_with_routes(NetworkConfig::default(), 0, true, false).unwrap();
866        let vars = net.guest_env_vars();
867
868        assert_eq!(vars.len(), 3);
869        assert_eq!(vars[0].0, ENV_NET);
870        assert!(vars[0].1.contains("iface=eth0"));
871        assert_eq!(vars[1].0, ENV_HOST_ALIAS);
872        assert_eq!(vars[1].1, crate::HOST_ALIAS);
873        assert_eq!(vars[2].0, ENV_NET_IPV4);
874        assert!(vars[2].1.contains("/30"));
875    }
876
877    #[test]
878    fn guest_env_vars_includes_ipv6_when_host_has_v6_route() {
879        let net = SmoltcpNetwork::new_with_routes(NetworkConfig::default(), 0, true, true).unwrap();
880        let vars = net.guest_env_vars();
881
882        assert_eq!(vars.len(), 4);
883        assert_eq!(vars[0].0, ENV_NET);
884        assert_eq!(vars[1].0, ENV_HOST_ALIAS);
885        assert_eq!(vars[2].0, ENV_NET_IPV4);
886        assert_eq!(vars[3].0, ENV_NET_IPV6);
887        assert!(vars[3].1.contains("/64"));
888    }
889
890    #[test]
891    fn guest_env_vars_omit_ipv6_without_host_route() {
892        let net =
893            SmoltcpNetwork::new_with_routes(NetworkConfig::default(), 0, true, false).unwrap();
894        let vars = net.guest_env_vars();
895
896        assert!(!vars.iter().any(|(k, _)| k == ENV_NET_IPV6));
897    }
898
899    #[test]
900    fn guest_env_vars_omit_ipv4_without_host_route() {
901        let net =
902            SmoltcpNetwork::new_with_routes(NetworkConfig::default(), 0, false, true).unwrap();
903        let vars = net.guest_env_vars();
904
905        assert_eq!(vars.len(), 3);
906        assert_eq!(vars[0].0, ENV_NET);
907        assert_eq!(vars[1].0, ENV_HOST_ALIAS);
908        assert_eq!(vars[2].0, ENV_NET_IPV6);
909    }
910
911    #[test]
912    fn explicit_ipv6_address_overrides_missing_host_v6_route() {
913        let mut config = NetworkConfig::default();
914        config.interface.ipv6_address = Some("fd42:6d73:62:99::2".parse().unwrap());
915        let net = SmoltcpNetwork::new_with_routes(config, 0, true, false).unwrap();
916        let vars = net.guest_env_vars();
917
918        let v6 = vars
919            .iter()
920            .find(|(k, _)| k == ENV_NET_IPV6)
921            .expect("explicit ipv6 should publish env var even without host route");
922        assert!(v6.1.contains("fd42:6d73:62:99::2/64"));
923    }
924
925    #[test]
926    fn neither_family_active_emits_only_base_env_vars() {
927        let net =
928            SmoltcpNetwork::new_with_routes(NetworkConfig::default(), 0, false, false).unwrap();
929        let vars = net.guest_env_vars();
930
931        assert_eq!(vars.len(), 2);
932        assert_eq!(vars[0].0, ENV_NET);
933        assert_eq!(vars[1].0, ENV_HOST_ALIAS);
934    }
935
936    #[test]
937    fn guest_bootstrap_network_preserves_active_address_families() {
938        let net = SmoltcpNetwork::new_with_routes(NetworkConfig::default(), 7, true, true).unwrap();
939
940        let bootstrap = net.guest_bootstrap_network();
941
942        assert_eq!(bootstrap.interface, "eth0");
943        assert_eq!(bootstrap.mac, net.guest_mac());
944        assert_eq!(bootstrap.mtu, 1500);
945        assert_eq!(bootstrap.ipv4.unwrap().prefix_len, 30);
946        assert_eq!(bootstrap.ipv6.unwrap().prefix_len, 64);
947        assert_eq!(net.guest_host_alias(), crate::HOST_ALIAS);
948    }
949
950    #[test]
951    fn guest_bootstrap_network_allows_no_active_address_family() {
952        let net =
953            SmoltcpNetwork::new_with_routes(NetworkConfig::default(), 0, false, false).unwrap();
954
955        let bootstrap = net.guest_bootstrap_network();
956
957        assert!(bootstrap.ipv4.is_none());
958        assert!(bootstrap.ipv6.is_none());
959    }
960
961    #[test]
962    fn new_with_routes_rejects_excessive_max_connections() {
963        let mut config = NetworkConfig {
964            max_connections: Some(MAX_NETWORK_CONNECTIONS + 1),
965            ..NetworkConfig::default()
966        };
967        config.tls.enabled = false;
968
969        let err = match SmoltcpNetwork::new_with_routes(config, 0, true, false) {
970            Ok(_) => panic!("excessive max_connections should fail"),
971            Err(err) => err,
972        };
973
974        assert!(matches!(
975            err,
976            NetworkInitError::MaxConnectionsExceeded {
977                configured,
978                limit: MAX_NETWORK_CONNECTIONS
979            } if configured == MAX_NETWORK_CONNECTIONS + 1
980        ));
981    }
982
983    /// A stored config bypasses the builder's validation, so an invalid
984    /// limiter must fail startup cleanly instead of panicking.
985    #[test]
986    fn new_with_routes_rejects_invalid_rate_limiter() {
987        let mut config = NetworkConfig {
988            rate_limiter: Some(microsandbox_types::NetworkRateLimiterConfig {
989                egress: None,
990                ingress: Some(microsandbox_types::RateLimiterConfig {
991                    bandwidth: None,
992                    ops: None,
993                }),
994            }),
995            ..NetworkConfig::default()
996        };
997        config.tls.enabled = false;
998
999        let err = match SmoltcpNetwork::new_with_routes(config, 0, true, false) {
1000            Ok(_) => panic!("empty rate limiter should fail"),
1001            Err(err) => err,
1002        };
1003
1004        assert!(matches!(
1005            err,
1006            NetworkInitError::InvalidRateLimit {
1007                direction: NetworkRateLimitDirection::Ingress,
1008                source: RateLimitConfigError::EmptyLimiter,
1009            }
1010        ));
1011    }
1012}