Skip to main content

nucleus/network/
bridge.rs

1use super::{egress, netlink, netns};
2use crate::error::{NucleusError, Result, StateTransition};
3use crate::network::config::{BridgeConfig, EgressPolicy, PortForward};
4use crate::network::NetworkState;
5use serde::{Deserialize, Serialize};
6use std::fs::OpenOptions;
7use std::net::Ipv4Addr;
8use std::os::fd::FromRawFd;
9use std::os::unix::fs::FileTypeExt;
10use std::os::unix::fs::OpenOptionsExt;
11use std::os::unix::io::AsRawFd;
12use std::os::unix::process::CommandExt;
13use std::process::Command;
14use tracing::{debug, info, warn};
15
16/// Bridge network manager
17pub struct BridgeNetwork {
18    config: BridgeConfig,
19    container_ip: String,
20    veth_host: String,
21    container_id: String,
22    ip_forward_ref_acquired: bool,
23    state: NetworkState,
24}
25
26const IP_FORWARD_SYSCTL_PATH: &str = "/proc/sys/net/ipv4/ip_forward";
27const IP_FORWARD_LOCK_FILE: &str = ".ip_forward.lock";
28const IP_FORWARD_STATE_FILE: &str = ".ip_forward.state";
29
30#[derive(Debug, Clone, Serialize, Deserialize)]
31struct IpForwardRefState {
32    refcount: u64,
33    original_value: String,
34}
35
36impl BridgeNetwork {
37    fn open_dev_urandom() -> Result<std::fs::File> {
38        let file = OpenOptions::new()
39            .read(true)
40            .custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC)
41            .open("/dev/urandom")
42            .map_err(|e| {
43                NucleusError::NetworkError(format!("Failed to open /dev/urandom: {}", e))
44            })?;
45
46        let metadata = file.metadata().map_err(|e| {
47            NucleusError::NetworkError(format!("Failed to stat /dev/urandom: {}", e))
48        })?;
49        if !metadata.file_type().is_char_device() {
50            return Err(NucleusError::NetworkError(
51                "/dev/urandom is not a character device".to_string(),
52            ));
53        }
54
55        Ok(file)
56    }
57
58    /// Set up bridge networking for a container
59    ///
60    /// Creates bridge, veth pair, assigns IPs, enables NAT.
61    /// Must be called from the parent process after fork (needs host netns).
62    ///
63    /// State transitions: Unconfigured -> Configuring -> Active
64    pub fn setup(pid: u32, config: &BridgeConfig) -> Result<Self> {
65        Self::setup_for(pid, config, &format!("{:x}", pid))
66    }
67
68    /// Set up bridge networking with an explicit container ID for IP tracking.
69    pub fn setup_with_id(pid: u32, config: &BridgeConfig, container_id: &str) -> Result<Self> {
70        Self::setup_for(pid, config, container_id)
71    }
72
73    fn setup_for(pid: u32, config: &BridgeConfig, container_id: &str) -> Result<Self> {
74        // Validate all network parameters before using them in shell commands
75        config.validate()?;
76
77        let mut net_state = NetworkState::Unconfigured;
78        net_state = net_state.transition(NetworkState::Configuring)?;
79
80        let alloc_dir = Self::ip_alloc_dir();
81        let container_ip = Self::reserve_ip_in_dir(
82            &alloc_dir,
83            container_id,
84            &config.subnet,
85            config.container_ip.as_deref(),
86        )?;
87        let prefix = Self::subnet_prefix(&config.subnet);
88
89        // Linux interface names max 15 chars; truncate if needed
90        let veth_host_full = format!("veth-{:x}", pid);
91        let veth_cont_full = format!("vethc-{:x}", pid);
92        let veth_host = veth_host_full[..veth_host_full.len().min(15)].to_string();
93        let veth_container = veth_cont_full[..veth_cont_full.len().min(15)].to_string();
94        let mut rollback = SetupRollback::new(
95            veth_host.clone(),
96            config.subnet.clone(),
97            Some((alloc_dir.clone(), container_id.to_string())),
98        );
99
100        // 1. Create bridge if it doesn't exist
101        Self::ensure_bridge_for(&config.bridge_name, &config.subnet)?;
102
103        // 2. Create veth pair
104        netlink::create_veth(&veth_host, &veth_container)?;
105        rollback.veth_created = true;
106
107        // 3. Attach host end to bridge
108        netlink::set_link_master(&veth_host, &config.bridge_name)?;
109        netlink::set_link_up(&veth_host)?;
110
111        // 4. Move container end to container's network namespace
112        netlink::set_link_netns(&veth_container, pid)?;
113
114        // 5. Configure container interface (inside container netns via setns).
115        // Capture the process start time from /proc to detect PID recycling
116        // between the caller passing the PID and our netns operations.
117        let start_ticks = Self::read_pid_start_ticks(pid);
118        if start_ticks == 0 {
119            drop(rollback);
120            return Err(NucleusError::NetworkError(format!(
121                "Cannot read start_ticks for PID {} – process may have exited",
122                pid
123            )));
124        }
125
126        let container_addr: Ipv4Addr = container_ip.parse().map_err(|e| {
127            NucleusError::NetworkError(format!("invalid container IP '{}': {}", container_ip, e))
128        })?;
129        {
130            let vc = veth_container.clone();
131            netns::in_netns(pid, move || {
132                netlink::add_addr(&vc, container_addr, prefix)?;
133                netlink::set_link_up(&vc)?;
134                netlink::set_link_up("lo")?;
135                Ok(())
136            })?;
137        }
138
139        // Verify PID was not recycled during netns operations
140        let current_ticks = Self::read_pid_start_ticks(pid);
141        if current_ticks != start_ticks {
142            drop(rollback);
143            return Err(NucleusError::NetworkError(format!(
144                "PID {} was recycled during network setup (start_ticks changed: {} -> {})",
145                pid, start_ticks, current_ticks
146            )));
147        }
148
149        // 6. Set default route in container
150        let gateway = Self::gateway_from_subnet(&config.subnet);
151        let gateway_addr: Ipv4Addr = gateway.parse().map_err(|e| {
152            NucleusError::NetworkError(format!("invalid gateway IP '{}': {}", gateway, e))
153        })?;
154        netns::in_netns(pid, move || netlink::add_default_route(gateway_addr))?;
155
156        // 7. Enable NAT (masquerade) on the host
157        Self::run_cmd(
158            "iptables",
159            &[
160                "-t",
161                "nat",
162                "-A",
163                "POSTROUTING",
164                "-s",
165                &config.subnet,
166                "-j",
167                "MASQUERADE",
168            ],
169        )?;
170        rollback.nat_added = true;
171
172        // 8. Enable IP forwarding using a cross-container refcount so one
173        // container cannot disable forwarding while another bridge is still active.
174        Self::acquire_ip_forward_ref()?;
175        rollback.ip_forward_ref_acquired = true;
176
177        // 9. Set up port forwarding rules
178        for pf in &config.port_forwards {
179            Self::setup_port_forward_for(&container_ip, pf)?;
180            rollback
181                .port_forwards
182                .push((container_ip.clone(), pf.clone()));
183        }
184
185        net_state = net_state.transition(NetworkState::Active)?;
186
187        info!(
188            "Bridge network configured: {} -> {} (IP: {})",
189            veth_host, veth_container, container_ip
190        );
191        let ip_forward_ref_acquired = rollback.ip_forward_ref_acquired;
192        rollback.disarm();
193
194        Ok(Self {
195            config: config.clone(),
196            container_ip,
197            veth_host,
198            container_id: container_id.to_string(),
199            ip_forward_ref_acquired,
200            state: net_state,
201        })
202    }
203
204    /// Apply egress policy rules inside the container's network namespace.
205    ///
206    /// Uses iptables OUTPUT chain to restrict outbound connections.
207    /// Must be called after bridge setup while the container netns is reachable.
208    pub fn apply_egress_policy(&self, pid: u32, policy: &EgressPolicy) -> Result<()> {
209        egress::apply_egress_policy(pid, &self.config.dns, policy, false)
210    }
211
212    /// Clean up bridge networking
213    ///
214    /// State transition: Active -> Cleaned
215    pub fn cleanup(mut self) -> Result<()> {
216        self.state = self.state.transition(NetworkState::Cleaned)?;
217
218        // Release the IP allocation
219        Self::release_allocated_ip(&self.container_id);
220
221        // Remove port forwarding rules
222        for pf in &self.config.port_forwards {
223            if let Err(e) = self.cleanup_port_forward(pf) {
224                warn!("Failed to cleanup port forward: {}", e);
225            }
226        }
227
228        // Remove NAT rule
229        let _ = Self::run_cmd(
230            "iptables",
231            &[
232                "-t",
233                "nat",
234                "-D",
235                "POSTROUTING",
236                "-s",
237                &self.config.subnet,
238                "-j",
239                "MASQUERADE",
240            ],
241        );
242
243        // Delete veth pair (deleting one end removes both)
244        let _ = netlink::del_link(&self.veth_host);
245
246        if self.ip_forward_ref_acquired {
247            if let Err(e) = Self::release_ip_forward_ref() {
248                warn!("Failed to release ip_forward refcount: {}", e);
249            } else {
250                self.ip_forward_ref_acquired = false;
251            }
252        }
253
254        info!("Bridge network cleaned up");
255        Ok(())
256    }
257
258    /// Best-effort cleanup for use in Drop. Performs the same teardown as
259    /// `cleanup()` but ignores all errors and skips the state transition
260    /// (which requires ownership).
261    fn cleanup_best_effort(&mut self) {
262        if self.state == NetworkState::Cleaned {
263            return;
264        }
265
266        Self::release_allocated_ip(&self.container_id);
267
268        for pf in &self.config.port_forwards {
269            let _ = self.cleanup_port_forward(pf);
270        }
271
272        let _ = Self::run_cmd(
273            "iptables",
274            &[
275                "-t",
276                "nat",
277                "-D",
278                "POSTROUTING",
279                "-s",
280                &self.config.subnet,
281                "-j",
282                "MASQUERADE",
283            ],
284        );
285
286        let _ = netlink::del_link(&self.veth_host);
287
288        if self.ip_forward_ref_acquired {
289            let _ = Self::release_ip_forward_ref();
290            self.ip_forward_ref_acquired = false;
291        }
292
293        self.state = NetworkState::Cleaned;
294        debug!("Bridge network cleaned up (best-effort via drop)");
295    }
296
297    /// Detect and remove orphaned iptables rules from previous Nucleus runs.
298    ///
299    /// Checks for stale MASQUERADE rules referencing the nucleus subnet that
300    /// have no corresponding running container. Prevents gradual degradation
301    /// of network isolation from accumulated orphaned rules.
302    pub fn cleanup_orphaned_rules(subnet: &str) {
303        // List NAT rules and look for nucleus-related MASQUERADE entries
304        let iptables = match Self::resolve_bin("iptables") {
305            Ok(path) => path,
306            Err(e) => {
307                debug!("Cannot resolve iptables for orphaned rule cleanup: {}", e);
308                return;
309            }
310        };
311        let output = match Command::new(&iptables)
312            .args(["-t", "nat", "-L", "POSTROUTING", "-n"])
313            .output()
314        {
315            Ok(o) => o,
316            Err(e) => {
317                debug!("Cannot check iptables for orphaned rules: {}", e);
318                return;
319            }
320        };
321
322        let stdout = String::from_utf8_lossy(&output.stdout);
323        let mut orphaned_count = 0u32;
324        for line in stdout.lines() {
325            if line.contains("MASQUERADE") && line.contains(subnet) {
326                // Try to remove it; if it fails, it may be actively used
327                let _ = Self::run_cmd(
328                    "iptables",
329                    &[
330                        "-t",
331                        "nat",
332                        "-D",
333                        "POSTROUTING",
334                        "-s",
335                        subnet,
336                        "-j",
337                        "MASQUERADE",
338                    ],
339                );
340                orphaned_count += 1;
341            }
342        }
343
344        if orphaned_count > 0 {
345            info!(
346                "Cleaned up {} orphaned iptables MASQUERADE rule(s) for subnet {}",
347                orphaned_count, subnet
348            );
349        }
350    }
351
352    fn ensure_bridge_for(bridge_name: &str, subnet: &str) -> Result<()> {
353        if netlink::link_exists(bridge_name) {
354            return Ok(());
355        }
356
357        netlink::create_bridge(bridge_name)?;
358
359        let gateway = Self::gateway_from_subnet(subnet);
360        let gateway_addr: Ipv4Addr = gateway.parse().map_err(|e| {
361            NucleusError::NetworkError(format!("invalid bridge gateway '{}': {}", gateway, e))
362        })?;
363        netlink::add_addr(bridge_name, gateway_addr, Self::subnet_prefix(subnet))?;
364        netlink::set_link_up(bridge_name)?;
365
366        info!("Created bridge {}", bridge_name);
367        Ok(())
368    }
369
370    fn setup_port_forward_for(container_ip: &str, pf: &PortForward) -> Result<()> {
371        for chain in ["PREROUTING", "OUTPUT"] {
372            let args = Self::port_forward_rule_args("-A", chain, container_ip, pf);
373            Self::run_cmd_owned("iptables", &args)?;
374        }
375
376        let host_ip = pf
377            .host_ip
378            .map(|ip| ip.to_string())
379            .unwrap_or_else(|| "0.0.0.0".to_string());
380        info!(
381            "Port forward: {}:{} -> {}:{}/{}",
382            host_ip, pf.host_port, container_ip, pf.container_port, pf.protocol
383        );
384        Ok(())
385    }
386
387    fn cleanup_port_forward(&self, pf: &PortForward) -> Result<()> {
388        for chain in ["OUTPUT", "PREROUTING"] {
389            let args = Self::port_forward_rule_args("-D", chain, &self.container_ip, pf);
390            Self::run_cmd_owned("iptables", &args)?;
391        }
392        Ok(())
393    }
394
395    /// Allocate a container IP from the subnet using /dev/urandom.
396    ///
397    /// Checks both host-visible interfaces (via `ip addr`) and IPs assigned to
398    /// other Nucleus containers (via state files) to avoid duplicates. Container
399    /// IPs inside network namespaces are invisible to `ip addr show` on the host.
400    fn allocate_ip_with_reserved(
401        subnet: &str,
402        reserved: &std::collections::HashSet<String>,
403    ) -> Result<String> {
404        let base = subnet.split('/').next().unwrap_or("10.0.42.0");
405        let parts: Vec<&str> = base.split('.').collect();
406        if parts.len() != 4 {
407            return Ok("10.0.42.2".to_string());
408        }
409
410        // Use rejection sampling to avoid modulo bias.
411        // Range is 2..=254 (253 values). We reject random bytes >= 253 to
412        // ensure uniform distribution, then add 2 to shift into the valid range.
413        // Open /dev/urandom once and read all randomness in a single batch.
414        // 128 bytes gives ~125 valid candidates (byte < 253), making exhaustion
415        // in a populated subnet far less likely than the previous 32-byte buffer.
416        let mut rand_buf = [0u8; 128];
417        let mut urandom = Self::open_dev_urandom()?;
418        std::io::Read::read_exact(&mut urandom, &mut rand_buf).map_err(|e| {
419            NucleusError::NetworkError(format!("Failed to read /dev/urandom: {}", e))
420        })?;
421        for &byte in &rand_buf {
422            // Rejection sampling: discard values that would cause modulo bias
423            if byte >= 253 {
424                continue;
425            }
426            let offset = byte as u32 + 2;
427            let candidate = format!("{}.{}.{}.{}", parts[0], parts[1], parts[2], offset);
428            if reserved.contains(&candidate) {
429                continue;
430            }
431            if !Self::is_ip_in_use(&candidate)? {
432                // Lock is released when lock_file is dropped
433                return Ok(candidate);
434            }
435        }
436
437        Err(NucleusError::NetworkError(format!(
438            "Failed to allocate free IP in subnet {}",
439            subnet
440        )))
441    }
442
443    fn reserve_ip_in_dir(
444        alloc_dir: &std::path::Path,
445        container_id: &str,
446        subnet: &str,
447        requested_ip: Option<&str>,
448    ) -> Result<String> {
449        Self::ensure_alloc_dir(alloc_dir)?;
450        let lock_path = alloc_dir.join(".lock");
451        let lock_file = std::fs::OpenOptions::new()
452            .create(true)
453            .write(true)
454            .truncate(false)
455            .open(&lock_path)
456            .map_err(|e| {
457                NucleusError::NetworkError(format!("Failed to open IP alloc lock: {}", e))
458            })?;
459        // SAFETY: lock_file is a valid open fd. LOCK_EX is a blocking exclusive
460        // lock that is released when the fd is closed (end of scope).
461        let lock_ret = unsafe { libc::flock(lock_file.as_raw_fd(), libc::LOCK_EX) };
462        if lock_ret != 0 {
463            return Err(NucleusError::NetworkError(format!(
464                "Failed to acquire IP alloc lock: {}",
465                std::io::Error::last_os_error()
466            )));
467        }
468
469        let reserved = Self::collect_reserved_ips_in_dir(alloc_dir);
470        let ip = match requested_ip {
471            Some(ip) => {
472                if reserved.contains(ip) || Self::is_ip_in_use(ip)? {
473                    return Err(NucleusError::NetworkError(format!(
474                        "Requested container IP {} is already in use",
475                        ip
476                    )));
477                }
478                ip.to_string()
479            }
480            None => Self::allocate_ip_with_reserved(subnet, &reserved)?,
481        };
482
483        Self::record_allocated_ip_in_dir(alloc_dir, container_id, &ip)?;
484        Ok(ip)
485    }
486
487    /// Scan the Nucleus IP allocation directory for IPs already assigned.
488    fn collect_reserved_ips_in_dir(
489        alloc_dir: &std::path::Path,
490    ) -> std::collections::HashSet<String> {
491        let mut ips = std::collections::HashSet::new();
492        if let Ok(entries) = std::fs::read_dir(alloc_dir) {
493            for entry in entries.flatten() {
494                if let Some(name) = entry.file_name().to_str() {
495                    if name.ends_with(".ip") {
496                        if let Ok(ip) = std::fs::read_to_string(entry.path()) {
497                            let ip = ip.trim().to_string();
498                            if !ip.is_empty() {
499                                ips.insert(ip);
500                            }
501                        }
502                    }
503                }
504            }
505        }
506        ips
507    }
508
509    /// Persist the allocated IP for this container so other containers can see it.
510    fn record_allocated_ip_in_dir(
511        alloc_dir: &std::path::Path,
512        container_id: &str,
513        ip: &str,
514    ) -> Result<()> {
515        Self::ensure_alloc_dir(alloc_dir)?;
516        let path = alloc_dir.join(format!("{}.ip", container_id));
517        std::fs::write(&path, ip).map_err(|e| {
518            NucleusError::NetworkError(format!("Failed to record IP allocation: {}", e))
519        })?;
520        Ok(())
521    }
522
523    /// Remove the persisted IP allocation for a container.
524    fn release_allocated_ip(container_id: &str) {
525        let alloc_dir = Self::ip_alloc_dir();
526        Self::release_allocated_ip_in_dir(&alloc_dir, container_id);
527    }
528
529    fn release_allocated_ip_in_dir(alloc_dir: &std::path::Path, container_id: &str) {
530        let path = alloc_dir.join(format!("{}.ip", container_id));
531        let _ = std::fs::remove_file(path);
532    }
533
534    /// Create the IP allocation directory with restrictive permissions (0700)
535    /// and reject symlinked paths to prevent symlink attacks.
536    fn ensure_alloc_dir(alloc_dir: &std::path::Path) -> Result<()> {
537        // L11: Check for symlinks BEFORE creating directories to avoid TOCTOU.
538        // If the path already exists, verify it's not a symlink.
539        if alloc_dir.exists() {
540            if let Ok(meta) = std::fs::symlink_metadata(alloc_dir) {
541                if meta.file_type().is_symlink() {
542                    return Err(NucleusError::NetworkError(format!(
543                        "IP alloc dir {:?} is a symlink, refusing to use",
544                        alloc_dir
545                    )));
546                }
547            }
548        }
549        // Also check parent directory for symlinks
550        if let Some(parent) = alloc_dir.parent() {
551            if let Ok(meta) = std::fs::symlink_metadata(parent) {
552                if meta.file_type().is_symlink() {
553                    return Err(NucleusError::NetworkError(format!(
554                        "IP alloc dir parent {:?} is a symlink, refusing to use",
555                        parent
556                    )));
557                }
558            }
559        }
560
561        std::fs::create_dir_all(alloc_dir).map_err(|e| {
562            NucleusError::NetworkError(format!("Failed to create IP alloc dir: {}", e))
563        })?;
564
565        // Restrict permissions to owner-only atomically after creation
566        use std::os::unix::fs::PermissionsExt;
567        let perms = std::fs::Permissions::from_mode(0o700);
568        std::fs::set_permissions(alloc_dir, perms).map_err(|e| {
569            NucleusError::NetworkError(format!(
570                "Failed to set permissions on IP alloc dir {:?}: {}",
571                alloc_dir, e
572            ))
573        })?;
574
575        // Re-verify no symlink replacement after permissions were set
576        if let Ok(meta) = std::fs::symlink_metadata(alloc_dir) {
577            if meta.file_type().is_symlink() {
578                return Err(NucleusError::NetworkError(format!(
579                    "IP alloc dir {:?} was replaced with a symlink during setup",
580                    alloc_dir
581                )));
582            }
583        }
584        Ok(())
585    }
586
587    fn ip_alloc_dir() -> std::path::PathBuf {
588        if nix::unistd::Uid::effective().is_root() {
589            std::path::PathBuf::from("/var/run/nucleus/ip-alloc")
590        } else {
591            dirs::runtime_dir()
592                .map(|d| d.join("nucleus/ip-alloc"))
593                .or_else(|| dirs::data_local_dir().map(|d| d.join("nucleus/ip-alloc")))
594                .unwrap_or_else(|| {
595                    dirs::home_dir()
596                        .map(|h| h.join(".nucleus/ip-alloc"))
597                        .unwrap_or_else(|| std::path::PathBuf::from("/var/run/nucleus/ip-alloc"))
598                })
599        }
600    }
601
602    fn ip_forward_lock_path(alloc_dir: &std::path::Path) -> std::path::PathBuf {
603        alloc_dir.join(IP_FORWARD_LOCK_FILE)
604    }
605
606    fn ip_forward_state_path(alloc_dir: &std::path::Path) -> std::path::PathBuf {
607        alloc_dir.join(IP_FORWARD_STATE_FILE)
608    }
609
610    fn read_ip_forward_value(sysctl_path: &std::path::Path) -> Result<String> {
611        std::fs::read_to_string(sysctl_path)
612            .map(|value| value.trim().to_string())
613            .map_err(|e| {
614                NucleusError::NetworkError(format!(
615                    "Failed to read {}: {}",
616                    sysctl_path.display(),
617                    e
618                ))
619            })
620    }
621
622    fn write_ip_forward_value(sysctl_path: &std::path::Path, value: &str) -> Result<()> {
623        std::fs::write(sysctl_path, value).map_err(|e| {
624            NucleusError::NetworkError(format!(
625                "Failed to write {} to {}: {}",
626                value,
627                sysctl_path.display(),
628                e
629            ))
630        })
631    }
632
633    fn load_ip_forward_state(alloc_dir: &std::path::Path) -> Result<Option<IpForwardRefState>> {
634        let state_path = Self::ip_forward_state_path(alloc_dir);
635        match std::fs::read_to_string(&state_path) {
636            Ok(content) => serde_json::from_str(&content).map(Some).map_err(|e| {
637                NucleusError::NetworkError(format!(
638                    "Failed to parse ip_forward refcount state {:?}: {}",
639                    state_path, e
640                ))
641            }),
642            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
643            Err(e) => Err(NucleusError::NetworkError(format!(
644                "Failed to read ip_forward refcount state {:?}: {}",
645                state_path, e
646            ))),
647        }
648    }
649
650    fn store_ip_forward_state(
651        alloc_dir: &std::path::Path,
652        state: &IpForwardRefState,
653    ) -> Result<()> {
654        let state_path = Self::ip_forward_state_path(alloc_dir);
655        let encoded = serde_json::to_vec(state).map_err(|e| {
656            NucleusError::NetworkError(format!(
657                "Failed to serialize ip_forward refcount state {:?}: {}",
658                state_path, e
659            ))
660        })?;
661        std::fs::write(&state_path, encoded).map_err(|e| {
662            NucleusError::NetworkError(format!(
663                "Failed to persist ip_forward refcount state {:?}: {}",
664                state_path, e
665            ))
666        })
667    }
668
669    fn remove_ip_forward_state(alloc_dir: &std::path::Path) -> Result<()> {
670        let state_path = Self::ip_forward_state_path(alloc_dir);
671        match std::fs::remove_file(&state_path) {
672            Ok(()) => Ok(()),
673            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
674            Err(e) => Err(NucleusError::NetworkError(format!(
675                "Failed to remove ip_forward refcount state {:?}: {}",
676                state_path, e
677            ))),
678        }
679    }
680
681    fn acquire_ip_forward_ref() -> Result<()> {
682        let alloc_dir = Self::ip_alloc_dir();
683        Self::acquire_ip_forward_ref_in_dir(
684            &alloc_dir,
685            std::path::Path::new(IP_FORWARD_SYSCTL_PATH),
686        )
687    }
688
689    fn acquire_ip_forward_ref_in_dir(
690        alloc_dir: &std::path::Path,
691        sysctl_path: &std::path::Path,
692    ) -> Result<()> {
693        Self::ensure_alloc_dir(alloc_dir)?;
694        let lock_path = Self::ip_forward_lock_path(alloc_dir);
695        let lock_file = std::fs::OpenOptions::new()
696            .create(true)
697            .write(true)
698            .truncate(false)
699            .open(&lock_path)
700            .map_err(|e| {
701                NucleusError::NetworkError(format!(
702                    "Failed to open ip_forward lock {:?}: {}",
703                    lock_path, e
704                ))
705            })?;
706        let lock_ret = unsafe { libc::flock(lock_file.as_raw_fd(), libc::LOCK_EX) };
707        if lock_ret != 0 {
708            return Err(NucleusError::NetworkError(format!(
709                "Failed to acquire ip_forward lock: {}",
710                std::io::Error::last_os_error()
711            )));
712        }
713
714        let mut state = match Self::load_ip_forward_state(alloc_dir)? {
715            Some(state) => state,
716            None => {
717                let original_value = Self::read_ip_forward_value(sysctl_path)?;
718                let state = IpForwardRefState {
719                    refcount: 0,
720                    original_value,
721                };
722                Self::store_ip_forward_state(alloc_dir, &state)?;
723                state
724            }
725        };
726
727        if state.refcount == 0 {
728            Self::write_ip_forward_value(sysctl_path, "1")?;
729        }
730        state.refcount = state.refcount.checked_add(1).ok_or_else(|| {
731            NucleusError::NetworkError("ip_forward refcount overflow".to_string())
732        })?;
733        Self::store_ip_forward_state(alloc_dir, &state)
734    }
735
736    fn release_ip_forward_ref() -> Result<()> {
737        let alloc_dir = Self::ip_alloc_dir();
738        Self::release_ip_forward_ref_in_dir(
739            &alloc_dir,
740            std::path::Path::new(IP_FORWARD_SYSCTL_PATH),
741        )
742    }
743
744    fn release_ip_forward_ref_in_dir(
745        alloc_dir: &std::path::Path,
746        sysctl_path: &std::path::Path,
747    ) -> Result<()> {
748        if !alloc_dir.exists() {
749            return Ok(());
750        }
751        let lock_path = Self::ip_forward_lock_path(alloc_dir);
752        let lock_file = std::fs::OpenOptions::new()
753            .create(true)
754            .write(true)
755            .truncate(false)
756            .open(&lock_path)
757            .map_err(|e| {
758                NucleusError::NetworkError(format!(
759                    "Failed to open ip_forward lock {:?}: {}",
760                    lock_path, e
761                ))
762            })?;
763        let lock_ret = unsafe { libc::flock(lock_file.as_raw_fd(), libc::LOCK_EX) };
764        if lock_ret != 0 {
765            return Err(NucleusError::NetworkError(format!(
766                "Failed to acquire ip_forward lock: {}",
767                std::io::Error::last_os_error()
768            )));
769        }
770
771        let Some(mut state) = Self::load_ip_forward_state(alloc_dir)? else {
772            return Ok(());
773        };
774
775        if state.refcount == 0 {
776            return Self::remove_ip_forward_state(alloc_dir);
777        }
778
779        state.refcount -= 1;
780        if state.refcount == 0 {
781            Self::write_ip_forward_value(sysctl_path, &state.original_value)?;
782            Self::remove_ip_forward_state(alloc_dir)?;
783            info!("Restored net.ipv4.ip_forward to {}", state.original_value);
784        } else {
785            Self::store_ip_forward_state(alloc_dir, &state)?;
786        }
787
788        Ok(())
789    }
790
791    /// Read the start time (field 22) from /proc/<pid>/stat to detect PID recycling.
792    /// Returns 0 if the process does not exist or the field cannot be parsed.
793    fn read_pid_start_ticks(pid: u32) -> u64 {
794        let stat_path = format!("/proc/{}/stat", pid);
795        if let Ok(content) = std::fs::read_to_string(&stat_path) {
796            // Field 22 is starttime. The comm field (2) may contain spaces/parens,
797            // so find the last ')' and count fields from there.
798            if let Some(after_comm) = content.rfind(')') {
799                return content[after_comm + 2..]
800                    .split_whitespace()
801                    .nth(19) // field 22 is 20th after the ')' + state field
802                    .and_then(|s| s.parse().ok())
803                    .unwrap_or(0);
804            }
805        }
806        0
807    }
808
809    /// Get gateway IP from subnet (first usable address)
810    fn gateway_from_subnet(subnet: &str) -> String {
811        let base = subnet.split('/').next().unwrap_or("10.0.42.0");
812        let parts: Vec<&str> = base.split('.').collect();
813        if parts.len() == 4 {
814            format!("{}.{}.{}.1", parts[0], parts[1], parts[2])
815        } else {
816            "10.0.42.1".to_string()
817        }
818    }
819
820    fn subnet_prefix(subnet: &str) -> u8 {
821        subnet
822            .split_once('/')
823            .and_then(|(_, p)| p.parse::<u8>().ok())
824            .filter(|p| *p <= 32)
825            .unwrap_or(24)
826    }
827
828    /// Resolve a system binary to a validated absolute path.
829    ///
830    /// Searches known sysadmin paths first while validating ownership and
831    /// permissions before use. When running as root, the process PATH is never
832    /// consulted because bridge networking invokes privileged helpers and must
833    /// not trust an attacker-influenced environment. This also avoids depending
834    /// on a separate `which` binary in service managers that set a narrow PATH.
835    /// Returns an error if no valid binary is found.
836    pub(crate) fn resolve_bin(name: &str) -> Result<String> {
837        let search_dirs: &[&str] = match name {
838            "iptables" => &[
839                "/usr/sbin/iptables",
840                "/sbin/iptables",
841                "/usr/bin/iptables",
842                "/run/current-system/sw/bin/iptables",
843            ],
844            "slirp4netns" => &[
845                "/usr/bin/slirp4netns",
846                "/bin/slirp4netns",
847                "/run/current-system/sw/bin/slirp4netns",
848            ],
849            _ => &[],
850        };
851
852        for path in search_dirs {
853            let p = std::path::Path::new(path);
854            if p.exists() {
855                Self::validate_network_binary(p, name)?;
856                let resolved = std::fs::canonicalize(p).map_err(|e| {
857                    NucleusError::NetworkError(format!(
858                        "Cannot canonicalize {} at {:?}: {}",
859                        name, p, e
860                    ))
861                })?;
862                return Ok(resolved.to_string_lossy().into_owned());
863            }
864        }
865
866        if nix::unistd::Uid::effective().is_root() {
867            return Err(NucleusError::NetworkError(format!(
868                "Required binary '{}' not found in trusted system paths",
869                name
870            )));
871        }
872
873        if let Some(path_var) = std::env::var_os("PATH") {
874            for dir in std::env::split_paths(&path_var) {
875                let candidate = dir.join(name);
876                if candidate.exists() {
877                    Self::validate_network_binary(&candidate, name)?;
878                    let resolved = std::fs::canonicalize(&candidate).map_err(|e| {
879                        NucleusError::NetworkError(format!(
880                            "Cannot canonicalize {} at {:?}: {}",
881                            name, candidate, e
882                        ))
883                    })?;
884                    return Ok(resolved.to_string_lossy().into_owned());
885                }
886            }
887        }
888
889        Err(NucleusError::NetworkError(format!(
890            "Required binary '{}' not found or failed validation",
891            name
892        )))
893    }
894
895    /// Validate a network binary's ownership and permissions.
896    /// Rejects binaries that are group/world-writable or not owned by root/euid,
897    /// except for immutable Nix store artifacts.
898    fn validate_network_binary(path: &std::path::Path, name: &str) -> Result<()> {
899        use std::os::unix::fs::MetadataExt;
900
901        let resolved = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
902        let meta = std::fs::metadata(&resolved)
903            .map_err(|e| NucleusError::NetworkError(format!("Cannot stat {}: {}", name, e)))?;
904        let mode = meta.mode();
905        if mode & 0o111 == 0 {
906            return Err(NucleusError::NetworkError(format!(
907                "Binary '{}' at {:?} is not executable, refusing to execute",
908                name, resolved
909            )));
910        }
911        if mode & 0o022 != 0 {
912            return Err(NucleusError::NetworkError(format!(
913                "Binary '{}' at {:?} is writable by group/others (mode {:o}), refusing to execute",
914                name, resolved, mode
915            )));
916        }
917        let owner = meta.uid();
918        let euid = nix::unistd::Uid::effective().as_raw();
919        if owner != 0 && owner != euid && !Self::is_trusted_store_network_binary(&resolved, mode) {
920            return Err(NucleusError::NetworkError(format!(
921                "Binary '{}' at {:?} owned by UID {} (expected root or euid {}), refusing to execute",
922                name, resolved, owner, euid
923            )));
924        }
925        Ok(())
926    }
927
928    fn is_trusted_store_network_binary(path: &std::path::Path, mode: u32) -> bool {
929        use std::os::unix::fs::MetadataExt;
930        if !path.starts_with("/nix/store") {
931            return false;
932        }
933        if mode & 0o200 != 0 {
934            return false;
935        }
936        if let Some(parent) = path.parent() {
937            if let Ok(parent_meta) = std::fs::metadata(parent) {
938                return parent_meta.mode() & 0o222 == 0;
939            }
940        }
941        false
942    }
943
944    fn run_cmd(program: &str, args: &[&str]) -> Result<()> {
945        let resolved = Self::resolve_bin(program)?;
946        // Nix's iptables package exposes applets as symlinks to xtables-*-multi.
947        // Keep the requested applet in argv[0] after canonicalization.
948        let output = Command::new(&resolved)
949            .arg0(program)
950            .args(args)
951            .output()
952            .map_err(|e| {
953                NucleusError::NetworkError(format!("Failed to run {} {:?}: {}", resolved, args, e))
954            })?;
955
956        if !output.status.success() {
957            let stderr = String::from_utf8_lossy(&output.stderr);
958            return Err(NucleusError::NetworkError(format!(
959                "{} {:?} failed: {}",
960                program, args, stderr
961            )));
962        }
963
964        Ok(())
965    }
966
967    fn run_cmd_owned(program: &str, args: &[String]) -> Result<()> {
968        let refs: Vec<&str> = args.iter().map(String::as_str).collect();
969        Self::run_cmd(program, &refs)
970    }
971
972    fn port_forward_rule_args(
973        operation: &str,
974        chain: &str,
975        container_ip: &str,
976        pf: &PortForward,
977    ) -> Vec<String> {
978        let mut args = vec![
979            "-t".to_string(),
980            "nat".to_string(),
981            operation.to_string(),
982            chain.to_string(),
983            "-p".to_string(),
984            pf.protocol.as_str().to_string(),
985        ];
986
987        if chain == "OUTPUT" {
988            args.extend([
989                "-m".to_string(),
990                "addrtype".to_string(),
991                "--dst-type".to_string(),
992                "LOCAL".to_string(),
993            ]);
994        }
995
996        if let Some(host_ip) = pf.host_ip {
997            args.extend(["-d".to_string(), host_ip.to_string()]);
998        }
999
1000        args.extend([
1001            "--dport".to_string(),
1002            pf.host_port.to_string(),
1003            "-j".to_string(),
1004            "DNAT".to_string(),
1005            "--to-destination".to_string(),
1006            format!("{}:{}", container_ip, pf.container_port),
1007        ]);
1008
1009        args
1010    }
1011
1012    fn is_ip_in_use(ip: &str) -> Result<bool> {
1013        let addr: Ipv4Addr = ip
1014            .parse()
1015            .map_err(|e| NucleusError::NetworkError(format!("invalid IP '{}': {}", ip, e)))?;
1016        netlink::is_addr_in_use(&addr)
1017    }
1018
1019    /// Write resolv.conf inside container (for writable /etc, e.g. agent mode)
1020    pub fn write_resolv_conf(root: &std::path::Path, dns: &[String]) -> Result<()> {
1021        let resolv_path = root.join("etc/resolv.conf");
1022        let content: String = dns
1023            .iter()
1024            .map(|server| format!("nameserver {}\n", server))
1025            .collect();
1026        std::fs::write(&resolv_path, content).map_err(|e| {
1027            NucleusError::NetworkError(format!("Failed to write resolv.conf: {}", e))
1028        })?;
1029        Ok(())
1030    }
1031
1032    /// Bind-mount a resolv.conf over a read-only /etc (for production rootfs mode).
1033    ///
1034    /// Creates a memfd-backed resolv.conf and bind-mounts it over
1035    /// /etc/resolv.conf so it works even when the rootfs /etc is read-only.
1036    /// The memfd is cleaned up when the container exits.
1037    pub fn bind_mount_resolv_conf(root: &std::path::Path, dns: &[String]) -> Result<()> {
1038        use nix::mount::{mount, MsFlags};
1039
1040        let content: String = dns
1041            .iter()
1042            .map(|server| format!("nameserver {}\n", server))
1043            .collect();
1044
1045        // Create a memfd-backed file to avoid leaving staging files on disk
1046        let memfd_name = std::ffi::CString::new("nucleus-resolv").map_err(|e| {
1047            NucleusError::NetworkError(format!("Failed to create memfd name: {}", e))
1048        })?;
1049        // SAFETY: memfd_name is a valid NUL-terminated CString. memfd_create
1050        // returns a new fd or -1 on error; we check for error below.
1051        let raw_fd = unsafe { libc::memfd_create(memfd_name.as_ptr(), 0) };
1052        if raw_fd < 0 {
1053            // Fallback to staging file if memfd_create is unavailable
1054            return Self::bind_mount_resolv_conf_staging(root, dns);
1055        }
1056        // SAFETY: raw_fd is a valid, newly-created fd from memfd_create.
1057        // OwnedFd takes ownership and will close it exactly once on drop,
1058        // preventing double-close on any error path.
1059        let memfd = unsafe { std::os::fd::OwnedFd::from_raw_fd(raw_fd) };
1060
1061        // Write content to memfd using File I/O to handle partial writes correctly.
1062        use std::io::Write as _;
1063        let mut memfd_file = std::fs::File::from(memfd);
1064        if memfd_file.write_all(content.as_bytes()).is_err() {
1065            // memfd_file dropped here, closing the fd automatically
1066            return Self::bind_mount_resolv_conf_staging(root, dns);
1067        }
1068        // Re-extract the OwnedFd for the proc path below
1069        use std::os::fd::IntoRawFd;
1070        let memfd = {
1071            let raw = memfd_file.into_raw_fd();
1072            // SAFETY: raw is the valid fd we just extracted from the File.
1073            unsafe { std::os::fd::OwnedFd::from_raw_fd(raw) }
1074        };
1075
1076        // Ensure the mount target exists
1077        let target = root.join("etc/resolv.conf");
1078        if !target.exists() {
1079            let _ = std::fs::write(&target, "");
1080        }
1081
1082        // Bind mount the memfd over the read-only resolv.conf
1083        let memfd_path = format!("/proc/self/fd/{}", memfd.as_raw_fd());
1084        if let Err(e) = mount(
1085            Some(memfd_path.as_str()),
1086            &target,
1087            None::<&str>,
1088            MsFlags::MS_BIND,
1089            None::<&str>,
1090        ) {
1091            return Err(NucleusError::NetworkError(format!(
1092                "Failed to bind mount memfd-backed resolv.conf: {}",
1093                e
1094            )));
1095        }
1096        Self::harden_resolv_conf_bind(&target)?;
1097
1098        // memfd dropped here – the mount holds a kernel reference to the file,
1099        // so it survives the fd close.
1100
1101        info!("Bind-mounted resolv.conf for bridge networking (rootfs mode, memfd)");
1102        Ok(())
1103    }
1104
1105    /// Fallback: bind-mount a staging resolv.conf file.
1106    fn bind_mount_resolv_conf_staging(root: &std::path::Path, dns: &[String]) -> Result<()> {
1107        use nix::mount::{mount, MsFlags};
1108
1109        let content: String = dns
1110            .iter()
1111            .map(|server| format!("nameserver {}\n", server))
1112            .collect();
1113
1114        let staging = Self::create_resolv_conf_staging_file(root, content.as_bytes())?;
1115
1116        // Ensure the mount target exists
1117        let target = root.join("etc/resolv.conf");
1118        if !target.exists() {
1119            let _ = std::fs::write(&target, "");
1120        }
1121
1122        // Bind mount the staging file over the read-only resolv.conf
1123        mount(
1124            Some(staging.path()),
1125            &target,
1126            None::<&str>,
1127            MsFlags::MS_BIND,
1128            None::<&str>,
1129        )
1130        .map_err(|e| {
1131            NucleusError::NetworkError(format!("Failed to bind mount resolv.conf: {}", e))
1132        })?;
1133        Self::harden_resolv_conf_bind(&target)?;
1134
1135        // The bind mount holds a reference to the inode. Dropping the temporary
1136        // file unlinks the staging path so DNS server info is not left on disk.
1137
1138        info!("Bind-mounted resolv.conf for bridge networking (rootfs mode, staging)");
1139        Ok(())
1140    }
1141
1142    fn create_resolv_conf_staging_file(
1143        root: &std::path::Path,
1144        content: &[u8],
1145    ) -> Result<tempfile::NamedTempFile> {
1146        use std::io::Write as _;
1147
1148        let staging_dir = root.parent().ok_or_else(|| {
1149            NucleusError::NetworkError(format!(
1150                "Container root {:?} has no parent for resolv.conf staging",
1151                root
1152            ))
1153        })?;
1154
1155        let mut staging = tempfile::Builder::new()
1156            .prefix(".resolv.conf.nucleus.")
1157            .tempfile_in(staging_dir)
1158            .map_err(|e| {
1159                NucleusError::NetworkError(format!(
1160                    "Failed to create temporary resolv.conf staging file under {:?}: {}",
1161                    staging_dir, e
1162                ))
1163            })?;
1164
1165        staging.as_file_mut().write_all(content).map_err(|e| {
1166            NucleusError::NetworkError(format!(
1167                "Failed to write temporary resolv.conf staging file {:?}: {}",
1168                staging.path(),
1169                e
1170            ))
1171        })?;
1172
1173        Ok(staging)
1174    }
1175
1176    fn harden_resolv_conf_bind(target: &std::path::Path) -> Result<()> {
1177        use nix::mount::{mount, MsFlags};
1178
1179        mount(
1180            None::<&str>,
1181            target,
1182            None::<&str>,
1183            MsFlags::MS_REMOUNT
1184                | MsFlags::MS_BIND
1185                | MsFlags::MS_RDONLY
1186                | MsFlags::MS_NOSUID
1187                | MsFlags::MS_NODEV
1188                | MsFlags::MS_NOEXEC,
1189            None::<&str>,
1190        )
1191        .map_err(|e| {
1192            NucleusError::NetworkError(format!(
1193                "Failed to remount resolv.conf with hardened flags at {:?}: {}",
1194                target, e
1195            ))
1196        })
1197    }
1198}
1199
1200impl Drop for BridgeNetwork {
1201    fn drop(&mut self) {
1202        self.cleanup_best_effort();
1203    }
1204}
1205
1206struct SetupRollback {
1207    veth_host: String,
1208    subnet: String,
1209    veth_created: bool,
1210    nat_added: bool,
1211    port_forwards: Vec<(String, PortForward)>,
1212    ip_forward_ref_acquired: bool,
1213    reserved_ip: Option<(std::path::PathBuf, String)>,
1214    armed: bool,
1215}
1216
1217impl SetupRollback {
1218    fn new(
1219        veth_host: String,
1220        subnet: String,
1221        reserved_ip: Option<(std::path::PathBuf, String)>,
1222    ) -> Self {
1223        Self {
1224            veth_host,
1225            subnet,
1226            veth_created: false,
1227            nat_added: false,
1228            port_forwards: Vec::new(),
1229            ip_forward_ref_acquired: false,
1230            reserved_ip,
1231            armed: true,
1232        }
1233    }
1234
1235    fn disarm(&mut self) {
1236        self.armed = false;
1237    }
1238}
1239
1240impl Drop for SetupRollback {
1241    fn drop(&mut self) {
1242        if !self.armed {
1243            return;
1244        }
1245
1246        for (container_ip, pf) in self.port_forwards.iter().rev() {
1247            for chain in ["OUTPUT", "PREROUTING"] {
1248                let args = BridgeNetwork::port_forward_rule_args("-D", chain, container_ip, pf);
1249                if let Err(e) = BridgeNetwork::run_cmd_owned("iptables", &args) {
1250                    warn!(
1251                        "Rollback: failed to remove iptables {} rule for {}: {}",
1252                        chain, container_ip, e
1253                    );
1254                }
1255            }
1256        }
1257
1258        if self.nat_added {
1259            if let Err(e) = BridgeNetwork::run_cmd(
1260                "iptables",
1261                &[
1262                    "-t",
1263                    "nat",
1264                    "-D",
1265                    "POSTROUTING",
1266                    "-s",
1267                    &self.subnet,
1268                    "-j",
1269                    "MASQUERADE",
1270                ],
1271            ) {
1272                warn!("Rollback: failed to remove NAT rule: {}", e);
1273            }
1274        }
1275
1276        if self.veth_created {
1277            if let Err(e) = netlink::del_link(&self.veth_host) {
1278                warn!("Rollback: failed to delete veth {}: {}", self.veth_host, e);
1279            }
1280        }
1281
1282        if self.ip_forward_ref_acquired {
1283            if let Err(e) = BridgeNetwork::release_ip_forward_ref() {
1284                warn!("Rollback: failed to release ip_forward refcount: {}", e);
1285            }
1286        }
1287
1288        if let Some((alloc_dir, container_id)) = &self.reserved_ip {
1289            BridgeNetwork::release_allocated_ip_in_dir(alloc_dir, container_id);
1290        }
1291    }
1292}
1293
1294#[cfg(test)]
1295mod tests {
1296    use super::*;
1297
1298    #[test]
1299    fn test_ip_allocation_rejection_sampling_range() {
1300        // H-5: Verify that rejection sampling produces values in 2..=254
1301        // and that values >= 253 are rejected (no modulo bias).
1302        for byte in 0u8..253 {
1303            let offset = byte as u32 + 2;
1304            assert!(
1305                (2..=254).contains(&offset),
1306                "offset {} out of range",
1307                offset
1308            );
1309        }
1310        // Values 253, 254, 255 must be rejected
1311        for byte in [253u8, 254, 255] {
1312            assert!(byte >= 253);
1313        }
1314    }
1315
1316    #[test]
1317    fn test_reserve_ip_blocks_duplicate_requested_address() {
1318        let temp = tempfile::tempdir().unwrap();
1319        BridgeNetwork::record_allocated_ip_in_dir(temp.path(), "one", "10.0.42.2").unwrap();
1320
1321        let err =
1322            BridgeNetwork::reserve_ip_in_dir(temp.path(), "two", "10.0.42.0/24", Some("10.0.42.2"))
1323                .unwrap_err();
1324        assert!(
1325            err.to_string().contains("already in use"),
1326            "second reservation of the same IP must fail"
1327        );
1328    }
1329
1330    #[test]
1331    fn test_setup_rollback_releases_reserved_ip() {
1332        let temp = tempfile::tempdir().unwrap();
1333        BridgeNetwork::record_allocated_ip_in_dir(temp.path(), "rollback", "10.0.42.3").unwrap();
1334
1335        let rollback = SetupRollback {
1336            veth_host: "veth-test".to_string(),
1337            subnet: "10.0.42.0/24".to_string(),
1338            veth_created: false,
1339            nat_added: false,
1340            port_forwards: Vec::new(),
1341            ip_forward_ref_acquired: false,
1342            reserved_ip: Some((temp.path().to_path_buf(), "rollback".to_string())),
1343            armed: true,
1344        };
1345
1346        drop(rollback);
1347
1348        assert!(
1349            !temp.path().join("rollback.ip").exists(),
1350            "rollback must release reserved IP files on setup failure"
1351        );
1352    }
1353
1354    #[test]
1355    fn test_resolv_conf_staging_file_is_outside_container_root() {
1356        let temp = tempfile::tempdir().unwrap();
1357        let root = temp.path().join("root");
1358        std::fs::create_dir_all(root.join("tmp")).unwrap();
1359
1360        let staging =
1361            BridgeNetwork::create_resolv_conf_staging_file(&root, b"nameserver 203.0.113.53\n")
1362                .unwrap();
1363
1364        assert_eq!(
1365            std::fs::read_to_string(staging.path()).unwrap(),
1366            "nameserver 203.0.113.53\n"
1367        );
1368        assert!(
1369            !staging.path().starts_with(&root),
1370            "staging file must not be created under the container root"
1371        );
1372    }
1373
1374    #[test]
1375    fn test_bind_mount_resolv_conf_does_not_overwrite_root_tmp_symlink_on_failure() {
1376        use std::os::unix::fs::symlink;
1377
1378        let temp = tempfile::tempdir().unwrap();
1379        let root = temp.path().join("root");
1380        std::fs::create_dir_all(root.join("tmp")).unwrap();
1381
1382        let victim = temp.path().join("host_victim_file");
1383        std::fs::write(&victim, "ORIGINAL_HOST_CONTENT\n").unwrap();
1384        symlink(&victim, root.join("tmp/.resolv.conf.nucleus")).unwrap();
1385
1386        let dns = vec!["203.0.113.53".to_string()];
1387        let result = BridgeNetwork::bind_mount_resolv_conf(&root, &dns);
1388
1389        assert!(
1390            result.is_err(),
1391            "test root intentionally lacks /etc so bind mount setup must fail"
1392        );
1393        assert_eq!(
1394            std::fs::read_to_string(&victim).unwrap(),
1395            "ORIGINAL_HOST_CONTENT\n",
1396            "resolv.conf setup must not write through attacker-controlled /tmp symlinks"
1397        );
1398    }
1399
1400    #[test]
1401    fn test_ip_forward_refcount_restores_original_only_after_last_release() {
1402        let temp = tempfile::tempdir().unwrap();
1403        let sysctl = temp.path().join("ip_forward");
1404        std::fs::write(&sysctl, "0").unwrap();
1405
1406        BridgeNetwork::acquire_ip_forward_ref_in_dir(temp.path(), &sysctl).unwrap();
1407        BridgeNetwork::acquire_ip_forward_ref_in_dir(temp.path(), &sysctl).unwrap();
1408        assert_eq!(std::fs::read_to_string(&sysctl).unwrap(), "1");
1409
1410        BridgeNetwork::release_ip_forward_ref_in_dir(temp.path(), &sysctl).unwrap();
1411        assert_eq!(std::fs::read_to_string(&sysctl).unwrap(), "1");
1412
1413        BridgeNetwork::release_ip_forward_ref_in_dir(temp.path(), &sysctl).unwrap();
1414        assert_eq!(std::fs::read_to_string(&sysctl).unwrap(), "0");
1415        assert!(
1416            !temp.path().join(IP_FORWARD_STATE_FILE).exists(),
1417            "state file must be removed when the last bridge releases ip_forward"
1418        );
1419    }
1420
1421    #[test]
1422    fn test_port_forward_rules_include_output_chain_for_local_host_clients() {
1423        let pf = PortForward {
1424            host_ip: None,
1425            host_port: 8080,
1426            container_port: 80,
1427            protocol: crate::network::config::Protocol::Tcp,
1428        };
1429
1430        let prerouting =
1431            BridgeNetwork::port_forward_rule_args("-A", "PREROUTING", "10.0.42.2", &pf);
1432        let output = BridgeNetwork::port_forward_rule_args("-A", "OUTPUT", "10.0.42.2", &pf);
1433
1434        assert!(prerouting.iter().any(|arg| arg == "PREROUTING"));
1435        assert!(output.iter().any(|arg| arg == "OUTPUT"));
1436        assert!(
1437            output
1438                .windows(2)
1439                .any(|pair| pair[0] == "--dst-type" && pair[1] == "LOCAL"),
1440            "OUTPUT rule must target local-destination traffic"
1441        );
1442    }
1443
1444    #[test]
1445    fn test_port_forward_rules_include_host_ip_when_configured() {
1446        let pf = PortForward {
1447            host_ip: Some(std::net::Ipv4Addr::new(127, 0, 0, 1)),
1448            host_port: 4173,
1449            container_port: 4173,
1450            protocol: crate::network::config::Protocol::Tcp,
1451        };
1452
1453        let prerouting =
1454            BridgeNetwork::port_forward_rule_args("-A", "PREROUTING", "10.0.42.2", &pf);
1455        let output = BridgeNetwork::port_forward_rule_args("-A", "OUTPUT", "10.0.42.2", &pf);
1456
1457        for args in [&prerouting, &output] {
1458            assert!(
1459                args.windows(2)
1460                    .any(|pair| pair[0] == "-d" && pair[1] == "127.0.0.1"),
1461                "port forward must restrict DNAT rules to the configured host IP"
1462            );
1463        }
1464    }
1465
1466    #[test]
1467    fn test_network_helper_execution_preserves_applet_argv0() {
1468        let source = include_str!("bridge.rs");
1469        let implementation = source.split("#[cfg(test)]").next().unwrap();
1470
1471        assert!(
1472            implementation.contains(".arg0(program)"),
1473            "canonicalized network helper execution must preserve the requested applet argv[0]"
1474        );
1475    }
1476}