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, 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, or the host has a route for 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        let guest_ipv4 = match config.interface.ipv4_address {
234            Some(address) => Some(address),
235            None if host_routes.ipv4 => Some(derive_guest_ipv4(
236                config
237                    .interface
238                    .ipv4_pool
239                    .unwrap_or_else(default_guest_ipv4_pool),
240                slot,
241            )?),
242            None => None,
243        };
244        let gateway_ipv4 = guest_ipv4.map(gateway_from_guest_ipv4);
245        let guest_ipv6 = match config.interface.ipv6_address {
246            Some(address) => Some(address),
247            None if host_routes.ipv6 => Some(derive_guest_ipv6(
248                config
249                    .interface
250                    .ipv6_pool
251                    .unwrap_or_else(default_guest_ipv6_pool),
252                slot,
253            )?),
254            None => None,
255        };
256        let gateway_ipv6 = guest_ipv6.map(gateway_from_guest_ipv6);
257
258        // Packet queue capacity is independent of the optional connection cap:
259        // a large cap must not allocate a correspondingly large packet queue.
260        let shared = Arc::new(SharedState::new(DEFAULT_QUEUE_CAPACITY));
261        shared.set_http_config(config.http.clone());
262        if let Some(prefix) = config
263            .nat64_prefixes
264            .iter()
265            .find(|prefix| prefix.prefix() != 96)
266        {
267            return Err(NetworkInitError::InvalidNat64Prefix {
268                raw: prefix.to_string(),
269            });
270        }
271        shared.set_nat64_prefixes(config.nat64_prefixes.clone());
272        // Every write path validates rate limiters (`NetworkBuilder::build`),
273        // but a stored config bypasses the builder: fail startup cleanly
274        // instead of panicking on a corrupted spec.
275        if config.rate_limiter.as_ref().is_some_and(|rate_limiter| {
276            rate_limiter.egress.is_none() && rate_limiter.ingress.is_none()
277        }) {
278            return Err(NetworkInitError::EmptyNetworkRateLimiter);
279        }
280        config
281            .rate_limiter
282            .as_ref()
283            .and_then(|rate_limiter| rate_limiter.ingress.as_ref())
284            .map(RateLimiterConfig::validate)
285            .transpose()
286            .map_err(|source| NetworkInitError::InvalidRateLimit {
287                direction: NetworkRateLimitDirection::Ingress,
288                source,
289            })?;
290        config
291            .rate_limiter
292            .as_ref()
293            .and_then(|rate_limiter| rate_limiter.egress.as_ref())
294            .map(RateLimiterConfig::validate)
295            .transpose()
296            .map_err(|source| NetworkInitError::InvalidRateLimit {
297                direction: NetworkRateLimitDirection::Egress,
298                source,
299            })?;
300        let backend = SmoltcpBackend::new(shared.clone());
301
302        let secrets = SecretsHandle::new(config.secrets.clone());
303        let tls_state = if config.tls.enabled {
304            Some(Arc::new(TlsState::new(
305                config.tls.clone(),
306                secrets.clone(),
307            )?))
308        } else {
309            None
310        };
311
312        Ok(Self {
313            config: resolved_config,
314            platform_policy,
315            shared,
316            backend: Some(backend),
317            poll_handle: None,
318            activation_gate: None,
319            guest_mac,
320            gateway_mac,
321            mtu,
322            guest_ipv4,
323            gateway_ipv4,
324            guest_ipv6,
325            gateway_ipv6,
326            tls_state,
327            secrets,
328        })
329    }
330
331    /// Hold network processing and published-port creation behind an explicit one-shot gate.
332    ///
333    /// This must be selected before [`start`](Self::start). It is used by checkpoint restore so
334    /// ingress cannot reach the child until guest activation has completed. The gate is consumed
335    /// once at poll-thread startup and adds no checks to steady-state packet processing.
336    pub fn defer_activation(&mut self) -> NetworkActivationHandle {
337        assert!(
338            self.poll_handle.is_none(),
339            "network activation can only be deferred before start"
340        );
341        let gate = Arc::new(NetworkActivationGate {
342            active: Mutex::new(false),
343            changed: Condvar::new(),
344        });
345        self.activation_gate = Some(Arc::clone(&gate));
346        NetworkActivationHandle { gate }
347    }
348
349    fn platform_policy(deployment_profile: DeploymentProfile) -> Option<NetworkPolicy> {
350        match deployment_profile {
351            DeploymentProfile::SingleTenant => None,
352            DeploymentProfile::MultiTenant => {
353                Some(NetworkPolicy::from_profiles([NetworkProfile::Public]))
354            }
355        }
356    }
357
358    /// Get the gateway IPs for virtio-net configuration and domain-based policy rules.
359    fn gateway_ips(&self) -> GatewayIps {
360        GatewayIps {
361            ipv4: self.gateway_ipv4,
362            ipv6: self.gateway_ipv6,
363        }
364    }
365
366    /// Start the smoltcp poll thread.
367    ///
368    /// Must be called before VM boot. Requires a tokio runtime handle for
369    /// spawning proxy tasks, DNS resolution, and published port listeners.
370    pub fn start(&mut self, tokio_handle: tokio::runtime::Handle) {
371        let shared = self.shared.clone();
372        let poll_config = PollLoopConfig {
373            gateway_mac: self.gateway_mac,
374            guest_mac: self.guest_mac,
375            gateway: self.gateway_ips(),
376            guest_ipv4: self.guest_ipv4,
377            guest_ipv6: self.guest_ipv6,
378            mtu: self.mtu as usize,
379        };
380        let config = self.config.config();
381        let network_policy = config.policy.clone();
382        let platform_policy = self.platform_policy.clone();
383        let dns_config = config.dns.clone();
384        let tls_state = self.tls_state.clone();
385        let published_ports = config.ports.clone();
386        let strict = config.strict;
387        let max_tcp_connections = config.max_tcp_connections.and_then(ConnectionLimit::cap);
388        let max_udp_connections = config.max_udp_connections;
389        let tcp_accept_queue_size = config.tcp_accept_queue_size.unwrap_or_default();
390        let secrets = self.secrets.clone();
391        let activation_gate = self.activation_gate.take();
392        let outbound_proxy = self.config.outbound_proxy().cloned().map(Arc::new);
393
394        self.poll_handle = Some(
395            std::thread::Builder::new()
396                .name("smoltcp-poll".into())
397                .spawn(move || {
398                    if let Some(gate) = activation_gate {
399                        gate.wait();
400                    }
401                    poll::smoltcp_poll_loop(
402                        shared,
403                        poll_config,
404                        network_policy,
405                        platform_policy,
406                        dns_config,
407                        tls_state,
408                        published_ports,
409                        strict,
410                        max_tcp_connections,
411                        max_udp_connections,
412                        tcp_accept_queue_size,
413                        tokio_handle,
414                        secrets,
415                        outbound_proxy,
416                    );
417                })
418                .expect("failed to spawn smoltcp poll thread"),
419        );
420    }
421
422    /// Take the `NetBackend` for `VmBuilder::net()`. One-shot.
423    pub fn take_backend(&mut self) -> Box<dyn NetBackend + Send> {
424        Box::new(self.backend.take().expect("backend already taken"))
425    }
426
427    /// Guest MAC address for `VmBuilder::net().mac()`.
428    pub fn guest_mac(&self) -> [u8; 6] {
429        self.guest_mac
430    }
431
432    /// Generate `MSB_NET*` environment variables for the guest.
433    ///
434    /// The guest init (`agentd`) reads these to configure the network
435    /// interface via ioctls + netlink.
436    pub fn guest_env_vars(&self) -> Vec<(String, String)> {
437        let mut vars = vec![
438            (
439                ENV_NET.into(),
440                format!(
441                    "iface=eth0,mac={},mtu={}",
442                    format_mac(self.guest_mac),
443                    self.mtu,
444                ),
445            ),
446            (ENV_HOST_ALIAS.into(), crate::HOST_ALIAS.into()),
447        ];
448
449        if let (Some(guest), Some(gateway)) = (self.guest_ipv4, self.gateway_ipv4) {
450            vars.push((
451                ENV_NET_IPV4.into(),
452                format!("addr={guest}/30,gw={gateway},dns={gateway}"),
453            ));
454        }
455
456        if let (Some(guest), Some(gateway)) = (self.guest_ipv6, self.gateway_ipv6) {
457            vars.push((
458                ENV_NET_IPV6.into(),
459                format!("addr={guest}/64,gw={gateway},dns={gateway}"),
460            ));
461        }
462
463        // Auto-expose secret placeholders as environment variables.
464        for secret in &self.config.config().secrets.secrets {
465            vars.push((secret.env_var.clone(), secret.placeholder.clone()));
466        }
467
468        vars
469    }
470
471    /// Build the typed network payload consumed by agentd during bootstrap.
472    pub fn guest_bootstrap_network(&self) -> BootstrapNetwork {
473        BootstrapNetwork {
474            interface: "eth0".to_string(),
475            mac: self.guest_mac,
476            mtu: self.mtu,
477            ipv4: self
478                .guest_ipv4
479                .zip(self.gateway_ipv4)
480                .map(|(address, gateway)| BootstrapIpv4 {
481                    address,
482                    prefix_len: 30,
483                    gateway,
484                    dns: Some(gateway),
485                }),
486            ipv6: self
487                .guest_ipv6
488                .zip(self.gateway_ipv6)
489                .map(|(address, gateway)| BootstrapIpv6 {
490                    address,
491                    prefix_len: 64,
492                    gateway,
493                    dns: Some(gateway),
494                }),
495        }
496    }
497
498    /// Return the stable hostname used by guests to address the host gateway.
499    pub fn guest_host_alias(&self) -> &'static str {
500        crate::HOST_ALIAS
501    }
502
503    /// Return guest-visible secret placeholders for the baseline environment.
504    ///
505    /// Real secret values stay in the host-side network handler and never
506    /// enter this payload.
507    pub fn guest_secret_env(&self) -> Vec<BootstrapEnvVar> {
508        self.config
509            .config()
510            .secrets
511            .secrets
512            .iter()
513            .map(|secret| BootstrapEnvVar {
514                key: secret.env_var.clone(),
515                value: secret.placeholder.clone(),
516            })
517            .collect()
518    }
519
520    /// CA certificate PEM bytes if TLS interception is enabled.
521    ///
522    /// Write to the runtime mount before VM boot so the guest can trust it.
523    pub fn ca_cert_pem(&self) -> Option<Vec<u8>> {
524        self.tls_state.as_ref().map(|s| s.ca_cert_pem())
525    }
526
527    /// Host-trusted CA bundle to ship into the guest, if
528    /// [`crate::config::NetworkConfig::trust_host_cas`] is enabled.
529    ///
530    /// Returned PEM may concatenate CAs that the Mozilla root bundle in
531    /// the guest already trusts; duplicates are harmless and saved the
532    /// cost of computing a delta. Returns `None` when the host store is
533    /// empty or the feature is disabled.
534    pub fn host_cas_cert_pem(&self) -> Option<Vec<u8>> {
535        if !self.config.config().trust_host_cas {
536            return None;
537        }
538        crate::engine::tls::host_cas::collect_host_cas()
539    }
540
541    /// Create a handle for wiring runtime termination into the network stack.
542    pub fn termination_handle(&self) -> TerminationHandle {
543        TerminationHandle {
544            shared: self.shared.clone(),
545        }
546    }
547
548    /// Create a handle for reading aggregate network byte counters.
549    pub fn metrics_handle(&self) -> MetricsHandle {
550        MetricsHandle {
551            shared: self.shared.clone(),
552        }
553    }
554
555    /// Live-swappable view of the secrets configuration. The runtime control
556    /// socket uses it to apply secret rotation, removal, and allowed-host
557    /// updates without restarting the sandbox.
558    pub fn secrets_handle(&self) -> SecretsHandle {
559        self.secrets.clone()
560    }
561}
562
563impl NetworkActivationHandle {
564    /// Release a deferred network exactly once. Repeated calls are harmless.
565    pub fn activate(&self) {
566        let mut active = self.gate.active.lock().unwrap();
567        if !*active {
568            *active = true;
569            self.gate.changed.notify_all();
570        }
571    }
572}
573
574impl NetworkActivationGate {
575    fn wait(&self) {
576        let mut active = self.active.lock().unwrap();
577        while !*active {
578            active = self.changed.wait(active).unwrap();
579        }
580    }
581}
582
583impl TerminationHandle {
584    /// Install the termination hook.
585    pub fn set_hook(&self, hook: Arc<dyn Fn() + Send + Sync>) {
586        self.shared.set_termination_hook(hook);
587    }
588}
589
590impl MetricsHandle {
591    /// Total guest -> runtime bytes observed at the virtio-net boundary.
592    pub fn tx_bytes(&self) -> u64 {
593        self.shared.tx_bytes()
594    }
595
596    /// Total runtime -> guest bytes observed at the virtio-net boundary.
597    pub fn rx_bytes(&self) -> u64 {
598        self.shared.rx_bytes()
599    }
600}
601
602//--------------------------------------------------------------------------------------------------
603// Functions
604//--------------------------------------------------------------------------------------------------
605
606/// Apply the platform-owned configuration floor before network resources are created.
607///
608/// Policy rules are deliberately not flattened here. The poll loop evaluates
609/// the platform public-network policy and the tenant policy independently so a
610/// broad tenant allow can never outrank the platform floor, while a tenant deny
611/// still remains effective.
612fn enforce_deployment_profile(config: &mut ResolvedNetworkConfig, profile: DeploymentProfile) {
613    if profile == DeploymentProfile::SingleTenant {
614        return;
615    }
616
617    config.clear_outbound_proxy();
618
619    let config = config.config_mut();
620    config
621        .max_tcp_connections
622        .get_or_insert(ConnectionLimit::Limited(
623            DEFAULT_MULTI_TENANT_MAX_TCP_CONNECTIONS,
624        ));
625    config
626        .max_udp_connections
627        .get_or_insert(ConnectionLimit::Limited(
628            DEFAULT_MULTI_TENANT_MAX_UDP_CONNECTIONS,
629        ));
630    let interface_overridden = config.interface.mac.is_some()
631        || config.interface.mtu.is_some()
632        || config.interface.ipv4_address.is_some()
633        || config.interface.ipv4_pool.is_some()
634        || config.interface.ipv6_address.is_some()
635        || config.interface.ipv6_pool.is_some();
636    let had_published_ports = !config.ports.is_empty();
637    let had_custom_nameservers = !config.dns.nameservers.is_empty();
638    let disabled_rebind_protection = !config.dns.rebind_protection;
639    let trusted_host_cas = config.trust_host_cas;
640    let had_outbound_proxy = config.outbound_proxy.is_some();
641    config.interface = Default::default();
642    config.ports.clear();
643    config.dns.nameservers.clear();
644    config.dns.rebind_protection = true;
645    config.trust_host_cas = false;
646    if interface_overridden
647        || had_published_ports
648        || had_custom_nameservers
649        || disabled_rebind_protection
650        || trusted_host_cas
651        || had_outbound_proxy
652    {
653        tracing::warn!(
654            interface_overridden,
655            had_published_ports,
656            had_custom_nameservers,
657            disabled_rebind_protection,
658            trusted_host_cas,
659            had_outbound_proxy,
660            "multi-tenant deployment profile overrode unsafe network configuration"
661        );
662    }
663}
664
665/// Derive a guest MAC address from the sandbox slot.
666///
667/// Format: `02:ms:bx:SS:SS:02` where SS:SS encodes the slot.
668fn derive_guest_mac(slot: u16) -> [u8; 6] {
669    let s = slot.to_be_bytes();
670    [0x02, 0x6d, 0x73, s[0], s[1], 0x02]
671}
672
673/// Derive a gateway MAC address from the sandbox slot.
674///
675/// Format: `02:ms:bx:SS:SS:01`.
676fn derive_gateway_mac(slot: u16) -> [u8; 6] {
677    let s = slot.to_be_bytes();
678    [0x02, 0x6d, 0x73, s[0], s[1], 0x01]
679}
680
681/// Derive a guest IPv4 address from the sandbox slot.
682///
683/// Pool: `172.16.0.0/12` by default. Each slot gets a `/30` block (4 IPs).
684/// Guest is at offset +2 in the block.
685fn derive_guest_ipv4(pool: Ipv4Network, slot: u16) -> Result<Ipv4Addr, NetworkInitError> {
686    let capacity = 30_u8
687        .checked_sub(pool.prefix())
688        .map(|host_bits| 1_u32 << host_bits)
689        .ok_or(NetworkInitError::Ipv4PoolCapacity { pool, slot })?;
690    if u32::from(slot) >= capacity {
691        return Err(NetworkInitError::Ipv4PoolCapacity { pool, slot });
692    }
693
694    let base = u32::from(pool.network());
695    let offset = u32::from(slot) * 4 + 2; // +2 = guest within /30
696    Ok(Ipv4Addr::from(base + offset))
697}
698
699/// Gateway IPv4 from guest IPv4: guest - 1 (offset +1 in the /30 block).
700fn gateway_from_guest_ipv4(guest: Ipv4Addr) -> Ipv4Addr {
701    Ipv4Addr::from(u32::from(guest) - 1)
702}
703
704fn default_guest_ipv4_pool() -> Ipv4Network {
705    Ipv4Network::new(Ipv4Addr::new(172, 16, 0, 0), 12)
706        .expect("default IPv4 pool must be a valid network")
707}
708
709/// Derive a guest IPv6 address from the sandbox slot.
710///
711/// Pool: `fd42:6d73:62::/48`. Each slot gets a `/64` prefix.
712/// Guest is `::2` in its prefix.
713fn derive_guest_ipv6(pool: Ipv6Network, slot: u16) -> Result<Ipv6Addr, NetworkInitError> {
714    let capacity = 64_u8
715        .checked_sub(pool.prefix())
716        .map(|host_bits| 1_u128 << host_bits)
717        .ok_or(NetworkInitError::Ipv6PoolCapacity { pool, slot })?;
718    if u128::from(slot) >= capacity {
719        return Err(NetworkInitError::Ipv6PoolCapacity { pool, slot });
720    }
721
722    let base = u128::from(pool.network());
723    let offset = u128::from(slot) << 64;
724    Ok(Ipv6Addr::from(base + offset + 2))
725}
726
727/// Gateway IPv6 from guest IPv6: `::1` in the same prefix.
728fn gateway_from_guest_ipv6(guest: Ipv6Addr) -> Ipv6Addr {
729    let segs = guest.segments();
730    Ipv6Addr::new(segs[0], segs[1], segs[2], segs[3], 0, 0, 0, 1)
731}
732
733fn default_guest_ipv6_pool() -> Ipv6Network {
734    Ipv6Network::new(Ipv6Addr::new(0xfd42, 0x6d73, 0x0062, 0, 0, 0, 0, 0), 48)
735        .expect("default IPv6 pool must be a valid network")
736}
737
738/// Format a MAC address as `xx:xx:xx:xx:xx:xx`.
739fn format_mac(mac: [u8; 6]) -> String {
740    format!(
741        "{:02x}:{:02x}:{:02x}:{:02x}:{:02x}:{:02x}",
742        mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]
743    )
744}
745
746/// Returns true if the host kernel can select an IPv4 route.
747///
748/// `UdpSocket::connect` performs a local routing-table lookup against the
749/// TEST-NET-1 (`192.0.2.1`) address; it does not send packets or wait on
750/// the network.
751fn host_has_ipv4_route() -> bool {
752    UdpSocket::bind((Ipv4Addr::UNSPECIFIED, 0))
753        .and_then(|socket| socket.connect((Ipv4Addr::new(192, 0, 2, 1), 443)))
754        .is_ok()
755}
756
757/// Returns true if the host kernel can select an IPv6 route. Probes a
758/// `2001:db8::/32` documentation address via `UdpSocket::connect` (no packet
759/// is sent).
760fn host_has_ipv6_route() -> bool {
761    UdpSocket::bind((Ipv6Addr::UNSPECIFIED, 0))
762        .and_then(|socket| socket.connect((Ipv6Addr::new(0x2001, 0x0db8, 0, 0, 0, 0, 0, 1), 443)))
763        .is_ok()
764}
765
766//--------------------------------------------------------------------------------------------------
767// Tests
768//--------------------------------------------------------------------------------------------------
769
770#[cfg(test)]
771mod tests {
772    use super::*;
773    use crate::config::{EnvNetworkSecretResolver, NetworkConfig, PortProtocol, PublishedPort};
774    use crate::dns::Nameserver;
775
776    fn resolved(config: NetworkConfig) -> ResolvedNetworkConfig {
777        config.resolve(&EnvNetworkSecretResolver).unwrap()
778    }
779
780    fn routes(ipv4: bool, ipv6: bool) -> HostRoutes {
781        HostRoutes { ipv4, ipv6 }
782    }
783
784    #[test]
785    fn captured_gateway_survives_new_slots_without_sharing_backends() {
786        let mut source_config = NetworkConfig::default();
787        // A user-supplied guest MAC cannot reveal the source gateway's slot.
788        source_config.interface.mac = Some([2, 0xaa, 0xbb, 0xcc, 0xdd, 0xee]);
789        let source = SmoltcpNetwork::build(
790            resolved(source_config),
791            7,
792            DeploymentProfile::SingleTenant,
793            routes(true, true),
794        )
795        .unwrap();
796        let mut config = NetworkConfig::default();
797        config.interface.mac = Some(source.guest_mac);
798        config.interface.ipv4_address = source.guest_ipv4;
799        config.interface.ipv6_address = source.guest_ipv6;
800        for slot in [8, 400] {
801            let child = SmoltcpNetwork::build(
802                resolved(config.clone()),
803                slot,
804                DeploymentProfile::SingleTenant,
805                routes(true, true),
806            )
807            .unwrap()
808            .with_captured_gateway_mac(source.gateway_mac)
809            .unwrap();
810            assert_eq!(child.gateway_mac, source.gateway_mac);
811            assert_eq!(child.gateway_ipv4, source.gateway_ipv4);
812            assert_eq!(child.gateway_ipv6, source.gateway_ipv6);
813            assert_eq!(child.guest_mac, source.guest_mac);
814            assert!(!Arc::ptr_eq(&child.shared, &source.shared));
815        }
816        assert_eq!(source.gateway_mac, SmoltcpNetwork::default_gateway_mac(7));
817    }
818
819    #[test]
820    fn captured_gateway_rejects_unusable_ethernet_addresses() {
821        for mac in [[0; 6], [1, 2, 3, 4, 5, 6], derive_guest_mac(8)] {
822            let network = SmoltcpNetwork::build(
823                resolved(NetworkConfig::default()),
824                8,
825                DeploymentProfile::SingleTenant,
826                routes(true, true),
827            )
828            .unwrap();
829            assert!(matches!(
830                network.with_captured_gateway_mac(mac),
831                Err(NetworkInitError::InvalidGatewayMac)
832            ));
833        }
834    }
835
836    #[test]
837    fn deferred_activation_blocks_until_released() {
838        let mut network = SmoltcpNetwork::build(
839            resolved(NetworkConfig::default()),
840            0,
841            DeploymentProfile::SingleTenant,
842            routes(true, false),
843        )
844        .unwrap();
845        let handle = network.defer_activation();
846        let gate = Arc::clone(network.activation_gate.as_ref().unwrap());
847        let (released_tx, released_rx) = std::sync::mpsc::channel();
848        let waiter = std::thread::spawn(move || {
849            gate.wait();
850            released_tx.send(()).unwrap();
851        });
852
853        assert!(
854            released_rx
855                .recv_timeout(std::time::Duration::from_millis(25))
856                .is_err(),
857            "deferred network became active before explicit release"
858        );
859        handle.activate();
860        released_rx
861            .recv_timeout(std::time::Duration::from_secs(1))
862            .unwrap();
863        waiter.join().unwrap();
864    }
865
866    #[test]
867    fn derive_addresses_slot_0() {
868        assert_eq!(derive_guest_mac(0), [0x02, 0x6d, 0x73, 0x00, 0x00, 0x02]);
869        assert_eq!(derive_gateway_mac(0), [0x02, 0x6d, 0x73, 0x00, 0x00, 0x01]);
870        assert_eq!(
871            derive_guest_ipv4(default_guest_ipv4_pool(), 0).unwrap(),
872            Ipv4Addr::new(172, 16, 0, 2)
873        );
874        assert_eq!(
875            gateway_from_guest_ipv4(Ipv4Addr::new(172, 16, 0, 2)),
876            Ipv4Addr::new(172, 16, 0, 1)
877        );
878    }
879
880    #[test]
881    fn multi_tenant_profile_sanitizes_host_owned_network_controls() {
882        let mut config = NetworkConfig::default();
883        config.interface.mac = Some([2, 3, 4, 5, 6, 7]);
884        config.interface.mtu = Some(9000);
885        config.ports.push(PublishedPort {
886            host_port: 8080,
887            guest_port: 80,
888            protocol: PortProtocol::Tcp,
889            host_bind: Ipv4Addr::UNSPECIFIED.into(),
890        });
891        config.dns.nameservers = vec!["10.0.0.53".parse::<Nameserver>().unwrap()];
892        config.dns.rebind_protection = false;
893        config.trust_host_cas = true;
894        config.outbound_proxy = Some(crate::proxy::OutboundProxy::Socks5 {
895            address: "127.0.0.1:1080".parse().unwrap(),
896            credentials: None,
897        });
898        config.max_tcp_connections = Some(ConnectionLimit::from(257));
899        config.policy = NetworkPolicy::allow_all();
900        let mut resolved = resolved(config);
901
902        enforce_deployment_profile(&mut resolved, DeploymentProfile::MultiTenant);
903        let config = resolved.config();
904
905        assert!(config.interface.mac.is_none());
906        assert!(config.interface.mtu.is_none());
907        assert!(config.ports.is_empty());
908        assert!(config.dns.nameservers.is_empty());
909        assert!(config.dns.rebind_protection);
910        assert!(!config.trust_host_cas);
911        assert!(config.outbound_proxy.is_none());
912        assert_eq!(config.max_tcp_connections, Some(ConnectionLimit::from(257)));
913        assert!(resolved.config().outbound_proxy.is_none());
914        assert!(resolved.outbound_proxy().is_none());
915        // Tenant policy stays intact and is intersected with the platform
916        // policy at evaluation time instead of being reordered or flattened.
917        assert!(config.policy.default_egress.is_allow());
918    }
919
920    #[test]
921    fn deployment_profile_defaults_and_explicit_connection_limits() {
922        for profile in [
923            DeploymentProfile::SingleTenant,
924            DeploymentProfile::MultiTenant,
925        ] {
926            for requested in [None, Some(0), Some(64), Some(4096)] {
927                let config: NetworkConfig =
928                    serde_json::from_value(serde_json::json!({"max_tcp_connections": requested}))
929                        .unwrap();
930                let mut config = resolved(config);
931                // Exercise the serialized runtime launch boundary as well.
932                config = serde_json::from_value(serde_json::to_value(config).unwrap()).unwrap();
933                enforce_deployment_profile(&mut config, profile);
934                let expected = requested
935                    .or(match profile {
936                        DeploymentProfile::SingleTenant => None,
937                        DeploymentProfile::MultiTenant => Some(1024),
938                    })
939                    .and_then(NonZeroUsize::new);
940                assert_eq!(
941                    config
942                        .config()
943                        .max_tcp_connections
944                        .and_then(ConnectionLimit::cap),
945                    expected
946                );
947                // Applying the profile again must preserve the resolved value.
948                enforce_deployment_profile(&mut config, profile);
949                assert_eq!(
950                    config
951                        .config()
952                        .max_tcp_connections
953                        .and_then(ConnectionLimit::cap),
954                    expected
955                );
956            }
957        }
958    }
959
960    #[test]
961    fn deployment_profiles_resolve_udp_defaults_and_preserve_overrides() {
962        for profile in [
963            DeploymentProfile::SingleTenant,
964            DeploymentProfile::MultiTenant,
965        ] {
966            for requested in [None, Some(0), Some(7), Some(4096)] {
967                let config: NetworkConfig = serde_json::from_value(serde_json::json!({
968                    "max_udp_connections": requested
969                }))
970                .unwrap();
971                let mut config = resolved(config);
972                let expected = requested
973                    .or(match profile {
974                        DeploymentProfile::SingleTenant => None,
975                        DeploymentProfile::MultiTenant => Some(1024),
976                    })
977                    .map(ConnectionLimit::from);
978                enforce_deployment_profile(&mut config, profile);
979                assert_eq!(config.config().max_udp_connections, expected);
980                enforce_deployment_profile(&mut config, profile);
981                assert_eq!(config.config().max_udp_connections, expected);
982            }
983        }
984    }
985
986    #[test]
987    fn single_tenant_profile_preserves_requested_network_controls() {
988        let mut config = NetworkConfig::default();
989        config.interface.mtu = Some(9000);
990        config.dns.rebind_protection = false;
991        config.trust_host_cas = true;
992        config.outbound_proxy = Some(crate::proxy::OutboundProxy::Socks5 {
993            address: "127.0.0.1:1080".parse().unwrap(),
994            credentials: None,
995        });
996
997        let mut resolved = resolved(config);
998        enforce_deployment_profile(&mut resolved, DeploymentProfile::SingleTenant);
999        let config = resolved.config();
1000
1001        assert_eq!(config.interface.mtu, Some(9000));
1002        assert!(!config.dns.rebind_protection);
1003        assert!(config.trust_host_cas);
1004        assert!(config.outbound_proxy.is_some());
1005    }
1006
1007    #[test]
1008    fn derive_addresses_slot_1() {
1009        assert_eq!(
1010            derive_guest_ipv4(default_guest_ipv4_pool(), 1).unwrap(),
1011            Ipv4Addr::new(172, 16, 0, 6)
1012        );
1013        assert_eq!(
1014            gateway_from_guest_ipv4(Ipv4Addr::new(172, 16, 0, 6)),
1015            Ipv4Addr::new(172, 16, 0, 5)
1016        );
1017    }
1018
1019    #[test]
1020    fn derive_addresses_max_slot() {
1021        assert_eq!(
1022            derive_guest_mac(u16::MAX),
1023            [0x02, 0x6d, 0x73, 0xff, 0xff, 0x02]
1024        );
1025        assert_eq!(
1026            derive_guest_ipv4(default_guest_ipv4_pool(), u16::MAX).unwrap(),
1027            Ipv4Addr::new(172, 19, 255, 254)
1028        );
1029        assert_eq!(
1030            derive_guest_ipv6(default_guest_ipv6_pool(), u16::MAX).unwrap(),
1031            "fd42:6d73:62:ffff::2".parse::<Ipv6Addr>().unwrap()
1032        );
1033    }
1034
1035    #[test]
1036    fn derive_addresses_custom_ipv4_pool() {
1037        let pool = "172.31.240.0/24".parse::<Ipv4Network>().unwrap();
1038        assert_eq!(
1039            derive_guest_ipv4(pool, 0).unwrap(),
1040            Ipv4Addr::new(172, 31, 240, 2)
1041        );
1042        assert_eq!(
1043            derive_guest_ipv4(pool, 63).unwrap(),
1044            Ipv4Addr::new(172, 31, 240, 254)
1045        );
1046    }
1047
1048    #[test]
1049    fn custom_ipv4_pool_capacity_is_a_typed_error() {
1050        let pool = "172.31.240.0/24".parse::<Ipv4Network>().unwrap();
1051        assert!(matches!(
1052            derive_guest_ipv4(pool, 64),
1053            Err(NetworkInitError::Ipv4PoolCapacity { slot: 64, .. })
1054        ));
1055
1056        let pool = "172.31.240.0/31".parse::<Ipv4Network>().unwrap();
1057        assert!(matches!(
1058            derive_guest_ipv4(pool, 0),
1059            Err(NetworkInitError::Ipv4PoolCapacity { slot: 0, .. })
1060        ));
1061    }
1062
1063    #[test]
1064    fn derive_ipv6_slot_0() {
1065        assert_eq!(
1066            derive_guest_ipv6(default_guest_ipv6_pool(), 0).unwrap(),
1067            "fd42:6d73:62:0::2".parse::<Ipv6Addr>().unwrap()
1068        );
1069        assert_eq!(
1070            gateway_from_guest_ipv6(derive_guest_ipv6(default_guest_ipv6_pool(), 0).unwrap()),
1071            "fd42:6d73:62:0::1".parse::<Ipv6Addr>().unwrap()
1072        );
1073    }
1074
1075    #[test]
1076    fn derive_addresses_custom_ipv6_pool() {
1077        let pool = "fd7a:115c:a1e0:100::/56".parse::<Ipv6Network>().unwrap();
1078        assert_eq!(
1079            derive_guest_ipv6(pool, 0).unwrap(),
1080            "fd7a:115c:a1e0:100::2".parse::<Ipv6Addr>().unwrap()
1081        );
1082        assert_eq!(
1083            derive_guest_ipv6(pool, 3).unwrap(),
1084            "fd7a:115c:a1e0:103::2".parse::<Ipv6Addr>().unwrap()
1085        );
1086    }
1087
1088    #[test]
1089    fn custom_ipv6_pool_capacity_is_a_typed_error() {
1090        let pool = "fd7a:115c:a1e0:100::/62".parse::<Ipv6Network>().unwrap();
1091        assert!(matches!(
1092            derive_guest_ipv6(pool, 4),
1093            Err(NetworkInitError::Ipv6PoolCapacity { slot: 4, .. })
1094        ));
1095
1096        let pool = "fd7a:115c:a1e0:100::/65".parse::<Ipv6Network>().unwrap();
1097        assert!(matches!(
1098            derive_guest_ipv6(pool, 0),
1099            Err(NetworkInitError::Ipv6PoolCapacity { slot: 0, .. })
1100        ));
1101    }
1102
1103    #[test]
1104    fn format_mac_address() {
1105        assert_eq!(
1106            format_mac([0x02, 0x6d, 0x73, 0x00, 0x00, 0x01]),
1107            "02:6d:73:00:00:01"
1108        );
1109    }
1110
1111    #[test]
1112    fn guest_env_vars_includes_ipv4_when_host_has_v4_route() {
1113        let net = SmoltcpNetwork::build(
1114            resolved(NetworkConfig::default()),
1115            0,
1116            DeploymentProfile::SingleTenant,
1117            routes(true, false),
1118        )
1119        .unwrap();
1120        let vars = net.guest_env_vars();
1121
1122        assert_eq!(vars.len(), 3);
1123        assert_eq!(vars[0].0, ENV_NET);
1124        assert!(vars[0].1.contains("iface=eth0"));
1125        assert_eq!(vars[1].0, ENV_HOST_ALIAS);
1126        assert_eq!(vars[1].1, crate::HOST_ALIAS);
1127        assert_eq!(vars[2].0, ENV_NET_IPV4);
1128        assert!(vars[2].1.contains("/30"));
1129    }
1130
1131    #[test]
1132    fn guest_env_vars_includes_ipv6_when_host_has_v6_route() {
1133        let net = SmoltcpNetwork::build(
1134            resolved(NetworkConfig::default()),
1135            0,
1136            DeploymentProfile::SingleTenant,
1137            routes(true, true),
1138        )
1139        .unwrap();
1140        let vars = net.guest_env_vars();
1141
1142        assert_eq!(vars.len(), 4);
1143        assert_eq!(vars[0].0, ENV_NET);
1144        assert_eq!(vars[1].0, ENV_HOST_ALIAS);
1145        assert_eq!(vars[2].0, ENV_NET_IPV4);
1146        assert_eq!(vars[3].0, ENV_NET_IPV6);
1147        assert!(vars[3].1.contains("/64"));
1148    }
1149
1150    #[test]
1151    fn guest_env_vars_omit_ipv6_without_host_route() {
1152        let net = SmoltcpNetwork::build(
1153            resolved(NetworkConfig::default()),
1154            0,
1155            DeploymentProfile::SingleTenant,
1156            routes(true, false),
1157        )
1158        .unwrap();
1159        let vars = net.guest_env_vars();
1160
1161        assert!(!vars.iter().any(|(k, _)| k == ENV_NET_IPV6));
1162    }
1163
1164    #[test]
1165    fn guest_env_vars_omit_ipv4_without_host_route() {
1166        let net = SmoltcpNetwork::build(
1167            resolved(NetworkConfig::default()),
1168            0,
1169            DeploymentProfile::SingleTenant,
1170            routes(false, true),
1171        )
1172        .unwrap();
1173        let vars = net.guest_env_vars();
1174
1175        assert_eq!(vars.len(), 3);
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_IPV6);
1179    }
1180
1181    #[test]
1182    fn explicit_ipv6_address_overrides_missing_host_v6_route() {
1183        let mut config = NetworkConfig::default();
1184        config.interface.ipv6_address = Some("fd42:6d73:62:99::2".parse().unwrap());
1185        let net = SmoltcpNetwork::build(
1186            resolved(config),
1187            0,
1188            DeploymentProfile::SingleTenant,
1189            routes(true, false),
1190        )
1191        .unwrap();
1192        let vars = net.guest_env_vars();
1193
1194        let v6 = vars
1195            .iter()
1196            .find(|(k, _)| k == ENV_NET_IPV6)
1197            .expect("explicit ipv6 should publish env var even without host route");
1198        assert!(v6.1.contains("fd42:6d73:62:99::2/64"));
1199    }
1200
1201    #[test]
1202    fn neither_family_active_emits_only_base_env_vars() {
1203        let net = SmoltcpNetwork::build(
1204            resolved(NetworkConfig::default()),
1205            0,
1206            DeploymentProfile::SingleTenant,
1207            routes(false, false),
1208        )
1209        .unwrap();
1210        let vars = net.guest_env_vars();
1211
1212        assert_eq!(vars.len(), 2);
1213        assert_eq!(vars[0].0, ENV_NET);
1214        assert_eq!(vars[1].0, ENV_HOST_ALIAS);
1215    }
1216
1217    #[test]
1218    fn guest_bootstrap_network_preserves_active_address_families() {
1219        let net = SmoltcpNetwork::build(
1220            resolved(NetworkConfig::default()),
1221            7,
1222            DeploymentProfile::SingleTenant,
1223            routes(true, true),
1224        )
1225        .unwrap();
1226
1227        let bootstrap = net.guest_bootstrap_network();
1228
1229        assert_eq!(bootstrap.interface, "eth0");
1230        assert_eq!(bootstrap.mac, net.guest_mac());
1231        assert_eq!(bootstrap.mtu, 1500);
1232        assert_eq!(bootstrap.ipv4.unwrap().prefix_len, 30);
1233        assert_eq!(bootstrap.ipv6.unwrap().prefix_len, 64);
1234        assert_eq!(net.guest_host_alias(), crate::HOST_ALIAS);
1235    }
1236
1237    #[test]
1238    fn guest_bootstrap_network_allows_no_active_address_family() {
1239        let net = SmoltcpNetwork::build(
1240            resolved(NetworkConfig::default()),
1241            0,
1242            DeploymentProfile::SingleTenant,
1243            routes(false, false),
1244        )
1245        .unwrap();
1246
1247        let bootstrap = net.guest_bootstrap_network();
1248
1249        assert!(bootstrap.ipv4.is_none());
1250        assert!(bootstrap.ipv6.is_none());
1251    }
1252
1253    #[test]
1254    fn large_connection_cap_does_not_preallocate_or_prevent_startup() {
1255        for limit in [10000, usize::MAX] {
1256            let mut config = NetworkConfig::default();
1257            config.tls.enabled = false;
1258            config.max_tcp_connections = Some(ConnectionLimit::from(limit));
1259            let net = SmoltcpNetwork::build(
1260                resolved(config),
1261                0,
1262                DeploymentProfile::MultiTenant,
1263                routes(true, false),
1264            );
1265            assert!(net.is_ok(), "large explicit cap should allow startup");
1266        }
1267    }
1268
1269    /// A stored config bypasses the builder's validation, so an invalid
1270    /// limiter must fail startup cleanly instead of panicking.
1271    #[test]
1272    fn build_rejects_invalid_rate_limiter() {
1273        let mut config = NetworkConfig {
1274            rate_limiter: Some(microsandbox_types::NetworkRateLimiterConfig {
1275                egress: None,
1276                ingress: Some(microsandbox_types::RateLimiterConfig {
1277                    bandwidth: None,
1278                    ops: None,
1279                }),
1280            }),
1281            ..NetworkConfig::default()
1282        };
1283        config.tls.enabled = false;
1284
1285        let err = match SmoltcpNetwork::build(
1286            resolved(config),
1287            0,
1288            DeploymentProfile::SingleTenant,
1289            routes(true, false),
1290        ) {
1291            Ok(_) => panic!("empty rate limiter should fail"),
1292            Err(err) => err,
1293        };
1294
1295        assert!(matches!(
1296            err,
1297            NetworkInitError::InvalidRateLimit {
1298                direction: NetworkRateLimitDirection::Ingress,
1299                source: RateLimitConfigError::EmptyLimiter,
1300            }
1301        ));
1302    }
1303}