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