Skip to main content

microsandbox_network/engine/
network.rs

1//! `SmoltcpNetwork` — orchestration type that ties [`crate::config::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::num::NonZeroUsize;
10use std::sync::{Arc, Condvar, Mutex};
11use std::thread::JoinHandle;
12
13use ipnetwork::{Ipv4Network, Ipv6Network};
14use microsandbox_protocol::bootstrap::{
15    BootstrapEnvVar, BootstrapIpv4, BootstrapIpv6, BootstrapNetwork,
16};
17use microsandbox_protocol::{ENV_HOST_ALIAS, ENV_NET, ENV_NET_IPV4, ENV_NET_IPV6};
18use microsandbox_types::{
19    DeploymentProfile, NetworkRateLimitDirection, RateLimitConfigError, RateLimiterConfig,
20};
21use msb_krun::backends::net::NetBackend;
22
23use crate::config::{ConnectionLimit, PortProtocol, ResolvedNetworkConfig};
24use crate::engine::tls::state::{TlsState, TlsStateError};
25use crate::netstack::{
26    backend::SmoltcpBackend,
27    poll::{self, GatewayIps, PollLoopConfig},
28    shared::{DEFAULT_QUEUE_CAPACITY, SharedState},
29};
30use crate::policy::{NetworkPolicy, NetworkProfile};
31use crate::secrets::handle::SecretsHandle;
32
33//--------------------------------------------------------------------------------------------------
34// Constants
35//--------------------------------------------------------------------------------------------------
36
37/// Default TCP cap for multi-tenant deployments; explicit settings override it.
38const DEFAULT_MULTI_TENANT_MAX_TCP_CONNECTIONS: NonZeroUsize = NonZeroUsize::new(1024).unwrap();
39
40/// Default UDP cap for multi-tenant deployments; explicit settings override it.
41const DEFAULT_MULTI_TENANT_MAX_UDP_CONNECTIONS: NonZeroUsize = NonZeroUsize::new(1024).unwrap();
42
43//--------------------------------------------------------------------------------------------------
44// Types
45//--------------------------------------------------------------------------------------------------
46
47/// The networking engine. Created from [`crate::config::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_bootstrap_network()`](Self::guest_bootstrap_network) — typed guest network setup
52/// - [`ca_cert_pem()`](Self::ca_cert_pem) — CA certificate for TLS interception
53pub struct SmoltcpNetwork {
54    config: ResolvedNetworkConfig,
55    /// Host-owned policy floor derived from the deployment profile and
56    /// enforced in addition to the sandbox's configured network policy.
57    platform_policy: Option<NetworkPolicy>,
58    shared: Arc<SharedState>,
59    backend: Option<SmoltcpBackend>,
60    poll_handle: Option<JoinHandle<()>>,
61    activation_gate: Option<Arc<NetworkActivationGate>>,
62
63    // Resolved from config + slot.
64    guest_mac: [u8; 6],
65    gateway_mac: [u8; 6],
66    mtu: u16,
67    // IPv4 / IPv6 are `Some` when active for this sandbox: the user supplied
68    // an explicit address, the host has a route, or published ports need that family.
69    guest_ipv4: Option<Ipv4Addr>,
70    gateway_ipv4: Option<Ipv4Addr>,
71    guest_ipv6: Option<Ipv6Addr>,
72    gateway_ipv6: Option<Ipv6Addr>,
73
74    // TLS state (if enabled). Created in new(), used for ca_cert_pem().
75    tls_state: Option<Arc<TlsState>>,
76
77    // Live-swappable secrets view shared with the poll loop and TLS state.
78    secrets: SecretsHandle,
79}
80
81#[derive(Clone, Copy)]
82struct HostRoutes {
83    ipv4: bool,
84    ipv6: bool,
85}
86
87/// Errors that prevent the smoltcp network from being created safely.
88#[derive(Debug, thiserror::Error)]
89pub enum NetworkInitError {
90    /// A checkpoint supplied an unusable virtual gateway Ethernet address.
91    #[error("captured gateway MAC must be a nonzero unicast address distinct from the guest")]
92    InvalidGatewayMac,
93    /// The configured IPv4 pool cannot provide a `/30` for this slot.
94    #[error("IPv4 pool {pool} cannot assign network slot {slot}")]
95    Ipv4PoolCapacity {
96        /// Configured IPv4 pool.
97        pool: Ipv4Network,
98        /// Requested sandbox slot.
99        slot: u16,
100    },
101
102    /// The configured IPv6 pool cannot provide a `/64` for this slot.
103    #[error("IPv6 pool {pool} cannot assign network slot {slot}")]
104    Ipv6PoolCapacity {
105        /// Configured IPv6 pool.
106        pool: Ipv6Network,
107        /// Requested sandbox slot.
108        slot: u16,
109    },
110
111    /// A configured NAT64 prefix is not an IPv6 `/96` network.
112    #[error("invalid NAT64 prefix `{raw}`: prefix must be IPv6 /96")]
113    InvalidNat64Prefix {
114        /// Invalid raw prefix.
115        raw: String,
116    },
117
118    /// TLS interception state failed to initialize.
119    #[error("TLS initialization failed: {0}")]
120    Tls(#[from] TlsStateError),
121
122    /// A stored rate limiter configuration failed validation.
123    #[error("invalid {direction} rate limiter: {source}")]
124    InvalidRateLimit {
125        /// Which limiter is invalid: `egress` or `ingress`.
126        direction: NetworkRateLimitDirection,
127        /// Underlying validation error.
128        #[source]
129        source: RateLimitConfigError,
130    },
131
132    /// A stored network rate limiter has neither direction configured.
133    #[error("invalid network rate limiter: at least one of egress or ingress is required")]
134    EmptyNetworkRateLimiter,
135}
136
137/// Handle for installing host-side termination behavior into the network stack.
138#[derive(Clone)]
139pub struct TerminationHandle {
140    shared: Arc<SharedState>,
141}
142
143/// Read-only view of aggregate network byte counters.
144#[derive(Clone)]
145pub struct MetricsHandle {
146    shared: Arc<SharedState>,
147}
148
149/// One-shot handle that permits a deferred network stack to publish listeners and process traffic.
150#[derive(Clone)]
151pub struct NetworkActivationHandle {
152    gate: Arc<NetworkActivationGate>,
153}
154
155struct NetworkActivationGate {
156    active: Mutex<bool>,
157    changed: Condvar,
158}
159
160//--------------------------------------------------------------------------------------------------
161// Methods
162//--------------------------------------------------------------------------------------------------
163
164impl HostRoutes {
165    fn detect() -> Self {
166        Self {
167            ipv4: host_has_ipv4_route(),
168            ipv6: host_has_ipv6_route(),
169        }
170    }
171}
172
173impl SmoltcpNetwork {
174    /// Gateway identity for an ordinary cold boot in the given host slot.
175    pub fn default_gateway_mac(slot: u16) -> [u8; 6] {
176        derive_gateway_mac(slot)
177    }
178
179    /// Preserve a captured gateway before starting this fresh network backend.
180    ///
181    /// Host sockets, policy, queues and port ownership remain child-owned. Only
182    /// the Ethernet identity visible to captured ARP/ND caches is retained.
183    /// Panics if called after the network poll thread has started.
184    pub fn with_captured_gateway_mac(mut self, mac: [u8; 6]) -> Result<Self, NetworkInitError> {
185        assert!(
186            self.poll_handle.is_none(),
187            "gateway identity must be set before network start"
188        );
189        if mac == [0; 6] || mac[0] & 1 != 0 || mac == self.guest_mac {
190            return Err(NetworkInitError::InvalidGatewayMac);
191        }
192        self.gateway_mac = mac;
193        Ok(self)
194    }
195
196    /// Creates the network backend from a fully resolved runtime configuration.
197    ///
198    /// `MultiTenant` applies platform-owned configuration floors before any
199    /// sockets, resolvers, or TLS state are created. The requested tenant policy
200    /// remains separate and is intersected with the platform's public-network
201    /// policy by the poll loop.
202    ///
203    /// # Errors
204    ///
205    /// Returns an error when the effective network configuration would allocate
206    /// unsafe resources or TLS interception cannot initialize.
207    pub fn new(
208        config: ResolvedNetworkConfig,
209        slot: u16,
210        deployment_profile: DeploymentProfile,
211    ) -> Result<Self, NetworkInitError> {
212        Self::build(config, slot, deployment_profile, HostRoutes::detect())
213    }
214
215    fn build(
216        mut config: ResolvedNetworkConfig,
217        slot: u16,
218        deployment_profile: DeploymentProfile,
219        host_routes: HostRoutes,
220    ) -> Result<Self, NetworkInitError> {
221        enforce_deployment_profile(&mut config, deployment_profile);
222        let platform_policy = Self::platform_policy(deployment_profile);
223        let resolved_config = config;
224        let config = resolved_config.config();
225
226        let guest_mac = config
227            .interface
228            .mac
229            .unwrap_or_else(|| derive_guest_mac(slot));
230        let gateway_mac = derive_gateway_mac(slot);
231        let mtu = config.interface.mtu.unwrap_or(1500);
232
233        // Preserve existing family selection, including IPv6-only TCP targets. If
234        // startup would leave the guest without any IP, supply the private addresses
235        // needed for published ports: TCP can bridge families and uses IPv4, while
236        // UDP needs the host bind's family. Neither requires an external host route.
237        let no_guest_address = !host_routes.ipv4
238            && !host_routes.ipv6
239            && config.interface.ipv4_address.is_none()
240            && config.interface.ipv6_address.is_none();
241        let published_ipv4 = no_guest_address
242            && config
243                .ports
244                .iter()
245                .any(|port| port.protocol == PortProtocol::Tcp || port.host_bind.is_ipv4());
246        let published_ipv6 = no_guest_address
247            && config
248                .ports
249                .iter()
250                .any(|port| port.protocol == PortProtocol::Udp && port.host_bind.is_ipv6());
251
252        let guest_ipv4 = match config.interface.ipv4_address {
253            Some(address) => Some(address),
254            None if host_routes.ipv4 || published_ipv4 => Some(derive_guest_ipv4(
255                config
256                    .interface
257                    .ipv4_pool
258                    .unwrap_or_else(default_guest_ipv4_pool),
259                slot,
260            )?),
261            None => None,
262        };
263        let gateway_ipv4 = guest_ipv4.map(gateway_from_guest_ipv4);
264        let guest_ipv6 = match config.interface.ipv6_address {
265            Some(address) => Some(address),
266            None if host_routes.ipv6 || published_ipv6 => Some(derive_guest_ipv6(
267                config
268                    .interface
269                    .ipv6_pool
270                    .unwrap_or_else(default_guest_ipv6_pool),
271                slot,
272            )?),
273            None => None,
274        };
275        let gateway_ipv6 = guest_ipv6.map(gateway_from_guest_ipv6);
276
277        // Packet queue capacity is independent of the optional connection cap:
278        // a large cap must not allocate a correspondingly large packet queue.
279        let shared = Arc::new(SharedState::new(DEFAULT_QUEUE_CAPACITY));
280        shared.set_http_config(config.http.clone());
281        if let Some(prefix) = config
282            .nat64_prefixes
283            .iter()
284            .find(|prefix| prefix.prefix() != 96)
285        {
286            return Err(NetworkInitError::InvalidNat64Prefix {
287                raw: prefix.to_string(),
288            });
289        }
290        shared.set_nat64_prefixes(config.nat64_prefixes.clone());
291        // Every write path validates rate limiters (`NetworkBuilder::build`),
292        // but a stored config bypasses the builder: fail startup cleanly
293        // instead of panicking on a corrupted spec.
294        if config.rate_limiter.as_ref().is_some_and(|rate_limiter| {
295            rate_limiter.egress.is_none() && rate_limiter.ingress.is_none()
296        }) {
297            return Err(NetworkInitError::EmptyNetworkRateLimiter);
298        }
299        config
300            .rate_limiter
301            .as_ref()
302            .and_then(|rate_limiter| rate_limiter.ingress.as_ref())
303            .map(RateLimiterConfig::validate)
304            .transpose()
305            .map_err(|source| NetworkInitError::InvalidRateLimit {
306                direction: NetworkRateLimitDirection::Ingress,
307                source,
308            })?;
309        config
310            .rate_limiter
311            .as_ref()
312            .and_then(|rate_limiter| rate_limiter.egress.as_ref())
313            .map(RateLimiterConfig::validate)
314            .transpose()
315            .map_err(|source| NetworkInitError::InvalidRateLimit {
316                direction: NetworkRateLimitDirection::Egress,
317                source,
318            })?;
319        let backend = SmoltcpBackend::new(shared.clone());
320
321        let secrets = SecretsHandle::new(config.secrets.clone());
322        let tls_state = if config.tls.enabled {
323            Some(Arc::new(TlsState::new(
324                config.tls.clone(),
325                secrets.clone(),
326            )?))
327        } else {
328            None
329        };
330
331        Ok(Self {
332            config: resolved_config,
333            platform_policy,
334            shared,
335            backend: Some(backend),
336            poll_handle: None,
337            activation_gate: None,
338            guest_mac,
339            gateway_mac,
340            mtu,
341            guest_ipv4,
342            gateway_ipv4,
343            guest_ipv6,
344            gateway_ipv6,
345            tls_state,
346            secrets,
347        })
348    }
349
350    /// Hold network processing and published-port creation behind an explicit one-shot gate.
351    ///
352    /// This must be selected before [`start`](Self::start). It is used by checkpoint restore so
353    /// ingress cannot reach the child until guest activation has completed. The gate is consumed
354    /// once at poll-thread startup and adds no checks to steady-state packet processing.
355    pub fn defer_activation(&mut self) -> NetworkActivationHandle {
356        assert!(
357            self.poll_handle.is_none(),
358            "network activation can only be deferred before start"
359        );
360        let gate = Arc::new(NetworkActivationGate {
361            active: Mutex::new(false),
362            changed: Condvar::new(),
363        });
364        self.activation_gate = Some(Arc::clone(&gate));
365        NetworkActivationHandle { gate }
366    }
367
368    fn platform_policy(deployment_profile: DeploymentProfile) -> Option<NetworkPolicy> {
369        match deployment_profile {
370            DeploymentProfile::SingleTenant => None,
371            DeploymentProfile::MultiTenant => {
372                Some(NetworkPolicy::from_profiles([NetworkProfile::Public]))
373            }
374        }
375    }
376
377    /// Get the gateway IPs for virtio-net configuration and domain-based policy rules.
378    fn gateway_ips(&self) -> GatewayIps {
379        GatewayIps {
380            ipv4: self.gateway_ipv4,
381            ipv6: self.gateway_ipv6,
382        }
383    }
384
385    /// Start the smoltcp poll thread.
386    ///
387    /// Must be called before VM boot. Requires a tokio runtime handle for
388    /// spawning proxy tasks, DNS resolution, and published port listeners.
389    pub fn start(&mut self, tokio_handle: tokio::runtime::Handle) {
390        let shared = self.shared.clone();
391        let poll_config = PollLoopConfig {
392            gateway_mac: self.gateway_mac,
393            guest_mac: self.guest_mac,
394            gateway: self.gateway_ips(),
395            guest_ipv4: self.guest_ipv4,
396            guest_ipv6: self.guest_ipv6,
397            mtu: self.mtu as usize,
398        };
399        let config = self.config.config();
400        let network_policy = config.policy.clone();
401        let platform_policy = self.platform_policy.clone();
402        let dns_config = config.dns.clone();
403        let tls_state = self.tls_state.clone();
404        let published_ports = config.ports.clone();
405        let strict = config.strict;
406        let max_tcp_connections = config.max_tcp_connections.and_then(ConnectionLimit::cap);
407        let max_udp_connections = config.max_udp_connections;
408        let tcp_accept_queue_size = config.tcp_accept_queue_size.unwrap_or_default();
409        let secrets = self.secrets.clone();
410        let activation_gate = self.activation_gate.take();
411        let outbound_proxy = self.config.outbound_proxy().cloned().map(Arc::new);
412
413        self.poll_handle = Some(
414            std::thread::Builder::new()
415                .name("smoltcp-poll".into())
416                .spawn(move || {
417                    if let Some(gate) = activation_gate {
418                        gate.wait();
419                    }
420                    poll::smoltcp_poll_loop(
421                        shared,
422                        poll_config,
423                        network_policy,
424                        platform_policy,
425                        dns_config,
426                        tls_state,
427                        published_ports,
428                        strict,
429                        max_tcp_connections,
430                        max_udp_connections,
431                        tcp_accept_queue_size,
432                        tokio_handle,
433                        secrets,
434                        outbound_proxy,
435                    );
436                })
437                .expect("failed to spawn smoltcp poll thread"),
438        );
439    }
440
441    /// Take the `NetBackend` for `VmBuilder::net()`. One-shot.
442    pub fn take_backend(&mut self) -> Box<dyn NetBackend + Send> {
443        Box::new(self.backend.take().expect("backend already taken"))
444    }
445
446    /// Guest MAC address for `VmBuilder::net().mac()`.
447    pub fn guest_mac(&self) -> [u8; 6] {
448        self.guest_mac
449    }
450
451    /// Generate `MSB_NET*` environment variables for the guest.
452    ///
453    /// The guest init (`agentd`) reads these to configure the network
454    /// interface via ioctls + netlink.
455    pub fn guest_env_vars(&self) -> Vec<(String, String)> {
456        let mut vars = vec![
457            (
458                ENV_NET.into(),
459                format!(
460                    "iface=eth0,mac={},mtu={}",
461                    format_mac(self.guest_mac),
462                    self.mtu,
463                ),
464            ),
465            (ENV_HOST_ALIAS.into(), crate::HOST_ALIAS.into()),
466        ];
467
468        if let (Some(guest), Some(gateway)) = (self.guest_ipv4, self.gateway_ipv4) {
469            vars.push((
470                ENV_NET_IPV4.into(),
471                format!("addr={guest}/30,gw={gateway},dns={gateway}"),
472            ));
473        }
474
475        if let (Some(guest), Some(gateway)) = (self.guest_ipv6, self.gateway_ipv6) {
476            vars.push((
477                ENV_NET_IPV6.into(),
478                format!("addr={guest}/64,gw={gateway},dns={gateway}"),
479            ));
480        }
481
482        // Auto-expose secret placeholders as environment variables.
483        for secret in &self.config.config().secrets.secrets {
484            vars.push((secret.env_var.clone(), secret.placeholder.clone()));
485        }
486
487        vars
488    }
489
490    /// Build the typed network payload consumed by agentd during bootstrap.
491    pub fn guest_bootstrap_network(&self) -> BootstrapNetwork {
492        BootstrapNetwork {
493            interface: "eth0".to_string(),
494            mac: self.guest_mac,
495            mtu: self.mtu,
496            ipv4: self
497                .guest_ipv4
498                .zip(self.gateway_ipv4)
499                .map(|(address, gateway)| BootstrapIpv4 {
500                    address,
501                    prefix_len: 30,
502                    gateway,
503                    dns: Some(gateway),
504                }),
505            ipv6: self
506                .guest_ipv6
507                .zip(self.gateway_ipv6)
508                .map(|(address, gateway)| BootstrapIpv6 {
509                    address,
510                    prefix_len: 64,
511                    gateway,
512                    dns: Some(gateway),
513                }),
514        }
515    }
516
517    /// Return the stable hostname used by guests to address the host gateway.
518    pub fn guest_host_alias(&self) -> &'static str {
519        crate::HOST_ALIAS
520    }
521
522    /// Return guest-visible secret placeholders for the baseline environment.
523    ///
524    /// Real secret values stay in the host-side network handler and never
525    /// enter this payload.
526    pub fn guest_secret_env(&self) -> Vec<BootstrapEnvVar> {
527        self.config
528            .config()
529            .secrets
530            .secrets
531            .iter()
532            .map(|secret| BootstrapEnvVar {
533                key: secret.env_var.clone(),
534                value: secret.placeholder.clone(),
535            })
536            .collect()
537    }
538
539    /// CA certificate PEM bytes if TLS interception is enabled.
540    ///
541    /// Write to the runtime mount before VM boot so the guest can trust it.
542    pub fn ca_cert_pem(&self) -> Option<Vec<u8>> {
543        self.tls_state.as_ref().map(|s| s.ca_cert_pem())
544    }
545
546    /// Host-trusted CA bundle to ship into the guest, if
547    /// [`crate::config::NetworkConfig::trust_host_cas`] is enabled.
548    ///
549    /// Returned PEM may concatenate CAs that the Mozilla root bundle in
550    /// the guest already trusts; duplicates are harmless and saved the
551    /// cost of computing a delta. Returns `None` when the host store is
552    /// empty or the feature is disabled.
553    pub fn host_cas_cert_pem(&self) -> Option<Vec<u8>> {
554        if !self.config.config().trust_host_cas {
555            return None;
556        }
557        crate::engine::tls::host_cas::collect_host_cas()
558    }
559
560    /// Create a handle for wiring runtime termination into the network stack.
561    pub fn termination_handle(&self) -> TerminationHandle {
562        TerminationHandle {
563            shared: self.shared.clone(),
564        }
565    }
566
567    /// Create a handle for reading aggregate network byte counters.
568    pub fn metrics_handle(&self) -> MetricsHandle {
569        MetricsHandle {
570            shared: self.shared.clone(),
571        }
572    }
573
574    /// Live-swappable view of the secrets configuration. The runtime control
575    /// socket uses it to apply secret rotation, removal, and allowed-host
576    /// updates without restarting the sandbox.
577    pub fn secrets_handle(&self) -> SecretsHandle {
578        self.secrets.clone()
579    }
580}
581
582impl NetworkActivationHandle {
583    /// Release a deferred network exactly once. Repeated calls are harmless.
584    pub fn activate(&self) {
585        let mut active = self.gate.active.lock().unwrap();
586        if !*active {
587            *active = true;
588            self.gate.changed.notify_all();
589        }
590    }
591}
592
593impl NetworkActivationGate {
594    fn wait(&self) {
595        let mut active = self.active.lock().unwrap();
596        while !*active {
597            active = self.changed.wait(active).unwrap();
598        }
599    }
600}
601
602impl TerminationHandle {
603    /// Install the termination hook.
604    pub fn set_hook(&self, hook: Arc<dyn Fn() + Send + Sync>) {
605        self.shared.set_termination_hook(hook);
606    }
607}
608
609impl MetricsHandle {
610    /// Total guest -> runtime bytes observed at the virtio-net boundary.
611    pub fn tx_bytes(&self) -> u64 {
612        self.shared.tx_bytes()
613    }
614
615    /// Total runtime -> guest bytes observed at the virtio-net boundary.
616    pub fn rx_bytes(&self) -> u64 {
617        self.shared.rx_bytes()
618    }
619}
620
621//--------------------------------------------------------------------------------------------------
622// Functions
623//--------------------------------------------------------------------------------------------------
624
625/// Apply the platform-owned configuration floor before network resources are created.
626///
627/// Policy rules are deliberately not flattened here. The poll loop evaluates
628/// the platform public-network policy and the tenant policy independently so a
629/// broad tenant allow can never outrank the platform floor, while a tenant deny
630/// still remains effective.
631fn enforce_deployment_profile(config: &mut ResolvedNetworkConfig, profile: DeploymentProfile) {
632    if profile == DeploymentProfile::SingleTenant {
633        return;
634    }
635
636    config.clear_outbound_proxy();
637
638    let config = config.config_mut();
639    config
640        .max_tcp_connections
641        .get_or_insert(ConnectionLimit::Limited(
642            DEFAULT_MULTI_TENANT_MAX_TCP_CONNECTIONS,
643        ));
644    config
645        .max_udp_connections
646        .get_or_insert(ConnectionLimit::Limited(
647            DEFAULT_MULTI_TENANT_MAX_UDP_CONNECTIONS,
648        ));
649    let interface_overridden = config.interface.mac.is_some()
650        || config.interface.mtu.is_some()
651        || config.interface.ipv4_address.is_some()
652        || config.interface.ipv4_pool.is_some()
653        || config.interface.ipv6_address.is_some()
654        || config.interface.ipv6_pool.is_some();
655    let had_published_ports = !config.ports.is_empty();
656    let had_custom_nameservers = !config.dns.nameservers.is_empty();
657    let disabled_rebind_protection = !config.dns.rebind_protection;
658    let trusted_host_cas = config.trust_host_cas;
659    let had_outbound_proxy = config.outbound_proxy.is_some();
660    config.interface = Default::default();
661    config.ports.clear();
662    config.dns.nameservers.clear();
663    config.dns.rebind_protection = true;
664    config.trust_host_cas = false;
665    if interface_overridden
666        || had_published_ports
667        || had_custom_nameservers
668        || disabled_rebind_protection
669        || trusted_host_cas
670        || had_outbound_proxy
671    {
672        tracing::warn!(
673            interface_overridden,
674            had_published_ports,
675            had_custom_nameservers,
676            disabled_rebind_protection,
677            trusted_host_cas,
678            had_outbound_proxy,
679            "multi-tenant deployment profile overrode unsafe network configuration"
680        );
681    }
682}
683
684/// Derive a guest MAC address from the sandbox slot.
685///
686/// Format: `02:ms:bx:SS:SS:02` where SS:SS encodes the slot.
687fn derive_guest_mac(slot: u16) -> [u8; 6] {
688    let s = slot.to_be_bytes();
689    [0x02, 0x6d, 0x73, s[0], s[1], 0x02]
690}
691
692/// Derive a gateway MAC address from the sandbox slot.
693///
694/// Format: `02:ms:bx:SS:SS:01`.
695fn derive_gateway_mac(slot: u16) -> [u8; 6] {
696    let s = slot.to_be_bytes();
697    [0x02, 0x6d, 0x73, s[0], s[1], 0x01]
698}
699
700/// Derive a guest IPv4 address from the sandbox slot.
701///
702/// Pool: `172.16.0.0/12` by default. Each slot gets a `/30` block (4 IPs).
703/// Guest is at offset +2 in the block.
704fn derive_guest_ipv4(pool: Ipv4Network, slot: u16) -> Result<Ipv4Addr, NetworkInitError> {
705    let capacity = 30_u8
706        .checked_sub(pool.prefix())
707        .map(|host_bits| 1_u32 << host_bits)
708        .ok_or(NetworkInitError::Ipv4PoolCapacity { pool, slot })?;
709    if u32::from(slot) >= capacity {
710        return Err(NetworkInitError::Ipv4PoolCapacity { pool, slot });
711    }
712
713    let base = u32::from(pool.network());
714    let offset = u32::from(slot) * 4 + 2; // +2 = guest within /30
715    Ok(Ipv4Addr::from(base + offset))
716}
717
718/// Gateway IPv4 from guest IPv4: guest - 1 (offset +1 in the /30 block).
719fn gateway_from_guest_ipv4(guest: Ipv4Addr) -> Ipv4Addr {
720    Ipv4Addr::from(u32::from(guest) - 1)
721}
722
723fn default_guest_ipv4_pool() -> Ipv4Network {
724    Ipv4Network::new(Ipv4Addr::new(172, 16, 0, 0), 12)
725        .expect("default IPv4 pool must be a valid network")
726}
727
728/// Derive a guest IPv6 address from the sandbox slot.
729///
730/// Pool: `fd42:6d73:62::/48`. Each slot gets a `/64` prefix.
731/// Guest is `::2` in its prefix.
732fn derive_guest_ipv6(pool: Ipv6Network, slot: u16) -> Result<Ipv6Addr, NetworkInitError> {
733    let capacity = 64_u8
734        .checked_sub(pool.prefix())
735        .map(|host_bits| 1_u128 << host_bits)
736        .ok_or(NetworkInitError::Ipv6PoolCapacity { pool, slot })?;
737    if u128::from(slot) >= capacity {
738        return Err(NetworkInitError::Ipv6PoolCapacity { pool, slot });
739    }
740
741    let base = u128::from(pool.network());
742    let offset = u128::from(slot) << 64;
743    Ok(Ipv6Addr::from(base + offset + 2))
744}
745
746/// Gateway IPv6 from guest IPv6: `::1` in the same prefix.
747fn gateway_from_guest_ipv6(guest: Ipv6Addr) -> Ipv6Addr {
748    let segs = guest.segments();
749    Ipv6Addr::new(segs[0], segs[1], segs[2], segs[3], 0, 0, 0, 1)
750}
751
752fn default_guest_ipv6_pool() -> Ipv6Network {
753    Ipv6Network::new(Ipv6Addr::new(0xfd42, 0x6d73, 0x0062, 0, 0, 0, 0, 0), 48)
754        .expect("default IPv6 pool must be a valid network")
755}
756
757/// Format a MAC address as `xx:xx:xx:xx:xx:xx`.
758fn format_mac(mac: [u8; 6]) -> String {
759    format!(
760        "{:02x}:{:02x}:{:02x}:{:02x}:{:02x}:{:02x}",
761        mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]
762    )
763}
764
765/// Returns true if the host kernel can select an IPv4 route.
766///
767/// `UdpSocket::connect` performs a local routing-table lookup against the
768/// TEST-NET-1 (`192.0.2.1`) address; it does not send packets or wait on
769/// the network.
770fn host_has_ipv4_route() -> bool {
771    UdpSocket::bind((Ipv4Addr::UNSPECIFIED, 0))
772        .and_then(|socket| socket.connect((Ipv4Addr::new(192, 0, 2, 1), 443)))
773        .is_ok()
774}
775
776/// Returns true if the host kernel can select an IPv6 route. Probes a
777/// `2001:db8::/32` documentation address via `UdpSocket::connect` (no packet
778/// is sent).
779fn host_has_ipv6_route() -> bool {
780    UdpSocket::bind((Ipv6Addr::UNSPECIFIED, 0))
781        .and_then(|socket| socket.connect((Ipv6Addr::new(0x2001, 0x0db8, 0, 0, 0, 0, 0, 1), 443)))
782        .is_ok()
783}
784
785//--------------------------------------------------------------------------------------------------
786// Tests
787//--------------------------------------------------------------------------------------------------
788
789#[cfg(test)]
790#[path = "network/published_ports_tests.rs"]
791mod published_ports_tests;
792
793#[cfg(test)]
794mod tests {
795    use super::*;
796    use crate::config::{EnvNetworkSecretResolver, NetworkConfig, PortProtocol, PublishedPort};
797    use crate::dns::Nameserver;
798
799    fn resolved(config: NetworkConfig) -> ResolvedNetworkConfig {
800        config.resolve(&EnvNetworkSecretResolver).unwrap()
801    }
802
803    fn routes(ipv4: bool, ipv6: bool) -> HostRoutes {
804        HostRoutes { ipv4, ipv6 }
805    }
806
807    #[test]
808    fn captured_gateway_survives_new_slots_without_sharing_backends() {
809        let mut source_config = NetworkConfig::default();
810        // A user-supplied guest MAC cannot reveal the source gateway's slot.
811        source_config.interface.mac = Some([2, 0xaa, 0xbb, 0xcc, 0xdd, 0xee]);
812        let source = SmoltcpNetwork::build(
813            resolved(source_config),
814            7,
815            DeploymentProfile::SingleTenant,
816            routes(true, true),
817        )
818        .unwrap();
819        let mut config = NetworkConfig::default();
820        config.interface.mac = Some(source.guest_mac);
821        config.interface.ipv4_address = source.guest_ipv4;
822        config.interface.ipv6_address = source.guest_ipv6;
823        for slot in [8, 400] {
824            let child = SmoltcpNetwork::build(
825                resolved(config.clone()),
826                slot,
827                DeploymentProfile::SingleTenant,
828                routes(true, true),
829            )
830            .unwrap()
831            .with_captured_gateway_mac(source.gateway_mac)
832            .unwrap();
833            assert_eq!(child.gateway_mac, source.gateway_mac);
834            assert_eq!(child.gateway_ipv4, source.gateway_ipv4);
835            assert_eq!(child.gateway_ipv6, source.gateway_ipv6);
836            assert_eq!(child.guest_mac, source.guest_mac);
837            assert!(!Arc::ptr_eq(&child.shared, &source.shared));
838        }
839        assert_eq!(source.gateway_mac, SmoltcpNetwork::default_gateway_mac(7));
840    }
841
842    #[test]
843    fn captured_gateway_rejects_unusable_ethernet_addresses() {
844        for mac in [[0; 6], [1, 2, 3, 4, 5, 6], derive_guest_mac(8)] {
845            let network = SmoltcpNetwork::build(
846                resolved(NetworkConfig::default()),
847                8,
848                DeploymentProfile::SingleTenant,
849                routes(true, true),
850            )
851            .unwrap();
852            assert!(matches!(
853                network.with_captured_gateway_mac(mac),
854                Err(NetworkInitError::InvalidGatewayMac)
855            ));
856        }
857    }
858
859    #[test]
860    fn deferred_activation_blocks_until_released() {
861        let mut network = SmoltcpNetwork::build(
862            resolved(NetworkConfig::default()),
863            0,
864            DeploymentProfile::SingleTenant,
865            routes(true, false),
866        )
867        .unwrap();
868        let handle = network.defer_activation();
869        let gate = Arc::clone(network.activation_gate.as_ref().unwrap());
870        let (released_tx, released_rx) = std::sync::mpsc::channel();
871        let waiter = std::thread::spawn(move || {
872            gate.wait();
873            released_tx.send(()).unwrap();
874        });
875
876        assert!(
877            released_rx
878                .recv_timeout(std::time::Duration::from_millis(25))
879                .is_err(),
880            "deferred network became active before explicit release"
881        );
882        handle.activate();
883        released_rx
884            .recv_timeout(std::time::Duration::from_secs(1))
885            .unwrap();
886        waiter.join().unwrap();
887    }
888
889    #[test]
890    fn derive_addresses_slot_0() {
891        assert_eq!(derive_guest_mac(0), [0x02, 0x6d, 0x73, 0x00, 0x00, 0x02]);
892        assert_eq!(derive_gateway_mac(0), [0x02, 0x6d, 0x73, 0x00, 0x00, 0x01]);
893        assert_eq!(
894            derive_guest_ipv4(default_guest_ipv4_pool(), 0).unwrap(),
895            Ipv4Addr::new(172, 16, 0, 2)
896        );
897        assert_eq!(
898            gateway_from_guest_ipv4(Ipv4Addr::new(172, 16, 0, 2)),
899            Ipv4Addr::new(172, 16, 0, 1)
900        );
901    }
902
903    #[test]
904    fn multi_tenant_profile_sanitizes_host_owned_network_controls() {
905        let mut config = NetworkConfig::default();
906        config.interface.mac = Some([2, 3, 4, 5, 6, 7]);
907        config.interface.mtu = Some(9000);
908        config.ports.push(PublishedPort {
909            host_port: 8080,
910            guest_port: 80,
911            protocol: PortProtocol::Tcp,
912            host_bind: Ipv4Addr::UNSPECIFIED.into(),
913        });
914        config.dns.nameservers = vec!["10.0.0.53".parse::<Nameserver>().unwrap()];
915        config.dns.rebind_protection = false;
916        config.trust_host_cas = true;
917        config.outbound_proxy = Some(crate::proxy::OutboundProxy::Socks5 {
918            address: "127.0.0.1:1080".parse().unwrap(),
919            credentials: None,
920        });
921        config.max_tcp_connections = Some(ConnectionLimit::from(257));
922        config.policy = NetworkPolicy::allow_all();
923        let mut resolved = resolved(config);
924
925        enforce_deployment_profile(&mut resolved, DeploymentProfile::MultiTenant);
926        let config = resolved.config();
927
928        assert!(config.interface.mac.is_none());
929        assert!(config.interface.mtu.is_none());
930        assert!(config.ports.is_empty());
931        assert!(config.dns.nameservers.is_empty());
932        assert!(config.dns.rebind_protection);
933        assert!(!config.trust_host_cas);
934        assert!(config.outbound_proxy.is_none());
935        assert_eq!(config.max_tcp_connections, Some(ConnectionLimit::from(257)));
936        assert!(resolved.config().outbound_proxy.is_none());
937        assert!(resolved.outbound_proxy().is_none());
938        // Tenant policy stays intact and is intersected with the platform
939        // policy at evaluation time instead of being reordered or flattened.
940        assert!(config.policy.default_egress.is_allow());
941    }
942
943    #[test]
944    fn deployment_profile_defaults_and_explicit_connection_limits() {
945        for profile in [
946            DeploymentProfile::SingleTenant,
947            DeploymentProfile::MultiTenant,
948        ] {
949            for requested in [None, Some(0), Some(64), Some(4096)] {
950                let config: NetworkConfig =
951                    serde_json::from_value(serde_json::json!({"max_tcp_connections": requested}))
952                        .unwrap();
953                let mut config = resolved(config);
954                // Exercise the serialized runtime launch boundary as well.
955                config = serde_json::from_value(serde_json::to_value(config).unwrap()).unwrap();
956                enforce_deployment_profile(&mut config, profile);
957                let expected = requested
958                    .or(match profile {
959                        DeploymentProfile::SingleTenant => None,
960                        DeploymentProfile::MultiTenant => Some(1024),
961                    })
962                    .and_then(NonZeroUsize::new);
963                assert_eq!(
964                    config
965                        .config()
966                        .max_tcp_connections
967                        .and_then(ConnectionLimit::cap),
968                    expected
969                );
970                // Applying the profile again must preserve the resolved value.
971                enforce_deployment_profile(&mut config, profile);
972                assert_eq!(
973                    config
974                        .config()
975                        .max_tcp_connections
976                        .and_then(ConnectionLimit::cap),
977                    expected
978                );
979            }
980        }
981    }
982
983    #[test]
984    fn deployment_profiles_resolve_udp_defaults_and_preserve_overrides() {
985        for profile in [
986            DeploymentProfile::SingleTenant,
987            DeploymentProfile::MultiTenant,
988        ] {
989            for requested in [None, Some(0), Some(7), Some(4096)] {
990                let config: NetworkConfig = serde_json::from_value(serde_json::json!({
991                    "max_udp_connections": requested
992                }))
993                .unwrap();
994                let mut config = resolved(config);
995                let expected = requested
996                    .or(match profile {
997                        DeploymentProfile::SingleTenant => None,
998                        DeploymentProfile::MultiTenant => Some(1024),
999                    })
1000                    .map(ConnectionLimit::from);
1001                enforce_deployment_profile(&mut config, profile);
1002                assert_eq!(config.config().max_udp_connections, expected);
1003                enforce_deployment_profile(&mut config, profile);
1004                assert_eq!(config.config().max_udp_connections, expected);
1005            }
1006        }
1007    }
1008
1009    #[test]
1010    fn single_tenant_profile_preserves_requested_network_controls() {
1011        let mut config = NetworkConfig::default();
1012        config.interface.mtu = Some(9000);
1013        config.dns.rebind_protection = false;
1014        config.trust_host_cas = true;
1015        config.outbound_proxy = Some(crate::proxy::OutboundProxy::Socks5 {
1016            address: "127.0.0.1:1080".parse().unwrap(),
1017            credentials: None,
1018        });
1019
1020        let mut resolved = resolved(config);
1021        enforce_deployment_profile(&mut resolved, DeploymentProfile::SingleTenant);
1022        let config = resolved.config();
1023
1024        assert_eq!(config.interface.mtu, Some(9000));
1025        assert!(!config.dns.rebind_protection);
1026        assert!(config.trust_host_cas);
1027        assert!(config.outbound_proxy.is_some());
1028    }
1029
1030    #[test]
1031    fn derive_addresses_slot_1() {
1032        assert_eq!(
1033            derive_guest_ipv4(default_guest_ipv4_pool(), 1).unwrap(),
1034            Ipv4Addr::new(172, 16, 0, 6)
1035        );
1036        assert_eq!(
1037            gateway_from_guest_ipv4(Ipv4Addr::new(172, 16, 0, 6)),
1038            Ipv4Addr::new(172, 16, 0, 5)
1039        );
1040    }
1041
1042    #[test]
1043    fn derive_addresses_max_slot() {
1044        assert_eq!(
1045            derive_guest_mac(u16::MAX),
1046            [0x02, 0x6d, 0x73, 0xff, 0xff, 0x02]
1047        );
1048        assert_eq!(
1049            derive_guest_ipv4(default_guest_ipv4_pool(), u16::MAX).unwrap(),
1050            Ipv4Addr::new(172, 19, 255, 254)
1051        );
1052        assert_eq!(
1053            derive_guest_ipv6(default_guest_ipv6_pool(), u16::MAX).unwrap(),
1054            "fd42:6d73:62:ffff::2".parse::<Ipv6Addr>().unwrap()
1055        );
1056    }
1057
1058    #[test]
1059    fn derive_addresses_custom_ipv4_pool() {
1060        let pool = "172.31.240.0/24".parse::<Ipv4Network>().unwrap();
1061        assert_eq!(
1062            derive_guest_ipv4(pool, 0).unwrap(),
1063            Ipv4Addr::new(172, 31, 240, 2)
1064        );
1065        assert_eq!(
1066            derive_guest_ipv4(pool, 63).unwrap(),
1067            Ipv4Addr::new(172, 31, 240, 254)
1068        );
1069    }
1070
1071    #[test]
1072    fn custom_ipv4_pool_capacity_is_a_typed_error() {
1073        let pool = "172.31.240.0/24".parse::<Ipv4Network>().unwrap();
1074        assert!(matches!(
1075            derive_guest_ipv4(pool, 64),
1076            Err(NetworkInitError::Ipv4PoolCapacity { slot: 64, .. })
1077        ));
1078
1079        let pool = "172.31.240.0/31".parse::<Ipv4Network>().unwrap();
1080        assert!(matches!(
1081            derive_guest_ipv4(pool, 0),
1082            Err(NetworkInitError::Ipv4PoolCapacity { slot: 0, .. })
1083        ));
1084    }
1085
1086    #[test]
1087    fn derive_ipv6_slot_0() {
1088        assert_eq!(
1089            derive_guest_ipv6(default_guest_ipv6_pool(), 0).unwrap(),
1090            "fd42:6d73:62:0::2".parse::<Ipv6Addr>().unwrap()
1091        );
1092        assert_eq!(
1093            gateway_from_guest_ipv6(derive_guest_ipv6(default_guest_ipv6_pool(), 0).unwrap()),
1094            "fd42:6d73:62:0::1".parse::<Ipv6Addr>().unwrap()
1095        );
1096    }
1097
1098    #[test]
1099    fn derive_addresses_custom_ipv6_pool() {
1100        let pool = "fd7a:115c:a1e0:100::/56".parse::<Ipv6Network>().unwrap();
1101        assert_eq!(
1102            derive_guest_ipv6(pool, 0).unwrap(),
1103            "fd7a:115c:a1e0:100::2".parse::<Ipv6Addr>().unwrap()
1104        );
1105        assert_eq!(
1106            derive_guest_ipv6(pool, 3).unwrap(),
1107            "fd7a:115c:a1e0:103::2".parse::<Ipv6Addr>().unwrap()
1108        );
1109    }
1110
1111    #[test]
1112    fn custom_ipv6_pool_capacity_is_a_typed_error() {
1113        let pool = "fd7a:115c:a1e0:100::/62".parse::<Ipv6Network>().unwrap();
1114        assert!(matches!(
1115            derive_guest_ipv6(pool, 4),
1116            Err(NetworkInitError::Ipv6PoolCapacity { slot: 4, .. })
1117        ));
1118
1119        let pool = "fd7a:115c:a1e0:100::/65".parse::<Ipv6Network>().unwrap();
1120        assert!(matches!(
1121            derive_guest_ipv6(pool, 0),
1122            Err(NetworkInitError::Ipv6PoolCapacity { slot: 0, .. })
1123        ));
1124    }
1125
1126    #[test]
1127    fn format_mac_address() {
1128        assert_eq!(
1129            format_mac([0x02, 0x6d, 0x73, 0x00, 0x00, 0x01]),
1130            "02:6d:73:00:00:01"
1131        );
1132    }
1133
1134    #[test]
1135    fn guest_env_vars_includes_ipv4_when_host_has_v4_route() {
1136        let net = SmoltcpNetwork::build(
1137            resolved(NetworkConfig::default()),
1138            0,
1139            DeploymentProfile::SingleTenant,
1140            routes(true, false),
1141        )
1142        .unwrap();
1143        let vars = net.guest_env_vars();
1144
1145        assert_eq!(vars.len(), 3);
1146        assert_eq!(vars[0].0, ENV_NET);
1147        assert!(vars[0].1.contains("iface=eth0"));
1148        assert_eq!(vars[1].0, ENV_HOST_ALIAS);
1149        assert_eq!(vars[1].1, crate::HOST_ALIAS);
1150        assert_eq!(vars[2].0, ENV_NET_IPV4);
1151        assert!(vars[2].1.contains("/30"));
1152    }
1153
1154    #[test]
1155    fn guest_env_vars_includes_ipv6_when_host_has_v6_route() {
1156        let net = SmoltcpNetwork::build(
1157            resolved(NetworkConfig::default()),
1158            0,
1159            DeploymentProfile::SingleTenant,
1160            routes(true, true),
1161        )
1162        .unwrap();
1163        let vars = net.guest_env_vars();
1164
1165        assert_eq!(vars.len(), 4);
1166        assert_eq!(vars[0].0, ENV_NET);
1167        assert_eq!(vars[1].0, ENV_HOST_ALIAS);
1168        assert_eq!(vars[2].0, ENV_NET_IPV4);
1169        assert_eq!(vars[3].0, ENV_NET_IPV6);
1170        assert!(vars[3].1.contains("/64"));
1171    }
1172
1173    #[test]
1174    fn guest_env_vars_omit_ipv6_without_host_route() {
1175        let net = SmoltcpNetwork::build(
1176            resolved(NetworkConfig::default()),
1177            0,
1178            DeploymentProfile::SingleTenant,
1179            routes(true, false),
1180        )
1181        .unwrap();
1182        let vars = net.guest_env_vars();
1183
1184        assert!(!vars.iter().any(|(k, _)| k == ENV_NET_IPV6));
1185    }
1186
1187    #[test]
1188    fn guest_env_vars_omit_ipv4_without_host_route() {
1189        let net = SmoltcpNetwork::build(
1190            resolved(NetworkConfig::default()),
1191            0,
1192            DeploymentProfile::SingleTenant,
1193            routes(false, true),
1194        )
1195        .unwrap();
1196        let vars = net.guest_env_vars();
1197
1198        assert_eq!(vars.len(), 3);
1199        assert_eq!(vars[0].0, ENV_NET);
1200        assert_eq!(vars[1].0, ENV_HOST_ALIAS);
1201        assert_eq!(vars[2].0, ENV_NET_IPV6);
1202    }
1203
1204    #[test]
1205    fn explicit_ipv6_address_overrides_missing_host_v6_route() {
1206        let mut config = NetworkConfig::default();
1207        config.interface.ipv6_address = Some("fd42:6d73:62:99::2".parse().unwrap());
1208        let net = SmoltcpNetwork::build(
1209            resolved(config),
1210            0,
1211            DeploymentProfile::SingleTenant,
1212            routes(true, false),
1213        )
1214        .unwrap();
1215        let vars = net.guest_env_vars();
1216
1217        let v6 = vars
1218            .iter()
1219            .find(|(k, _)| k == ENV_NET_IPV6)
1220            .expect("explicit ipv6 should publish env var even without host route");
1221        assert!(v6.1.contains("fd42:6d73:62:99::2/64"));
1222    }
1223
1224    #[test]
1225    fn published_ports_preserve_existing_address_families() {
1226        for (host_routes, explicit_address, profile, expected_ipv4, expected_ipv6) in [
1227            (
1228                routes(true, false),
1229                None,
1230                DeploymentProfile::SingleTenant,
1231                true,
1232                false,
1233            ),
1234            (
1235                routes(false, true),
1236                None,
1237                DeploymentProfile::SingleTenant,
1238                false,
1239                true,
1240            ),
1241            (
1242                routes(true, true),
1243                None,
1244                DeploymentProfile::SingleTenant,
1245                true,
1246                true,
1247            ),
1248            (
1249                routes(false, false),
1250                Some("fd42:6d73:62:99::2"),
1251                DeploymentProfile::SingleTenant,
1252                false,
1253                true,
1254            ),
1255            (
1256                routes(false, false),
1257                Some("172.20.0.2"),
1258                DeploymentProfile::SingleTenant,
1259                true,
1260                false,
1261            ),
1262            (
1263                routes(false, false),
1264                None,
1265                DeploymentProfile::MultiTenant,
1266                false,
1267                false,
1268            ),
1269        ] {
1270            let mut config = NetworkConfig::default();
1271            config.tls.enabled = false;
1272            let explicit_address = explicit_address.map(|address| address.parse().unwrap());
1273            match explicit_address {
1274                Some(std::net::IpAddr::V4(address)) => {
1275                    config.interface.ipv4_address = Some(address)
1276                }
1277                Some(std::net::IpAddr::V6(address)) => {
1278                    config.interface.ipv6_address = Some(address)
1279                }
1280                None => {}
1281            }
1282            config.ports.push(PublishedPort {
1283                host_port: 8080,
1284                guest_port: 8000,
1285                protocol: PortProtocol::Tcp,
1286                host_bind: Ipv4Addr::UNSPECIFIED.into(),
1287            });
1288            // Adding a missing UDP family must not switch an existing TCP target
1289            // from IPv6 to IPv4, or otherwise change an already-addressed guest.
1290            for host_bind in [Ipv4Addr::LOCALHOST.into(), Ipv6Addr::LOCALHOST.into()] {
1291                config.ports.push(PublishedPort {
1292                    host_port: 8081,
1293                    guest_port: 8001,
1294                    protocol: PortProtocol::Udp,
1295                    host_bind,
1296                });
1297            }
1298            let network = SmoltcpNetwork::build(resolved(config), 7, profile, host_routes).unwrap();
1299            let bootstrap = network.guest_bootstrap_network();
1300
1301            assert_eq!(bootstrap.ipv4.is_some(), expected_ipv4);
1302            assert_eq!(bootstrap.ipv6.is_some(), expected_ipv6);
1303            match explicit_address {
1304                Some(std::net::IpAddr::V4(address)) => {
1305                    assert_eq!(bootstrap.ipv4.unwrap().address, address);
1306                }
1307                Some(std::net::IpAddr::V6(address)) => {
1308                    assert_eq!(bootstrap.ipv6.unwrap().address, address);
1309                }
1310                None => {}
1311            }
1312        }
1313    }
1314
1315    #[test]
1316    fn neither_family_active_emits_only_base_env_vars() {
1317        let net = SmoltcpNetwork::build(
1318            resolved(NetworkConfig::default()),
1319            0,
1320            DeploymentProfile::SingleTenant,
1321            routes(false, false),
1322        )
1323        .unwrap();
1324        let vars = net.guest_env_vars();
1325
1326        assert_eq!(vars.len(), 2);
1327        assert_eq!(vars[0].0, ENV_NET);
1328        assert_eq!(vars[1].0, ENV_HOST_ALIAS);
1329    }
1330
1331    #[test]
1332    fn guest_bootstrap_network_preserves_active_address_families() {
1333        let net = SmoltcpNetwork::build(
1334            resolved(NetworkConfig::default()),
1335            7,
1336            DeploymentProfile::SingleTenant,
1337            routes(true, true),
1338        )
1339        .unwrap();
1340
1341        let bootstrap = net.guest_bootstrap_network();
1342
1343        assert_eq!(bootstrap.interface, "eth0");
1344        assert_eq!(bootstrap.mac, net.guest_mac());
1345        assert_eq!(bootstrap.mtu, 1500);
1346        assert_eq!(bootstrap.ipv4.unwrap().prefix_len, 30);
1347        assert_eq!(bootstrap.ipv6.unwrap().prefix_len, 64);
1348        assert_eq!(net.guest_host_alias(), crate::HOST_ALIAS);
1349    }
1350
1351    #[test]
1352    fn guest_bootstrap_network_allows_no_active_address_family() {
1353        let net = SmoltcpNetwork::build(
1354            resolved(NetworkConfig::default()),
1355            0,
1356            DeploymentProfile::SingleTenant,
1357            routes(false, false),
1358        )
1359        .unwrap();
1360
1361        let bootstrap = net.guest_bootstrap_network();
1362
1363        assert!(bootstrap.ipv4.is_none());
1364        assert!(bootstrap.ipv6.is_none());
1365    }
1366
1367    #[test]
1368    fn large_connection_cap_does_not_preallocate_or_prevent_startup() {
1369        for limit in [10000, usize::MAX] {
1370            let mut config = NetworkConfig::default();
1371            config.tls.enabled = false;
1372            config.max_tcp_connections = Some(ConnectionLimit::from(limit));
1373            let net = SmoltcpNetwork::build(
1374                resolved(config),
1375                0,
1376                DeploymentProfile::MultiTenant,
1377                routes(true, false),
1378            );
1379            assert!(net.is_ok(), "large explicit cap should allow startup");
1380        }
1381    }
1382
1383    /// A stored config bypasses the builder's validation, so an invalid
1384    /// limiter must fail startup cleanly instead of panicking.
1385    #[test]
1386    fn build_rejects_invalid_rate_limiter() {
1387        let mut config = NetworkConfig {
1388            rate_limiter: Some(microsandbox_types::NetworkRateLimiterConfig {
1389                egress: None,
1390                ingress: Some(microsandbox_types::RateLimiterConfig {
1391                    bandwidth: None,
1392                    ops: None,
1393                }),
1394            }),
1395            ..NetworkConfig::default()
1396        };
1397        config.tls.enabled = false;
1398
1399        let err = match SmoltcpNetwork::build(
1400            resolved(config),
1401            0,
1402            DeploymentProfile::SingleTenant,
1403            routes(true, false),
1404        ) {
1405            Ok(_) => panic!("empty rate limiter should fail"),
1406            Err(err) => err,
1407        };
1408
1409        assert!(matches!(
1410            err,
1411            NetworkInitError::InvalidRateLimit {
1412                direction: NetworkRateLimitDirection::Ingress,
1413                source: RateLimitConfigError::EmptyLimiter,
1414            }
1415        ));
1416    }
1417}