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 max_inbound_tcp_connections = config.max_inbound_tcp_connections;
409        let tcp_accept_queue_size = config.tcp_accept_queue_size.unwrap_or_default();
410        let secrets = self.secrets.clone();
411        let activation_gate = self.activation_gate.take();
412        let outbound_proxy = self.config.outbound_proxy().cloned().map(Arc::new);
413
414        self.poll_handle = Some(
415            std::thread::Builder::new()
416                .name("smoltcp-poll".into())
417                .spawn(move || {
418                    if let Some(gate) = activation_gate {
419                        gate.wait();
420                    }
421                    poll::smoltcp_poll_loop(
422                        shared,
423                        poll_config,
424                        network_policy,
425                        platform_policy,
426                        dns_config,
427                        tls_state,
428                        published_ports,
429                        strict,
430                        max_tcp_connections,
431                        max_udp_connections,
432                        tcp_accept_queue_size,
433                        max_inbound_tcp_connections,
434                        tokio_handle,
435                        secrets,
436                        outbound_proxy,
437                    );
438                })
439                .expect("failed to spawn smoltcp poll thread"),
440        );
441    }
442
443    /// Take the `NetBackend` for `VmBuilder::net()`. One-shot.
444    pub fn take_backend(&mut self) -> Box<dyn NetBackend + Send> {
445        Box::new(self.backend.take().expect("backend already taken"))
446    }
447
448    /// Guest MAC address for `VmBuilder::net().mac()`.
449    pub fn guest_mac(&self) -> [u8; 6] {
450        self.guest_mac
451    }
452
453    /// Generate `MSB_NET*` environment variables for the guest.
454    ///
455    /// The guest init (`agentd`) reads these to configure the network
456    /// interface via ioctls + netlink.
457    pub fn guest_env_vars(&self) -> Vec<(String, String)> {
458        let mut vars = vec![
459            (
460                ENV_NET.into(),
461                format!(
462                    "iface=eth0,mac={},mtu={}",
463                    format_mac(self.guest_mac),
464                    self.mtu,
465                ),
466            ),
467            (ENV_HOST_ALIAS.into(), crate::HOST_ALIAS.into()),
468        ];
469
470        if let (Some(guest), Some(gateway)) = (self.guest_ipv4, self.gateway_ipv4) {
471            vars.push((
472                ENV_NET_IPV4.into(),
473                format!("addr={guest}/30,gw={gateway},dns={gateway}"),
474            ));
475        }
476
477        if let (Some(guest), Some(gateway)) = (self.guest_ipv6, self.gateway_ipv6) {
478            vars.push((
479                ENV_NET_IPV6.into(),
480                format!("addr={guest}/64,gw={gateway},dns={gateway}"),
481            ));
482        }
483
484        // Auto-expose secret placeholders as environment variables.
485        for secret in &self.config.config().secrets.secrets {
486            vars.push((secret.env_var.clone(), secret.placeholder.clone()));
487        }
488
489        vars
490    }
491
492    /// Build the typed network payload consumed by agentd during bootstrap.
493    pub fn guest_bootstrap_network(&self) -> BootstrapNetwork {
494        BootstrapNetwork {
495            interface: "eth0".to_string(),
496            mac: self.guest_mac,
497            mtu: self.mtu,
498            ipv4: self
499                .guest_ipv4
500                .zip(self.gateway_ipv4)
501                .map(|(address, gateway)| BootstrapIpv4 {
502                    address,
503                    prefix_len: 30,
504                    gateway,
505                    dns: Some(gateway),
506                }),
507            ipv6: self
508                .guest_ipv6
509                .zip(self.gateway_ipv6)
510                .map(|(address, gateway)| BootstrapIpv6 {
511                    address,
512                    prefix_len: 64,
513                    gateway,
514                    dns: Some(gateway),
515                }),
516        }
517    }
518
519    /// Return the stable hostname used by guests to address the host gateway.
520    pub fn guest_host_alias(&self) -> &'static str {
521        crate::HOST_ALIAS
522    }
523
524    /// Return guest-visible secret placeholders for the baseline environment.
525    ///
526    /// Real secret values stay in the host-side network handler and never
527    /// enter this payload.
528    pub fn guest_secret_env(&self) -> Vec<BootstrapEnvVar> {
529        self.config
530            .config()
531            .secrets
532            .secrets
533            .iter()
534            .map(|secret| BootstrapEnvVar {
535                key: secret.env_var.clone(),
536                value: secret.placeholder.clone(),
537            })
538            .collect()
539    }
540
541    /// CA certificate PEM bytes if TLS interception is enabled.
542    ///
543    /// Write to the runtime mount before VM boot so the guest can trust it.
544    pub fn ca_cert_pem(&self) -> Option<Vec<u8>> {
545        self.tls_state.as_ref().map(|s| s.ca_cert_pem())
546    }
547
548    /// Host-trusted CA bundle to ship into the guest, if
549    /// [`crate::config::NetworkConfig::trust_host_cas`] is enabled.
550    ///
551    /// Returned PEM may concatenate CAs that the Mozilla root bundle in
552    /// the guest already trusts; duplicates are harmless and saved the
553    /// cost of computing a delta. Returns `None` when the host store is
554    /// empty or the feature is disabled.
555    pub fn host_cas_cert_pem(&self) -> Option<Vec<u8>> {
556        if !self.config.config().trust_host_cas {
557            return None;
558        }
559        crate::engine::tls::host_cas::collect_host_cas()
560    }
561
562    /// Create a handle for wiring runtime termination into the network stack.
563    pub fn termination_handle(&self) -> TerminationHandle {
564        TerminationHandle {
565            shared: self.shared.clone(),
566        }
567    }
568
569    /// Create a handle for reading aggregate network byte counters.
570    pub fn metrics_handle(&self) -> MetricsHandle {
571        MetricsHandle {
572            shared: self.shared.clone(),
573        }
574    }
575
576    /// Live-swappable view of the secrets configuration. The runtime control
577    /// socket uses it to apply secret rotation, removal, and allowed-host
578    /// updates without restarting the sandbox.
579    pub fn secrets_handle(&self) -> SecretsHandle {
580        self.secrets.clone()
581    }
582}
583
584impl NetworkActivationHandle {
585    /// Release a deferred network exactly once. Repeated calls are harmless.
586    pub fn activate(&self) {
587        let mut active = self.gate.active.lock().unwrap();
588        if !*active {
589            *active = true;
590            self.gate.changed.notify_all();
591        }
592    }
593}
594
595impl NetworkActivationGate {
596    fn wait(&self) {
597        let mut active = self.active.lock().unwrap();
598        while !*active {
599            active = self.changed.wait(active).unwrap();
600        }
601    }
602}
603
604impl TerminationHandle {
605    /// Install the termination hook.
606    pub fn set_hook(&self, hook: Arc<dyn Fn() + Send + Sync>) {
607        self.shared.set_termination_hook(hook);
608    }
609}
610
611impl MetricsHandle {
612    /// Total guest -> runtime bytes observed at the virtio-net boundary.
613    pub fn tx_bytes(&self) -> u64 {
614        self.shared.tx_bytes()
615    }
616
617    /// Total runtime -> guest bytes observed at the virtio-net boundary.
618    pub fn rx_bytes(&self) -> u64 {
619        self.shared.rx_bytes()
620    }
621}
622
623//--------------------------------------------------------------------------------------------------
624// Functions
625//--------------------------------------------------------------------------------------------------
626
627/// Apply the platform-owned configuration floor before network resources are created.
628///
629/// Policy rules are deliberately not flattened here. The poll loop evaluates
630/// the platform public-network policy and the tenant policy independently so a
631/// broad tenant allow can never outrank the platform floor, while a tenant deny
632/// still remains effective.
633fn enforce_deployment_profile(config: &mut ResolvedNetworkConfig, profile: DeploymentProfile) {
634    if profile == DeploymentProfile::SingleTenant {
635        return;
636    }
637
638    config.clear_outbound_proxy();
639
640    let config = config.config_mut();
641    config
642        .max_tcp_connections
643        .get_or_insert(ConnectionLimit::Limited(
644            DEFAULT_MULTI_TENANT_MAX_TCP_CONNECTIONS,
645        ));
646    config
647        .max_udp_connections
648        .get_or_insert(ConnectionLimit::Limited(
649            DEFAULT_MULTI_TENANT_MAX_UDP_CONNECTIONS,
650        ));
651    let interface_overridden = config.interface.mac.is_some()
652        || config.interface.mtu.is_some()
653        || config.interface.ipv4_address.is_some()
654        || config.interface.ipv4_pool.is_some()
655        || config.interface.ipv6_address.is_some()
656        || config.interface.ipv6_pool.is_some();
657    let had_published_ports = !config.ports.is_empty();
658    let had_custom_nameservers = !config.dns.nameservers.is_empty();
659    let disabled_rebind_protection = !config.dns.rebind_protection;
660    let trusted_host_cas = config.trust_host_cas;
661    let had_outbound_proxy = config.outbound_proxy.is_some();
662    config.interface = Default::default();
663    config.ports.clear();
664    config.dns.nameservers.clear();
665    config.dns.rebind_protection = true;
666    config.trust_host_cas = false;
667    if interface_overridden
668        || had_published_ports
669        || had_custom_nameservers
670        || disabled_rebind_protection
671        || trusted_host_cas
672        || had_outbound_proxy
673    {
674        tracing::warn!(
675            interface_overridden,
676            had_published_ports,
677            had_custom_nameservers,
678            disabled_rebind_protection,
679            trusted_host_cas,
680            had_outbound_proxy,
681            "multi-tenant deployment profile overrode unsafe network configuration"
682        );
683    }
684}
685
686/// Derive a guest MAC address from the sandbox slot.
687///
688/// Format: `02:ms:bx:SS:SS:02` where SS:SS encodes the slot.
689fn derive_guest_mac(slot: u16) -> [u8; 6] {
690    let s = slot.to_be_bytes();
691    [0x02, 0x6d, 0x73, s[0], s[1], 0x02]
692}
693
694/// Derive a gateway MAC address from the sandbox slot.
695///
696/// Format: `02:ms:bx:SS:SS:01`.
697fn derive_gateway_mac(slot: u16) -> [u8; 6] {
698    let s = slot.to_be_bytes();
699    [0x02, 0x6d, 0x73, s[0], s[1], 0x01]
700}
701
702/// Derive a guest IPv4 address from the sandbox slot.
703///
704/// Pool: `172.16.0.0/12` by default. Each slot gets a `/30` block (4 IPs).
705/// Guest is at offset +2 in the block.
706fn derive_guest_ipv4(pool: Ipv4Network, slot: u16) -> Result<Ipv4Addr, NetworkInitError> {
707    let capacity = 30_u8
708        .checked_sub(pool.prefix())
709        .map(|host_bits| 1_u32 << host_bits)
710        .ok_or(NetworkInitError::Ipv4PoolCapacity { pool, slot })?;
711    if u32::from(slot) >= capacity {
712        return Err(NetworkInitError::Ipv4PoolCapacity { pool, slot });
713    }
714
715    let base = u32::from(pool.network());
716    let offset = u32::from(slot) * 4 + 2; // +2 = guest within /30
717    Ok(Ipv4Addr::from(base + offset))
718}
719
720/// Gateway IPv4 from guest IPv4: guest - 1 (offset +1 in the /30 block).
721fn gateway_from_guest_ipv4(guest: Ipv4Addr) -> Ipv4Addr {
722    Ipv4Addr::from(u32::from(guest) - 1)
723}
724
725fn default_guest_ipv4_pool() -> Ipv4Network {
726    Ipv4Network::new(Ipv4Addr::new(172, 16, 0, 0), 12)
727        .expect("default IPv4 pool must be a valid network")
728}
729
730/// Derive a guest IPv6 address from the sandbox slot.
731///
732/// Pool: `fd42:6d73:62::/48`. Each slot gets a `/64` prefix.
733/// Guest is `::2` in its prefix.
734fn derive_guest_ipv6(pool: Ipv6Network, slot: u16) -> Result<Ipv6Addr, NetworkInitError> {
735    let capacity = 64_u8
736        .checked_sub(pool.prefix())
737        .map(|host_bits| 1_u128 << host_bits)
738        .ok_or(NetworkInitError::Ipv6PoolCapacity { pool, slot })?;
739    if u128::from(slot) >= capacity {
740        return Err(NetworkInitError::Ipv6PoolCapacity { pool, slot });
741    }
742
743    let base = u128::from(pool.network());
744    let offset = u128::from(slot) << 64;
745    Ok(Ipv6Addr::from(base + offset + 2))
746}
747
748/// Gateway IPv6 from guest IPv6: `::1` in the same prefix.
749fn gateway_from_guest_ipv6(guest: Ipv6Addr) -> Ipv6Addr {
750    let segs = guest.segments();
751    Ipv6Addr::new(segs[0], segs[1], segs[2], segs[3], 0, 0, 0, 1)
752}
753
754fn default_guest_ipv6_pool() -> Ipv6Network {
755    Ipv6Network::new(Ipv6Addr::new(0xfd42, 0x6d73, 0x0062, 0, 0, 0, 0, 0), 48)
756        .expect("default IPv6 pool must be a valid network")
757}
758
759/// Format a MAC address as `xx:xx:xx:xx:xx:xx`.
760fn format_mac(mac: [u8; 6]) -> String {
761    format!(
762        "{:02x}:{:02x}:{:02x}:{:02x}:{:02x}:{:02x}",
763        mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]
764    )
765}
766
767/// Returns true if the host kernel can select an IPv4 route.
768///
769/// `UdpSocket::connect` performs a local routing-table lookup against the
770/// TEST-NET-1 (`192.0.2.1`) address; it does not send packets or wait on
771/// the network.
772fn host_has_ipv4_route() -> bool {
773    UdpSocket::bind((Ipv4Addr::UNSPECIFIED, 0))
774        .and_then(|socket| socket.connect((Ipv4Addr::new(192, 0, 2, 1), 443)))
775        .is_ok()
776}
777
778/// Returns true if the host kernel can select an IPv6 route. Probes a
779/// `2001:db8::/32` documentation address via `UdpSocket::connect` (no packet
780/// is sent).
781fn host_has_ipv6_route() -> bool {
782    UdpSocket::bind((Ipv6Addr::UNSPECIFIED, 0))
783        .and_then(|socket| socket.connect((Ipv6Addr::new(0x2001, 0x0db8, 0, 0, 0, 0, 0, 1), 443)))
784        .is_ok()
785}
786
787//--------------------------------------------------------------------------------------------------
788// Tests
789//--------------------------------------------------------------------------------------------------
790
791#[cfg(test)]
792#[path = "network/test_support.rs"]
793mod test_support;
794
795#[cfg(test)]
796#[path = "network/tcp_connect_tests.rs"]
797mod tcp_connect_tests;
798
799#[cfg(test)]
800#[path = "network/published_ports_tests.rs"]
801mod published_ports_tests;
802
803#[cfg(test)]
804mod tests {
805    use super::*;
806    use crate::config::{EnvNetworkSecretResolver, NetworkConfig, PortProtocol, PublishedPort};
807    use crate::dns::Nameserver;
808
809    fn resolved(config: NetworkConfig) -> ResolvedNetworkConfig {
810        config.resolve(&EnvNetworkSecretResolver).unwrap()
811    }
812
813    fn routes(ipv4: bool, ipv6: bool) -> HostRoutes {
814        HostRoutes { ipv4, ipv6 }
815    }
816
817    #[test]
818    fn captured_gateway_survives_new_slots_without_sharing_backends() {
819        let mut source_config = NetworkConfig::default();
820        // A user-supplied guest MAC cannot reveal the source gateway's slot.
821        source_config.interface.mac = Some([2, 0xaa, 0xbb, 0xcc, 0xdd, 0xee]);
822        let source = SmoltcpNetwork::build(
823            resolved(source_config),
824            7,
825            DeploymentProfile::SingleTenant,
826            routes(true, true),
827        )
828        .unwrap();
829        let mut config = NetworkConfig::default();
830        config.interface.mac = Some(source.guest_mac);
831        config.interface.ipv4_address = source.guest_ipv4;
832        config.interface.ipv6_address = source.guest_ipv6;
833        for slot in [8, 400] {
834            let child = SmoltcpNetwork::build(
835                resolved(config.clone()),
836                slot,
837                DeploymentProfile::SingleTenant,
838                routes(true, true),
839            )
840            .unwrap()
841            .with_captured_gateway_mac(source.gateway_mac)
842            .unwrap();
843            assert_eq!(child.gateway_mac, source.gateway_mac);
844            assert_eq!(child.gateway_ipv4, source.gateway_ipv4);
845            assert_eq!(child.gateway_ipv6, source.gateway_ipv6);
846            assert_eq!(child.guest_mac, source.guest_mac);
847            assert!(!Arc::ptr_eq(&child.shared, &source.shared));
848        }
849        assert_eq!(source.gateway_mac, SmoltcpNetwork::default_gateway_mac(7));
850    }
851
852    #[test]
853    fn captured_gateway_rejects_unusable_ethernet_addresses() {
854        for mac in [[0; 6], [1, 2, 3, 4, 5, 6], derive_guest_mac(8)] {
855            let network = SmoltcpNetwork::build(
856                resolved(NetworkConfig::default()),
857                8,
858                DeploymentProfile::SingleTenant,
859                routes(true, true),
860            )
861            .unwrap();
862            assert!(matches!(
863                network.with_captured_gateway_mac(mac),
864                Err(NetworkInitError::InvalidGatewayMac)
865            ));
866        }
867    }
868
869    #[test]
870    fn deferred_activation_blocks_until_released() {
871        let mut network = SmoltcpNetwork::build(
872            resolved(NetworkConfig::default()),
873            0,
874            DeploymentProfile::SingleTenant,
875            routes(true, false),
876        )
877        .unwrap();
878        let handle = network.defer_activation();
879        let gate = Arc::clone(network.activation_gate.as_ref().unwrap());
880        let (released_tx, released_rx) = std::sync::mpsc::channel();
881        let waiter = std::thread::spawn(move || {
882            gate.wait();
883            released_tx.send(()).unwrap();
884        });
885
886        assert!(
887            released_rx
888                .recv_timeout(std::time::Duration::from_millis(25))
889                .is_err(),
890            "deferred network became active before explicit release"
891        );
892        handle.activate();
893        released_rx
894            .recv_timeout(std::time::Duration::from_secs(1))
895            .unwrap();
896        waiter.join().unwrap();
897    }
898
899    #[test]
900    fn derive_addresses_slot_0() {
901        assert_eq!(derive_guest_mac(0), [0x02, 0x6d, 0x73, 0x00, 0x00, 0x02]);
902        assert_eq!(derive_gateway_mac(0), [0x02, 0x6d, 0x73, 0x00, 0x00, 0x01]);
903        assert_eq!(
904            derive_guest_ipv4(default_guest_ipv4_pool(), 0).unwrap(),
905            Ipv4Addr::new(172, 16, 0, 2)
906        );
907        assert_eq!(
908            gateway_from_guest_ipv4(Ipv4Addr::new(172, 16, 0, 2)),
909            Ipv4Addr::new(172, 16, 0, 1)
910        );
911    }
912
913    #[test]
914    fn multi_tenant_profile_sanitizes_host_owned_network_controls() {
915        let mut config = NetworkConfig::default();
916        config.interface.mac = Some([2, 3, 4, 5, 6, 7]);
917        config.interface.mtu = Some(9000);
918        config.ports.push(PublishedPort {
919            host_port: 8080,
920            guest_port: 80,
921            protocol: PortProtocol::Tcp,
922            host_bind: Ipv4Addr::UNSPECIFIED.into(),
923        });
924        config.dns.nameservers = vec!["10.0.0.53".parse::<Nameserver>().unwrap()];
925        config.dns.rebind_protection = false;
926        config.trust_host_cas = true;
927        config.outbound_proxy = Some(crate::proxy::OutboundProxy::Socks5 {
928            address: "127.0.0.1:1080".parse().unwrap(),
929            credentials: None,
930        });
931        config.max_tcp_connections = Some(ConnectionLimit::from(257));
932        config.policy = NetworkPolicy::allow_all();
933        let mut resolved = resolved(config);
934
935        enforce_deployment_profile(&mut resolved, DeploymentProfile::MultiTenant);
936        let config = resolved.config();
937
938        assert!(config.interface.mac.is_none());
939        assert!(config.interface.mtu.is_none());
940        assert!(config.ports.is_empty());
941        assert!(config.dns.nameservers.is_empty());
942        assert!(config.dns.rebind_protection);
943        assert!(!config.trust_host_cas);
944        assert!(config.outbound_proxy.is_none());
945        assert_eq!(config.max_tcp_connections, Some(ConnectionLimit::from(257)));
946        assert!(resolved.config().outbound_proxy.is_none());
947        assert!(resolved.outbound_proxy().is_none());
948        // Tenant policy stays intact and is intersected with the platform
949        // policy at evaluation time instead of being reordered or flattened.
950        assert!(config.policy.default_egress.is_allow());
951    }
952
953    #[test]
954    fn deployment_profile_defaults_and_explicit_connection_limits() {
955        for profile in [
956            DeploymentProfile::SingleTenant,
957            DeploymentProfile::MultiTenant,
958        ] {
959            for requested in [None, Some(0), Some(64), Some(4096)] {
960                let config: NetworkConfig =
961                    serde_json::from_value(serde_json::json!({"max_tcp_connections": requested}))
962                        .unwrap();
963                let mut config = resolved(config);
964                // Exercise the serialized runtime launch boundary as well.
965                config = serde_json::from_value(serde_json::to_value(config).unwrap()).unwrap();
966                enforce_deployment_profile(&mut config, profile);
967                let expected = requested
968                    .or(match profile {
969                        DeploymentProfile::SingleTenant => None,
970                        DeploymentProfile::MultiTenant => Some(1024),
971                    })
972                    .and_then(NonZeroUsize::new);
973                assert_eq!(
974                    config
975                        .config()
976                        .max_tcp_connections
977                        .and_then(ConnectionLimit::cap),
978                    expected
979                );
980                // Applying the profile again must preserve the resolved value.
981                enforce_deployment_profile(&mut config, profile);
982                assert_eq!(
983                    config
984                        .config()
985                        .max_tcp_connections
986                        .and_then(ConnectionLimit::cap),
987                    expected
988                );
989            }
990        }
991    }
992
993    #[test]
994    fn deployment_profiles_resolve_udp_defaults_and_preserve_overrides() {
995        for profile in [
996            DeploymentProfile::SingleTenant,
997            DeploymentProfile::MultiTenant,
998        ] {
999            for requested in [None, Some(0), Some(7), Some(4096)] {
1000                let config: NetworkConfig = serde_json::from_value(serde_json::json!({
1001                    "max_udp_connections": requested
1002                }))
1003                .unwrap();
1004                let mut config = resolved(config);
1005                let expected = requested
1006                    .or(match profile {
1007                        DeploymentProfile::SingleTenant => None,
1008                        DeploymentProfile::MultiTenant => Some(1024),
1009                    })
1010                    .map(ConnectionLimit::from);
1011                enforce_deployment_profile(&mut config, profile);
1012                assert_eq!(config.config().max_udp_connections, expected);
1013                enforce_deployment_profile(&mut config, profile);
1014                assert_eq!(config.config().max_udp_connections, expected);
1015            }
1016        }
1017    }
1018
1019    #[test]
1020    fn single_tenant_profile_preserves_requested_network_controls() {
1021        let mut config = NetworkConfig::default();
1022        config.interface.mtu = Some(9000);
1023        config.dns.rebind_protection = false;
1024        config.trust_host_cas = true;
1025        config.outbound_proxy = Some(crate::proxy::OutboundProxy::Socks5 {
1026            address: "127.0.0.1:1080".parse().unwrap(),
1027            credentials: None,
1028        });
1029
1030        let mut resolved = resolved(config);
1031        enforce_deployment_profile(&mut resolved, DeploymentProfile::SingleTenant);
1032        let config = resolved.config();
1033
1034        assert_eq!(config.interface.mtu, Some(9000));
1035        assert!(!config.dns.rebind_protection);
1036        assert!(config.trust_host_cas);
1037        assert!(config.outbound_proxy.is_some());
1038    }
1039
1040    #[test]
1041    fn derive_addresses_slot_1() {
1042        assert_eq!(
1043            derive_guest_ipv4(default_guest_ipv4_pool(), 1).unwrap(),
1044            Ipv4Addr::new(172, 16, 0, 6)
1045        );
1046        assert_eq!(
1047            gateway_from_guest_ipv4(Ipv4Addr::new(172, 16, 0, 6)),
1048            Ipv4Addr::new(172, 16, 0, 5)
1049        );
1050    }
1051
1052    #[test]
1053    fn derive_addresses_max_slot() {
1054        assert_eq!(
1055            derive_guest_mac(u16::MAX),
1056            [0x02, 0x6d, 0x73, 0xff, 0xff, 0x02]
1057        );
1058        assert_eq!(
1059            derive_guest_ipv4(default_guest_ipv4_pool(), u16::MAX).unwrap(),
1060            Ipv4Addr::new(172, 19, 255, 254)
1061        );
1062        assert_eq!(
1063            derive_guest_ipv6(default_guest_ipv6_pool(), u16::MAX).unwrap(),
1064            "fd42:6d73:62:ffff::2".parse::<Ipv6Addr>().unwrap()
1065        );
1066    }
1067
1068    #[test]
1069    fn derive_addresses_custom_ipv4_pool() {
1070        let pool = "172.31.240.0/24".parse::<Ipv4Network>().unwrap();
1071        assert_eq!(
1072            derive_guest_ipv4(pool, 0).unwrap(),
1073            Ipv4Addr::new(172, 31, 240, 2)
1074        );
1075        assert_eq!(
1076            derive_guest_ipv4(pool, 63).unwrap(),
1077            Ipv4Addr::new(172, 31, 240, 254)
1078        );
1079    }
1080
1081    #[test]
1082    fn custom_ipv4_pool_capacity_is_a_typed_error() {
1083        let pool = "172.31.240.0/24".parse::<Ipv4Network>().unwrap();
1084        assert!(matches!(
1085            derive_guest_ipv4(pool, 64),
1086            Err(NetworkInitError::Ipv4PoolCapacity { slot: 64, .. })
1087        ));
1088
1089        let pool = "172.31.240.0/31".parse::<Ipv4Network>().unwrap();
1090        assert!(matches!(
1091            derive_guest_ipv4(pool, 0),
1092            Err(NetworkInitError::Ipv4PoolCapacity { slot: 0, .. })
1093        ));
1094    }
1095
1096    #[test]
1097    fn derive_ipv6_slot_0() {
1098        assert_eq!(
1099            derive_guest_ipv6(default_guest_ipv6_pool(), 0).unwrap(),
1100            "fd42:6d73:62:0::2".parse::<Ipv6Addr>().unwrap()
1101        );
1102        assert_eq!(
1103            gateway_from_guest_ipv6(derive_guest_ipv6(default_guest_ipv6_pool(), 0).unwrap()),
1104            "fd42:6d73:62:0::1".parse::<Ipv6Addr>().unwrap()
1105        );
1106    }
1107
1108    #[test]
1109    fn derive_addresses_custom_ipv6_pool() {
1110        let pool = "fd7a:115c:a1e0:100::/56".parse::<Ipv6Network>().unwrap();
1111        assert_eq!(
1112            derive_guest_ipv6(pool, 0).unwrap(),
1113            "fd7a:115c:a1e0:100::2".parse::<Ipv6Addr>().unwrap()
1114        );
1115        assert_eq!(
1116            derive_guest_ipv6(pool, 3).unwrap(),
1117            "fd7a:115c:a1e0:103::2".parse::<Ipv6Addr>().unwrap()
1118        );
1119    }
1120
1121    #[test]
1122    fn custom_ipv6_pool_capacity_is_a_typed_error() {
1123        let pool = "fd7a:115c:a1e0:100::/62".parse::<Ipv6Network>().unwrap();
1124        assert!(matches!(
1125            derive_guest_ipv6(pool, 4),
1126            Err(NetworkInitError::Ipv6PoolCapacity { slot: 4, .. })
1127        ));
1128
1129        let pool = "fd7a:115c:a1e0:100::/65".parse::<Ipv6Network>().unwrap();
1130        assert!(matches!(
1131            derive_guest_ipv6(pool, 0),
1132            Err(NetworkInitError::Ipv6PoolCapacity { slot: 0, .. })
1133        ));
1134    }
1135
1136    #[test]
1137    fn format_mac_address() {
1138        assert_eq!(
1139            format_mac([0x02, 0x6d, 0x73, 0x00, 0x00, 0x01]),
1140            "02:6d:73:00:00:01"
1141        );
1142    }
1143
1144    #[test]
1145    fn guest_env_vars_includes_ipv4_when_host_has_v4_route() {
1146        let net = SmoltcpNetwork::build(
1147            resolved(NetworkConfig::default()),
1148            0,
1149            DeploymentProfile::SingleTenant,
1150            routes(true, false),
1151        )
1152        .unwrap();
1153        let vars = net.guest_env_vars();
1154
1155        assert_eq!(vars.len(), 3);
1156        assert_eq!(vars[0].0, ENV_NET);
1157        assert!(vars[0].1.contains("iface=eth0"));
1158        assert_eq!(vars[1].0, ENV_HOST_ALIAS);
1159        assert_eq!(vars[1].1, crate::HOST_ALIAS);
1160        assert_eq!(vars[2].0, ENV_NET_IPV4);
1161        assert!(vars[2].1.contains("/30"));
1162    }
1163
1164    #[test]
1165    fn guest_env_vars_includes_ipv6_when_host_has_v6_route() {
1166        let net = SmoltcpNetwork::build(
1167            resolved(NetworkConfig::default()),
1168            0,
1169            DeploymentProfile::SingleTenant,
1170            routes(true, true),
1171        )
1172        .unwrap();
1173        let vars = net.guest_env_vars();
1174
1175        assert_eq!(vars.len(), 4);
1176        assert_eq!(vars[0].0, ENV_NET);
1177        assert_eq!(vars[1].0, ENV_HOST_ALIAS);
1178        assert_eq!(vars[2].0, ENV_NET_IPV4);
1179        assert_eq!(vars[3].0, ENV_NET_IPV6);
1180        assert!(vars[3].1.contains("/64"));
1181    }
1182
1183    #[test]
1184    fn guest_env_vars_omit_ipv6_without_host_route() {
1185        let net = SmoltcpNetwork::build(
1186            resolved(NetworkConfig::default()),
1187            0,
1188            DeploymentProfile::SingleTenant,
1189            routes(true, false),
1190        )
1191        .unwrap();
1192        let vars = net.guest_env_vars();
1193
1194        assert!(!vars.iter().any(|(k, _)| k == ENV_NET_IPV6));
1195    }
1196
1197    #[test]
1198    fn guest_env_vars_omit_ipv4_without_host_route() {
1199        let net = SmoltcpNetwork::build(
1200            resolved(NetworkConfig::default()),
1201            0,
1202            DeploymentProfile::SingleTenant,
1203            routes(false, true),
1204        )
1205        .unwrap();
1206        let vars = net.guest_env_vars();
1207
1208        assert_eq!(vars.len(), 3);
1209        assert_eq!(vars[0].0, ENV_NET);
1210        assert_eq!(vars[1].0, ENV_HOST_ALIAS);
1211        assert_eq!(vars[2].0, ENV_NET_IPV6);
1212    }
1213
1214    #[test]
1215    fn explicit_ipv6_address_overrides_missing_host_v6_route() {
1216        let mut config = NetworkConfig::default();
1217        config.interface.ipv6_address = Some("fd42:6d73:62:99::2".parse().unwrap());
1218        let net = SmoltcpNetwork::build(
1219            resolved(config),
1220            0,
1221            DeploymentProfile::SingleTenant,
1222            routes(true, false),
1223        )
1224        .unwrap();
1225        let vars = net.guest_env_vars();
1226
1227        let v6 = vars
1228            .iter()
1229            .find(|(k, _)| k == ENV_NET_IPV6)
1230            .expect("explicit ipv6 should publish env var even without host route");
1231        assert!(v6.1.contains("fd42:6d73:62:99::2/64"));
1232    }
1233
1234    #[test]
1235    fn published_ports_preserve_existing_address_families() {
1236        for (host_routes, explicit_address, profile, expected_ipv4, expected_ipv6) in [
1237            (
1238                routes(true, false),
1239                None,
1240                DeploymentProfile::SingleTenant,
1241                true,
1242                false,
1243            ),
1244            (
1245                routes(false, true),
1246                None,
1247                DeploymentProfile::SingleTenant,
1248                false,
1249                true,
1250            ),
1251            (
1252                routes(true, true),
1253                None,
1254                DeploymentProfile::SingleTenant,
1255                true,
1256                true,
1257            ),
1258            (
1259                routes(false, false),
1260                Some("fd42:6d73:62:99::2"),
1261                DeploymentProfile::SingleTenant,
1262                false,
1263                true,
1264            ),
1265            (
1266                routes(false, false),
1267                Some("172.20.0.2"),
1268                DeploymentProfile::SingleTenant,
1269                true,
1270                false,
1271            ),
1272            (
1273                routes(false, false),
1274                None,
1275                DeploymentProfile::MultiTenant,
1276                false,
1277                false,
1278            ),
1279        ] {
1280            let mut config = NetworkConfig::default();
1281            config.tls.enabled = false;
1282            let explicit_address = explicit_address.map(|address| address.parse().unwrap());
1283            match explicit_address {
1284                Some(std::net::IpAddr::V4(address)) => {
1285                    config.interface.ipv4_address = Some(address)
1286                }
1287                Some(std::net::IpAddr::V6(address)) => {
1288                    config.interface.ipv6_address = Some(address)
1289                }
1290                None => {}
1291            }
1292            config.ports.push(PublishedPort {
1293                host_port: 8080,
1294                guest_port: 8000,
1295                protocol: PortProtocol::Tcp,
1296                host_bind: Ipv4Addr::UNSPECIFIED.into(),
1297            });
1298            // Adding a missing UDP family must not switch an existing TCP target
1299            // from IPv6 to IPv4, or otherwise change an already-addressed guest.
1300            for host_bind in [Ipv4Addr::LOCALHOST.into(), Ipv6Addr::LOCALHOST.into()] {
1301                config.ports.push(PublishedPort {
1302                    host_port: 8081,
1303                    guest_port: 8001,
1304                    protocol: PortProtocol::Udp,
1305                    host_bind,
1306                });
1307            }
1308            let network = SmoltcpNetwork::build(resolved(config), 7, profile, host_routes).unwrap();
1309            let bootstrap = network.guest_bootstrap_network();
1310
1311            assert_eq!(bootstrap.ipv4.is_some(), expected_ipv4);
1312            assert_eq!(bootstrap.ipv6.is_some(), expected_ipv6);
1313            match explicit_address {
1314                Some(std::net::IpAddr::V4(address)) => {
1315                    assert_eq!(bootstrap.ipv4.unwrap().address, address);
1316                }
1317                Some(std::net::IpAddr::V6(address)) => {
1318                    assert_eq!(bootstrap.ipv6.unwrap().address, address);
1319                }
1320                None => {}
1321            }
1322        }
1323    }
1324
1325    #[test]
1326    fn neither_family_active_emits_only_base_env_vars() {
1327        let net = SmoltcpNetwork::build(
1328            resolved(NetworkConfig::default()),
1329            0,
1330            DeploymentProfile::SingleTenant,
1331            routes(false, false),
1332        )
1333        .unwrap();
1334        let vars = net.guest_env_vars();
1335
1336        assert_eq!(vars.len(), 2);
1337        assert_eq!(vars[0].0, ENV_NET);
1338        assert_eq!(vars[1].0, ENV_HOST_ALIAS);
1339    }
1340
1341    #[test]
1342    fn guest_bootstrap_network_preserves_active_address_families() {
1343        let net = SmoltcpNetwork::build(
1344            resolved(NetworkConfig::default()),
1345            7,
1346            DeploymentProfile::SingleTenant,
1347            routes(true, true),
1348        )
1349        .unwrap();
1350
1351        let bootstrap = net.guest_bootstrap_network();
1352
1353        assert_eq!(bootstrap.interface, "eth0");
1354        assert_eq!(bootstrap.mac, net.guest_mac());
1355        assert_eq!(bootstrap.mtu, 1500);
1356        assert_eq!(bootstrap.ipv4.unwrap().prefix_len, 30);
1357        assert_eq!(bootstrap.ipv6.unwrap().prefix_len, 64);
1358        assert_eq!(net.guest_host_alias(), crate::HOST_ALIAS);
1359    }
1360
1361    #[test]
1362    fn guest_bootstrap_network_allows_no_active_address_family() {
1363        let net = SmoltcpNetwork::build(
1364            resolved(NetworkConfig::default()),
1365            0,
1366            DeploymentProfile::SingleTenant,
1367            routes(false, false),
1368        )
1369        .unwrap();
1370
1371        let bootstrap = net.guest_bootstrap_network();
1372
1373        assert!(bootstrap.ipv4.is_none());
1374        assert!(bootstrap.ipv6.is_none());
1375    }
1376
1377    #[test]
1378    fn large_connection_cap_does_not_preallocate_or_prevent_startup() {
1379        for limit in [10000, usize::MAX] {
1380            let mut config = NetworkConfig::default();
1381            config.tls.enabled = false;
1382            config.max_tcp_connections = Some(ConnectionLimit::from(limit));
1383            let net = SmoltcpNetwork::build(
1384                resolved(config),
1385                0,
1386                DeploymentProfile::MultiTenant,
1387                routes(true, false),
1388            );
1389            assert!(net.is_ok(), "large explicit cap should allow startup");
1390        }
1391    }
1392
1393    /// A stored config bypasses the builder's validation, so an invalid
1394    /// limiter must fail startup cleanly instead of panicking.
1395    #[test]
1396    fn build_rejects_invalid_rate_limiter() {
1397        let mut config = NetworkConfig {
1398            rate_limiter: Some(microsandbox_types::NetworkRateLimiterConfig {
1399                egress: None,
1400                ingress: Some(microsandbox_types::RateLimiterConfig {
1401                    bandwidth: None,
1402                    ops: None,
1403                }),
1404            }),
1405            ..NetworkConfig::default()
1406        };
1407        config.tls.enabled = false;
1408
1409        let err = match SmoltcpNetwork::build(
1410            resolved(config),
1411            0,
1412            DeploymentProfile::SingleTenant,
1413            routes(true, false),
1414        ) {
1415            Ok(_) => panic!("empty rate limiter should fail"),
1416            Err(err) => err,
1417        };
1418
1419        assert!(matches!(
1420            err,
1421            NetworkInitError::InvalidRateLimit {
1422                direction: NetworkRateLimitDirection::Ingress,
1423                source: RateLimitConfigError::EmptyLimiter,
1424            }
1425        ));
1426    }
1427}