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
16pub 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 pub fn setup(pid: u32, config: &BridgeConfig) -> Result<Self> {
65 Self::setup_for(pid, config, &format!("{:x}", pid))
66 }
67
68 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 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 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 Self::ensure_bridge_for(&config.bridge_name, &config.subnet)?;
102
103 netlink::create_veth(&veth_host, &veth_container)?;
105 rollback.veth_created = true;
106
107 netlink::set_link_master(&veth_host, &config.bridge_name)?;
109 netlink::set_link_up(&veth_host)?;
110
111 netlink::set_link_netns(&veth_container, pid)?;
113
114 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 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 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 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 Self::acquire_ip_forward_ref()?;
175 rollback.ip_forward_ref_acquired = true;
176
177 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 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 pub fn cleanup(mut self) -> Result<()> {
216 self.state = self.state.transition(NetworkState::Cleaned)?;
217
218 Self::release_allocated_ip(&self.container_id);
220
221 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 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 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 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 pub fn cleanup_orphaned_rules(subnet: &str) {
303 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 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 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 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 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 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 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 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 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 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 fn ensure_alloc_dir(alloc_dir: &std::path::Path) -> Result<()> {
537 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 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 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 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 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 if let Some(after_comm) = content.rfind(')') {
799 return content[after_comm + 2..]
800 .split_whitespace()
801 .nth(19) .and_then(|s| s.parse().ok())
803 .unwrap_or(0);
804 }
805 }
806 0
807 }
808
809 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 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 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 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 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 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 let memfd_name = std::ffi::CString::new("nucleus-resolv").map_err(|e| {
1047 NucleusError::NetworkError(format!("Failed to create memfd name: {}", e))
1048 })?;
1049 let raw_fd = unsafe { libc::memfd_create(memfd_name.as_ptr(), 0) };
1052 if raw_fd < 0 {
1053 return Self::bind_mount_resolv_conf_staging(root, dns);
1055 }
1056 let memfd = unsafe { std::os::fd::OwnedFd::from_raw_fd(raw_fd) };
1060
1061 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 return Self::bind_mount_resolv_conf_staging(root, dns);
1067 }
1068 use std::os::fd::IntoRawFd;
1070 let memfd = {
1071 let raw = memfd_file.into_raw_fd();
1072 unsafe { std::os::fd::OwnedFd::from_raw_fd(raw) }
1074 };
1075
1076 let target = root.join("etc/resolv.conf");
1078 if !target.exists() {
1079 let _ = std::fs::write(&target, "");
1080 }
1081
1082 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 info!("Bind-mounted resolv.conf for bridge networking (rootfs mode, memfd)");
1102 Ok(())
1103 }
1104
1105 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 let target = root.join("etc/resolv.conf");
1118 if !target.exists() {
1119 let _ = std::fs::write(&target, "");
1120 }
1121
1122 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 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 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 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}