Skip to main content

microsandbox_network/
network.rs

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