Skip to main content

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