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 strict = config.strict;
332        let max_connections = config.max_connections;
333        let secrets = self.secrets.clone();
334        let outbound_proxy = self.config.outbound_proxy().cloned().map(Arc::new);
335
336        self.poll_handle = Some(
337            std::thread::Builder::new()
338                .name("smoltcp-poll".into())
339                .spawn(move || {
340                    poll::smoltcp_poll_loop(
341                        shared,
342                        poll_config,
343                        network_policy,
344                        platform_policy,
345                        dns_config,
346                        tls_state,
347                        published_ports,
348                        strict,
349                        max_connections,
350                        tokio_handle,
351                        secrets,
352                        outbound_proxy,
353                    );
354                })
355                .expect("failed to spawn smoltcp poll thread"),
356        );
357    }
358
359    /// Take the `NetBackend` for `VmBuilder::net()`. One-shot.
360    pub fn take_backend(&mut self) -> Box<dyn NetBackend + Send> {
361        Box::new(self.backend.take().expect("backend already taken"))
362    }
363
364    /// Guest MAC address for `VmBuilder::net().mac()`.
365    pub fn guest_mac(&self) -> [u8; 6] {
366        self.guest_mac
367    }
368
369    /// Generate `MSB_NET*` environment variables for the guest.
370    ///
371    /// The guest init (`agentd`) reads these to configure the network
372    /// interface via ioctls + netlink.
373    pub fn guest_env_vars(&self) -> Vec<(String, String)> {
374        let mut vars = vec![
375            (
376                ENV_NET.into(),
377                format!(
378                    "iface=eth0,mac={},mtu={}",
379                    format_mac(self.guest_mac),
380                    self.mtu,
381                ),
382            ),
383            (ENV_HOST_ALIAS.into(), crate::HOST_ALIAS.into()),
384        ];
385
386        if let (Some(guest), Some(gateway)) = (self.guest_ipv4, self.gateway_ipv4) {
387            vars.push((
388                ENV_NET_IPV4.into(),
389                format!("addr={guest}/30,gw={gateway},dns={gateway}"),
390            ));
391        }
392
393        if let (Some(guest), Some(gateway)) = (self.guest_ipv6, self.gateway_ipv6) {
394            vars.push((
395                ENV_NET_IPV6.into(),
396                format!("addr={guest}/64,gw={gateway},dns={gateway}"),
397            ));
398        }
399
400        // Auto-expose secret placeholders as environment variables.
401        for secret in &self.config.config().secrets.secrets {
402            vars.push((secret.env_var.clone(), secret.placeholder.clone()));
403        }
404
405        vars
406    }
407
408    /// Build the typed network payload consumed by agentd during bootstrap.
409    pub fn guest_bootstrap_network(&self) -> BootstrapNetwork {
410        BootstrapNetwork {
411            interface: "eth0".to_string(),
412            mac: self.guest_mac,
413            mtu: self.mtu,
414            ipv4: self
415                .guest_ipv4
416                .zip(self.gateway_ipv4)
417                .map(|(address, gateway)| BootstrapIpv4 {
418                    address,
419                    prefix_len: 30,
420                    gateway,
421                    dns: Some(gateway),
422                }),
423            ipv6: self
424                .guest_ipv6
425                .zip(self.gateway_ipv6)
426                .map(|(address, gateway)| BootstrapIpv6 {
427                    address,
428                    prefix_len: 64,
429                    gateway,
430                    dns: Some(gateway),
431                }),
432        }
433    }
434
435    /// Return the stable hostname used by guests to address the host gateway.
436    pub fn guest_host_alias(&self) -> &'static str {
437        crate::HOST_ALIAS
438    }
439
440    /// Return guest-visible secret placeholders for the baseline environment.
441    ///
442    /// Real secret values stay in the host-side network handler and never
443    /// enter this payload.
444    pub fn guest_secret_env(&self) -> Vec<BootstrapEnvVar> {
445        self.config
446            .config()
447            .secrets
448            .secrets
449            .iter()
450            .map(|secret| BootstrapEnvVar {
451                key: secret.env_var.clone(),
452                value: secret.placeholder.clone(),
453            })
454            .collect()
455    }
456
457    /// CA certificate PEM bytes if TLS interception is enabled.
458    ///
459    /// Write to the runtime mount before VM boot so the guest can trust it.
460    pub fn ca_cert_pem(&self) -> Option<Vec<u8>> {
461        self.tls_state.as_ref().map(|s| s.ca_cert_pem())
462    }
463
464    /// Host-trusted CA bundle to ship into the guest, if
465    /// [`crate::config::NetworkConfig::trust_host_cas`] is enabled.
466    ///
467    /// Returned PEM may concatenate CAs that the Mozilla root bundle in
468    /// the guest already trusts; duplicates are harmless and saved the
469    /// cost of computing a delta. Returns `None` when the host store is
470    /// empty or the feature is disabled.
471    pub fn host_cas_cert_pem(&self) -> Option<Vec<u8>> {
472        if !self.config.config().trust_host_cas {
473            return None;
474        }
475        crate::tls::host_cas::collect_host_cas()
476    }
477
478    /// Create a handle for wiring runtime termination into the network stack.
479    pub fn termination_handle(&self) -> TerminationHandle {
480        TerminationHandle {
481            shared: self.shared.clone(),
482        }
483    }
484
485    /// Create a handle for reading aggregate network byte counters.
486    pub fn metrics_handle(&self) -> MetricsHandle {
487        MetricsHandle {
488            shared: self.shared.clone(),
489        }
490    }
491
492    /// Live-swappable view of the secrets configuration. The runtime control
493    /// socket uses it to apply secret rotation, removal, and allowed-host
494    /// updates without restarting the sandbox.
495    pub fn secrets_handle(&self) -> SecretsHandle {
496        self.secrets.clone()
497    }
498}
499
500impl TerminationHandle {
501    /// Install the termination hook.
502    pub fn set_hook(&self, hook: Arc<dyn Fn() + Send + Sync>) {
503        self.shared.set_termination_hook(hook);
504    }
505}
506
507impl MetricsHandle {
508    /// Total guest -> runtime bytes observed at the virtio-net boundary.
509    pub fn tx_bytes(&self) -> u64 {
510        self.shared.tx_bytes()
511    }
512
513    /// Total runtime -> guest bytes observed at the virtio-net boundary.
514    pub fn rx_bytes(&self) -> u64 {
515        self.shared.rx_bytes()
516    }
517}
518
519//--------------------------------------------------------------------------------------------------
520// Functions
521//--------------------------------------------------------------------------------------------------
522
523/// Apply the platform-owned configuration floor before network resources are created.
524///
525/// Policy rules are deliberately not flattened here. The poll loop evaluates
526/// the platform public-network policy and the tenant policy independently so a
527/// broad tenant allow can never outrank the platform floor, while a tenant deny
528/// still remains effective.
529fn enforce_deployment_profile(config: &mut ResolvedNetworkConfig, profile: DeploymentProfile) {
530    if profile == DeploymentProfile::SingleTenant {
531        return;
532    }
533
534    config.clear_outbound_proxy();
535
536    let config = config.config_mut();
537    let interface_overridden = config.interface.mac.is_some()
538        || config.interface.mtu.is_some()
539        || config.interface.ipv4_address.is_some()
540        || config.interface.ipv4_pool.is_some()
541        || config.interface.ipv6_address.is_some()
542        || config.interface.ipv6_pool.is_some();
543    let had_published_ports = !config.ports.is_empty();
544    let had_custom_nameservers = !config.dns.nameservers.is_empty();
545    let disabled_rebind_protection = !config.dns.rebind_protection;
546    let trusted_host_cas = config.trust_host_cas;
547    let had_outbound_proxy = config.outbound_proxy.is_some();
548    let connection_limit_clamped = config
549        .max_connections
550        .is_some_and(|limit| limit > MULTI_TENANT_MAX_CONNECTIONS);
551
552    config.interface = Default::default();
553    config.ports.clear();
554    config.dns.nameservers.clear();
555    config.dns.rebind_protection = true;
556    config.trust_host_cas = false;
557    config.max_connections = Some(
558        config
559            .max_connections
560            .unwrap_or(MULTI_TENANT_MAX_CONNECTIONS)
561            .min(MULTI_TENANT_MAX_CONNECTIONS),
562    );
563
564    if interface_overridden
565        || had_published_ports
566        || had_custom_nameservers
567        || disabled_rebind_protection
568        || trusted_host_cas
569        || had_outbound_proxy
570        || connection_limit_clamped
571    {
572        tracing::warn!(
573            interface_overridden,
574            had_published_ports,
575            had_custom_nameservers,
576            disabled_rebind_protection,
577            trusted_host_cas,
578            had_outbound_proxy,
579            connection_limit_clamped,
580            "multi-tenant deployment profile overrode unsafe network configuration"
581        );
582    }
583}
584
585/// Derive a guest MAC address from the sandbox slot.
586///
587/// Format: `02:ms:bx:SS:SS:02` where SS:SS encodes the slot.
588fn derive_guest_mac(slot: u16) -> [u8; 6] {
589    let s = slot.to_be_bytes();
590    [0x02, 0x6d, 0x73, s[0], s[1], 0x02]
591}
592
593/// Derive a gateway MAC address from the sandbox slot.
594///
595/// Format: `02:ms:bx:SS:SS:01`.
596fn derive_gateway_mac(slot: u16) -> [u8; 6] {
597    let s = slot.to_be_bytes();
598    [0x02, 0x6d, 0x73, s[0], s[1], 0x01]
599}
600
601/// Derive a guest IPv4 address from the sandbox slot.
602///
603/// Pool: `172.16.0.0/12` by default. Each slot gets a `/30` block (4 IPs).
604/// Guest is at offset +2 in the block.
605fn derive_guest_ipv4(pool: Ipv4Network, slot: u16) -> Result<Ipv4Addr, NetworkInitError> {
606    let capacity = 30_u8
607        .checked_sub(pool.prefix())
608        .map(|host_bits| 1_u32 << host_bits)
609        .ok_or(NetworkInitError::Ipv4PoolCapacity { pool, slot })?;
610    if u32::from(slot) >= capacity {
611        return Err(NetworkInitError::Ipv4PoolCapacity { pool, slot });
612    }
613
614    let base = u32::from(pool.network());
615    let offset = u32::from(slot) * 4 + 2; // +2 = guest within /30
616    Ok(Ipv4Addr::from(base + offset))
617}
618
619/// Gateway IPv4 from guest IPv4: guest - 1 (offset +1 in the /30 block).
620fn gateway_from_guest_ipv4(guest: Ipv4Addr) -> Ipv4Addr {
621    Ipv4Addr::from(u32::from(guest) - 1)
622}
623
624fn default_guest_ipv4_pool() -> Ipv4Network {
625    Ipv4Network::new(Ipv4Addr::new(172, 16, 0, 0), 12)
626        .expect("default IPv4 pool must be a valid network")
627}
628
629/// Derive a guest IPv6 address from the sandbox slot.
630///
631/// Pool: `fd42:6d73:62::/48`. Each slot gets a `/64` prefix.
632/// Guest is `::2` in its prefix.
633fn derive_guest_ipv6(pool: Ipv6Network, slot: u16) -> Result<Ipv6Addr, NetworkInitError> {
634    let capacity = 64_u8
635        .checked_sub(pool.prefix())
636        .map(|host_bits| 1_u128 << host_bits)
637        .ok_or(NetworkInitError::Ipv6PoolCapacity { pool, slot })?;
638    if u128::from(slot) >= capacity {
639        return Err(NetworkInitError::Ipv6PoolCapacity { pool, slot });
640    }
641
642    let base = u128::from(pool.network());
643    let offset = u128::from(slot) << 64;
644    Ok(Ipv6Addr::from(base + offset + 2))
645}
646
647/// Gateway IPv6 from guest IPv6: `::1` in the same prefix.
648fn gateway_from_guest_ipv6(guest: Ipv6Addr) -> Ipv6Addr {
649    let segs = guest.segments();
650    Ipv6Addr::new(segs[0], segs[1], segs[2], segs[3], 0, 0, 0, 1)
651}
652
653fn default_guest_ipv6_pool() -> Ipv6Network {
654    Ipv6Network::new(Ipv6Addr::new(0xfd42, 0x6d73, 0x0062, 0, 0, 0, 0, 0), 48)
655        .expect("default IPv6 pool must be a valid network")
656}
657
658/// Format a MAC address as `xx:xx:xx:xx:xx:xx`.
659fn format_mac(mac: [u8; 6]) -> String {
660    format!(
661        "{:02x}:{:02x}:{:02x}:{:02x}:{:02x}:{:02x}",
662        mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]
663    )
664}
665
666/// Returns true if the host kernel can select an IPv4 route.
667///
668/// `UdpSocket::connect` performs a local routing-table lookup against the
669/// TEST-NET-1 (`192.0.2.1`) address; it does not send packets or wait on
670/// the network.
671fn host_has_ipv4_route() -> bool {
672    UdpSocket::bind((Ipv4Addr::UNSPECIFIED, 0))
673        .and_then(|socket| socket.connect((Ipv4Addr::new(192, 0, 2, 1), 443)))
674        .is_ok()
675}
676
677/// Returns true if the host kernel can select an IPv6 route. Probes a
678/// `2001:db8::/32` documentation address via `UdpSocket::connect` (no packet
679/// is sent).
680fn host_has_ipv6_route() -> bool {
681    UdpSocket::bind((Ipv6Addr::UNSPECIFIED, 0))
682        .and_then(|socket| socket.connect((Ipv6Addr::new(0x2001, 0x0db8, 0, 0, 0, 0, 0, 1), 443)))
683        .is_ok()
684}
685
686//--------------------------------------------------------------------------------------------------
687// Tests
688//--------------------------------------------------------------------------------------------------
689
690#[cfg(test)]
691mod tests {
692    use super::*;
693    use crate::config::{EnvNetworkSecretResolver, NetworkConfig, PortProtocol, PublishedPort};
694    use crate::dns::Nameserver;
695
696    fn resolved(config: NetworkConfig) -> ResolvedNetworkConfig {
697        config.resolve(&EnvNetworkSecretResolver).unwrap()
698    }
699
700    fn routes(ipv4: bool, ipv6: bool) -> HostRoutes {
701        HostRoutes { ipv4, ipv6 }
702    }
703
704    #[test]
705    fn derive_addresses_slot_0() {
706        assert_eq!(derive_guest_mac(0), [0x02, 0x6d, 0x73, 0x00, 0x00, 0x02]);
707        assert_eq!(derive_gateway_mac(0), [0x02, 0x6d, 0x73, 0x00, 0x00, 0x01]);
708        assert_eq!(
709            derive_guest_ipv4(default_guest_ipv4_pool(), 0).unwrap(),
710            Ipv4Addr::new(172, 16, 0, 2)
711        );
712        assert_eq!(
713            gateway_from_guest_ipv4(Ipv4Addr::new(172, 16, 0, 2)),
714            Ipv4Addr::new(172, 16, 0, 1)
715        );
716    }
717
718    #[test]
719    fn multi_tenant_profile_sanitizes_host_owned_network_controls() {
720        let mut config = NetworkConfig::default();
721        config.interface.mac = Some([2, 3, 4, 5, 6, 7]);
722        config.interface.mtu = Some(9000);
723        config.ports.push(PublishedPort {
724            host_port: 8080,
725            guest_port: 80,
726            protocol: PortProtocol::Tcp,
727            host_bind: Ipv4Addr::UNSPECIFIED.into(),
728        });
729        config.dns.nameservers = vec!["10.0.0.53".parse::<Nameserver>().unwrap()];
730        config.dns.rebind_protection = false;
731        config.trust_host_cas = true;
732        config.outbound_proxy = Some(crate::proxy::OutboundProxy::Socks5 {
733            address: "127.0.0.1:1080".parse().unwrap(),
734            credentials: None,
735        });
736        config.max_connections = Some(MULTI_TENANT_MAX_CONNECTIONS + 1);
737        config.policy = NetworkPolicy::allow_all();
738        let mut resolved = resolved(config);
739
740        enforce_deployment_profile(&mut resolved, DeploymentProfile::MultiTenant);
741        let config = resolved.config();
742
743        assert!(config.interface.mac.is_none());
744        assert!(config.interface.mtu.is_none());
745        assert!(config.ports.is_empty());
746        assert!(config.dns.nameservers.is_empty());
747        assert!(config.dns.rebind_protection);
748        assert!(!config.trust_host_cas);
749        assert!(config.outbound_proxy.is_none());
750        assert_eq!(config.max_connections, Some(MULTI_TENANT_MAX_CONNECTIONS));
751        assert!(resolved.config().outbound_proxy.is_none());
752        assert!(resolved.outbound_proxy().is_none());
753        // Tenant policy stays intact and is intersected with the platform
754        // policy at evaluation time instead of being reordered or flattened.
755        assert!(config.policy.default_egress.is_allow());
756    }
757
758    #[test]
759    fn single_tenant_profile_preserves_requested_network_controls() {
760        let mut config = NetworkConfig::default();
761        config.interface.mtu = Some(9000);
762        config.dns.rebind_protection = false;
763        config.trust_host_cas = true;
764        config.outbound_proxy = Some(crate::proxy::OutboundProxy::Socks5 {
765            address: "127.0.0.1:1080".parse().unwrap(),
766            credentials: None,
767        });
768
769        let mut resolved = resolved(config);
770        enforce_deployment_profile(&mut resolved, DeploymentProfile::SingleTenant);
771        let config = resolved.config();
772
773        assert_eq!(config.interface.mtu, Some(9000));
774        assert!(!config.dns.rebind_protection);
775        assert!(config.trust_host_cas);
776        assert!(config.outbound_proxy.is_some());
777    }
778
779    #[test]
780    fn derive_addresses_slot_1() {
781        assert_eq!(
782            derive_guest_ipv4(default_guest_ipv4_pool(), 1).unwrap(),
783            Ipv4Addr::new(172, 16, 0, 6)
784        );
785        assert_eq!(
786            gateway_from_guest_ipv4(Ipv4Addr::new(172, 16, 0, 6)),
787            Ipv4Addr::new(172, 16, 0, 5)
788        );
789    }
790
791    #[test]
792    fn derive_addresses_max_slot() {
793        assert_eq!(
794            derive_guest_mac(u16::MAX),
795            [0x02, 0x6d, 0x73, 0xff, 0xff, 0x02]
796        );
797        assert_eq!(
798            derive_guest_ipv4(default_guest_ipv4_pool(), u16::MAX).unwrap(),
799            Ipv4Addr::new(172, 19, 255, 254)
800        );
801        assert_eq!(
802            derive_guest_ipv6(default_guest_ipv6_pool(), u16::MAX).unwrap(),
803            "fd42:6d73:62:ffff::2".parse::<Ipv6Addr>().unwrap()
804        );
805    }
806
807    #[test]
808    fn derive_addresses_custom_ipv4_pool() {
809        let pool = "172.31.240.0/24".parse::<Ipv4Network>().unwrap();
810        assert_eq!(
811            derive_guest_ipv4(pool, 0).unwrap(),
812            Ipv4Addr::new(172, 31, 240, 2)
813        );
814        assert_eq!(
815            derive_guest_ipv4(pool, 63).unwrap(),
816            Ipv4Addr::new(172, 31, 240, 254)
817        );
818    }
819
820    #[test]
821    fn custom_ipv4_pool_capacity_is_a_typed_error() {
822        let pool = "172.31.240.0/24".parse::<Ipv4Network>().unwrap();
823        assert!(matches!(
824            derive_guest_ipv4(pool, 64),
825            Err(NetworkInitError::Ipv4PoolCapacity { slot: 64, .. })
826        ));
827
828        let pool = "172.31.240.0/31".parse::<Ipv4Network>().unwrap();
829        assert!(matches!(
830            derive_guest_ipv4(pool, 0),
831            Err(NetworkInitError::Ipv4PoolCapacity { slot: 0, .. })
832        ));
833    }
834
835    #[test]
836    fn derive_ipv6_slot_0() {
837        assert_eq!(
838            derive_guest_ipv6(default_guest_ipv6_pool(), 0).unwrap(),
839            "fd42:6d73:62:0::2".parse::<Ipv6Addr>().unwrap()
840        );
841        assert_eq!(
842            gateway_from_guest_ipv6(derive_guest_ipv6(default_guest_ipv6_pool(), 0).unwrap()),
843            "fd42:6d73:62:0::1".parse::<Ipv6Addr>().unwrap()
844        );
845    }
846
847    #[test]
848    fn derive_addresses_custom_ipv6_pool() {
849        let pool = "fd7a:115c:a1e0:100::/56".parse::<Ipv6Network>().unwrap();
850        assert_eq!(
851            derive_guest_ipv6(pool, 0).unwrap(),
852            "fd7a:115c:a1e0:100::2".parse::<Ipv6Addr>().unwrap()
853        );
854        assert_eq!(
855            derive_guest_ipv6(pool, 3).unwrap(),
856            "fd7a:115c:a1e0:103::2".parse::<Ipv6Addr>().unwrap()
857        );
858    }
859
860    #[test]
861    fn custom_ipv6_pool_capacity_is_a_typed_error() {
862        let pool = "fd7a:115c:a1e0:100::/62".parse::<Ipv6Network>().unwrap();
863        assert!(matches!(
864            derive_guest_ipv6(pool, 4),
865            Err(NetworkInitError::Ipv6PoolCapacity { slot: 4, .. })
866        ));
867
868        let pool = "fd7a:115c:a1e0:100::/65".parse::<Ipv6Network>().unwrap();
869        assert!(matches!(
870            derive_guest_ipv6(pool, 0),
871            Err(NetworkInitError::Ipv6PoolCapacity { slot: 0, .. })
872        ));
873    }
874
875    #[test]
876    fn format_mac_address() {
877        assert_eq!(
878            format_mac([0x02, 0x6d, 0x73, 0x00, 0x00, 0x01]),
879            "02:6d:73:00:00:01"
880        );
881    }
882
883    #[test]
884    fn guest_env_vars_includes_ipv4_when_host_has_v4_route() {
885        let net = SmoltcpNetwork::build(
886            resolved(NetworkConfig::default()),
887            0,
888            DeploymentProfile::SingleTenant,
889            routes(true, false),
890        )
891        .unwrap();
892        let vars = net.guest_env_vars();
893
894        assert_eq!(vars.len(), 3);
895        assert_eq!(vars[0].0, ENV_NET);
896        assert!(vars[0].1.contains("iface=eth0"));
897        assert_eq!(vars[1].0, ENV_HOST_ALIAS);
898        assert_eq!(vars[1].1, crate::HOST_ALIAS);
899        assert_eq!(vars[2].0, ENV_NET_IPV4);
900        assert!(vars[2].1.contains("/30"));
901    }
902
903    #[test]
904    fn guest_env_vars_includes_ipv6_when_host_has_v6_route() {
905        let net = SmoltcpNetwork::build(
906            resolved(NetworkConfig::default()),
907            0,
908            DeploymentProfile::SingleTenant,
909            routes(true, true),
910        )
911        .unwrap();
912        let vars = net.guest_env_vars();
913
914        assert_eq!(vars.len(), 4);
915        assert_eq!(vars[0].0, ENV_NET);
916        assert_eq!(vars[1].0, ENV_HOST_ALIAS);
917        assert_eq!(vars[2].0, ENV_NET_IPV4);
918        assert_eq!(vars[3].0, ENV_NET_IPV6);
919        assert!(vars[3].1.contains("/64"));
920    }
921
922    #[test]
923    fn guest_env_vars_omit_ipv6_without_host_route() {
924        let net = SmoltcpNetwork::build(
925            resolved(NetworkConfig::default()),
926            0,
927            DeploymentProfile::SingleTenant,
928            routes(true, false),
929        )
930        .unwrap();
931        let vars = net.guest_env_vars();
932
933        assert!(!vars.iter().any(|(k, _)| k == ENV_NET_IPV6));
934    }
935
936    #[test]
937    fn guest_env_vars_omit_ipv4_without_host_route() {
938        let net = SmoltcpNetwork::build(
939            resolved(NetworkConfig::default()),
940            0,
941            DeploymentProfile::SingleTenant,
942            routes(false, true),
943        )
944        .unwrap();
945        let vars = net.guest_env_vars();
946
947        assert_eq!(vars.len(), 3);
948        assert_eq!(vars[0].0, ENV_NET);
949        assert_eq!(vars[1].0, ENV_HOST_ALIAS);
950        assert_eq!(vars[2].0, ENV_NET_IPV6);
951    }
952
953    #[test]
954    fn explicit_ipv6_address_overrides_missing_host_v6_route() {
955        let mut config = NetworkConfig::default();
956        config.interface.ipv6_address = Some("fd42:6d73:62:99::2".parse().unwrap());
957        let net = SmoltcpNetwork::build(
958            resolved(config),
959            0,
960            DeploymentProfile::SingleTenant,
961            routes(true, false),
962        )
963        .unwrap();
964        let vars = net.guest_env_vars();
965
966        let v6 = vars
967            .iter()
968            .find(|(k, _)| k == ENV_NET_IPV6)
969            .expect("explicit ipv6 should publish env var even without host route");
970        assert!(v6.1.contains("fd42:6d73:62:99::2/64"));
971    }
972
973    #[test]
974    fn neither_family_active_emits_only_base_env_vars() {
975        let net = SmoltcpNetwork::build(
976            resolved(NetworkConfig::default()),
977            0,
978            DeploymentProfile::SingleTenant,
979            routes(false, false),
980        )
981        .unwrap();
982        let vars = net.guest_env_vars();
983
984        assert_eq!(vars.len(), 2);
985        assert_eq!(vars[0].0, ENV_NET);
986        assert_eq!(vars[1].0, ENV_HOST_ALIAS);
987    }
988
989    #[test]
990    fn guest_bootstrap_network_preserves_active_address_families() {
991        let net = SmoltcpNetwork::build(
992            resolved(NetworkConfig::default()),
993            7,
994            DeploymentProfile::SingleTenant,
995            routes(true, true),
996        )
997        .unwrap();
998
999        let bootstrap = net.guest_bootstrap_network();
1000
1001        assert_eq!(bootstrap.interface, "eth0");
1002        assert_eq!(bootstrap.mac, net.guest_mac());
1003        assert_eq!(bootstrap.mtu, 1500);
1004        assert_eq!(bootstrap.ipv4.unwrap().prefix_len, 30);
1005        assert_eq!(bootstrap.ipv6.unwrap().prefix_len, 64);
1006        assert_eq!(net.guest_host_alias(), crate::HOST_ALIAS);
1007    }
1008
1009    #[test]
1010    fn guest_bootstrap_network_allows_no_active_address_family() {
1011        let net = SmoltcpNetwork::build(
1012            resolved(NetworkConfig::default()),
1013            0,
1014            DeploymentProfile::SingleTenant,
1015            routes(false, false),
1016        )
1017        .unwrap();
1018
1019        let bootstrap = net.guest_bootstrap_network();
1020
1021        assert!(bootstrap.ipv4.is_none());
1022        assert!(bootstrap.ipv6.is_none());
1023    }
1024
1025    #[test]
1026    fn build_rejects_excessive_max_connections() {
1027        let mut config = NetworkConfig {
1028            max_connections: Some(MAX_NETWORK_CONNECTIONS + 1),
1029            ..NetworkConfig::default()
1030        };
1031        config.tls.enabled = false;
1032
1033        let err = match SmoltcpNetwork::build(
1034            resolved(config),
1035            0,
1036            DeploymentProfile::SingleTenant,
1037            routes(true, false),
1038        ) {
1039            Ok(_) => panic!("excessive max_connections should fail"),
1040            Err(err) => err,
1041        };
1042
1043        assert!(matches!(
1044            err,
1045            NetworkInitError::MaxConnectionsExceeded {
1046                configured,
1047                limit: MAX_NETWORK_CONNECTIONS
1048            } if configured == MAX_NETWORK_CONNECTIONS + 1
1049        ));
1050    }
1051
1052    /// A stored config bypasses the builder's validation, so an invalid
1053    /// limiter must fail startup cleanly instead of panicking.
1054    #[test]
1055    fn build_rejects_invalid_rate_limiter() {
1056        let mut config = NetworkConfig {
1057            rate_limiter: Some(microsandbox_types::NetworkRateLimiterConfig {
1058                egress: None,
1059                ingress: Some(microsandbox_types::RateLimiterConfig {
1060                    bandwidth: None,
1061                    ops: None,
1062                }),
1063            }),
1064            ..NetworkConfig::default()
1065        };
1066        config.tls.enabled = false;
1067
1068        let err = match SmoltcpNetwork::build(
1069            resolved(config),
1070            0,
1071            DeploymentProfile::SingleTenant,
1072            routes(true, false),
1073        ) {
1074            Ok(_) => panic!("empty rate limiter should fail"),
1075            Err(err) => err,
1076        };
1077
1078        assert!(matches!(
1079            err,
1080            NetworkInitError::InvalidRateLimit {
1081                direction: NetworkRateLimitDirection::Ingress,
1082                source: RateLimitConfigError::EmptyLimiter,
1083            }
1084        ));
1085    }
1086}