Skip to main content

nucleus/network/
userspace.rs

1use super::{
2    egress, BridgeConfig, BridgeNetwork, EgressPolicy, NatBackend, NetworkState, PortForward,
3};
4use crate::error::{NucleusError, Result, StateTransition};
5use nix::fcntl::{fcntl, FcntlArg, FdFlag};
6use serde_json::json;
7use std::io::{Read, Write};
8use std::os::fd::{AsRawFd, OwnedFd};
9use std::os::unix::net::UnixStream;
10use std::os::unix::process::ExitStatusExt;
11use std::path::{Path, PathBuf};
12use std::process::{Child, Command};
13use std::time::{Duration, Instant};
14use tracing::{debug, info, warn};
15
16const SLIRP_TAP_NAME: &str = "tap0";
17
18/// Native bridge-mode driver for the native runtime.
19pub enum BridgeDriver {
20    Kernel(BridgeNetwork),
21    Userspace(UserspaceNetwork),
22}
23
24impl BridgeDriver {
25    pub fn setup_with_id(
26        pid: u32,
27        config: &BridgeConfig,
28        container_id: &str,
29        host_is_root: bool,
30        rootless: bool,
31    ) -> Result<Self> {
32        match config.selected_nat_backend(host_is_root, rootless) {
33            NatBackend::Kernel => Ok(Self::Kernel(BridgeNetwork::setup_with_id(
34                pid,
35                config,
36                container_id,
37            )?)),
38            NatBackend::Userspace => Ok(Self::Userspace(UserspaceNetwork::setup_with_id(
39                pid,
40                config,
41                container_id,
42                host_is_root,
43                rootless,
44            )?)),
45            NatBackend::Auto => Err(NucleusError::NetworkError(
46                "nat backend selection resolved to auto unexpectedly".to_string(),
47            )),
48        }
49    }
50
51    pub fn apply_egress_policy(
52        &self,
53        pid: u32,
54        policy: &EgressPolicy,
55        rootless: bool,
56    ) -> Result<()> {
57        match self {
58            Self::Kernel(net) => net.apply_egress_policy(pid, policy),
59            Self::Userspace(net) => net.apply_egress_policy(pid, policy, rootless),
60        }
61    }
62
63    pub fn cleanup(self) -> Result<()> {
64        match self {
65            Self::Kernel(net) => net.cleanup(),
66            Self::Userspace(net) => net.cleanup(),
67        }
68    }
69}
70
71/// Userspace NAT manager backed by slirp4netns.
72pub struct UserspaceNetwork {
73    config: BridgeConfig,
74    guest_ip: String,
75    container_id: String,
76    api_socket_path: PathBuf,
77    runtime_dir: PathBuf,
78    exit_signal: Option<OwnedFd>,
79    child: Child,
80    state: NetworkState,
81}
82
83impl UserspaceNetwork {
84    pub(crate) fn default_dns_server(subnet: &str) -> Result<String> {
85        Self::dns_ip_from_subnet(subnet)
86    }
87
88    pub fn setup_with_id(
89        pid: u32,
90        config: &BridgeConfig,
91        container_id: &str,
92        host_is_root: bool,
93        rootless: bool,
94    ) -> Result<Self> {
95        config.validate()?;
96
97        let guest_ip = Self::guest_ip_from_subnet(&config.subnet)?;
98        Self::validate_userspace_config(config, &guest_ip)?;
99
100        let mut state = NetworkState::Unconfigured;
101        state = state.transition(NetworkState::Configuring)?;
102
103        let runtime_dir = Self::runtime_dir(container_id);
104        Self::ensure_runtime_dir(&runtime_dir)?;
105        let api_socket_path = runtime_dir.join("slirp4netns.sock");
106
107        let slirp = BridgeNetwork::resolve_bin("slirp4netns")?;
108        // Keep slirp4netns inside the container user namespace whenever the
109        // container is rootless/root-remapped, even when Nucleus itself runs as
110        // host root. slirp4netns processes untrusted guest network traffic, so
111        // omitting --userns-path would run that helper as host root in the
112        // initial user namespace.
113        let needs_userns = Self::should_join_userns(host_is_root, rootless);
114
115        let slirp_path = Path::new(&slirp);
116        let (child, exit_write) = match Self::spawn_slirp(
117            slirp_path,
118            pid,
119            config,
120            needs_userns,
121            &api_socket_path,
122            true,
123        ) {
124            Ok(result) => result,
125            Err(e) => {
126                warn!(
127                    "slirp4netns sandbox failed ({}), retrying without --enable-sandbox",
128                    e
129                );
130                // The sandbox uses pivot_root(2) which can fail in constrained
131                // environments (e.g. QEMU test VMs, nested containers) where
132                // mount propagation or /tmp restrictions prevent the pivot.
133                // Retry without --enable-sandbox while preserving user namespace
134                // confinement for rootless/root-remapped containers.
135                let _ = std::fs::remove_file(&api_socket_path);
136                Self::spawn_slirp(
137                    slirp_path,
138                    pid,
139                    config,
140                    needs_userns,
141                    &api_socket_path,
142                    false,
143                )
144                .inspect_err(|_| {
145                    let _ = std::fs::remove_dir_all(&runtime_dir);
146                })?
147            }
148        };
149
150        let mut network = Self {
151            config: config.clone(),
152            guest_ip: guest_ip.to_string(),
153            container_id: container_id.to_string(),
154            api_socket_path,
155            runtime_dir,
156            exit_signal: Some(exit_write),
157            child,
158            state,
159        };
160
161        if let Err(e) = network.configure_port_forwards() {
162            network.cleanup_best_effort();
163            return Err(e);
164        }
165
166        network.state = network.state.transition(NetworkState::Active)?;
167
168        info!(
169            "Userspace NAT configured via slirp4netns for container {} (guest IP {})",
170            network.container_id, network.guest_ip
171        );
172
173        Ok(network)
174    }
175
176    pub fn apply_egress_policy(
177        &self,
178        pid: u32,
179        policy: &EgressPolicy,
180        rootless: bool,
181    ) -> Result<()> {
182        egress::apply_egress_policy(pid, &self.effective_dns_servers(), policy, rootless)
183    }
184
185    pub fn cleanup(mut self) -> Result<()> {
186        self.state = self.state.transition(NetworkState::Cleaned)?;
187        self.stop_child()?;
188        self.cleanup_runtime_dir();
189        Ok(())
190    }
191
192    fn effective_dns_servers(&self) -> Vec<String> {
193        if self.config.dns.is_empty() {
194            vec![Self::dns_ip_from_subnet(&self.config.subnet)
195                .unwrap_or_else(|_| "10.0.2.3".to_string())]
196        } else {
197            self.config.dns.clone()
198        }
199    }
200
201    fn configure_port_forwards(&mut self) -> Result<()> {
202        for pf in &self.config.port_forwards {
203            self.add_port_forward(pf)?;
204        }
205        Ok(())
206    }
207
208    fn add_port_forward(&self, pf: &PortForward) -> Result<()> {
209        let mut arguments = serde_json::Map::new();
210        arguments.insert("proto".to_string(), json!(pf.protocol.as_str()));
211        arguments.insert("host_port".to_string(), json!(pf.host_port));
212        arguments.insert("guest_port".to_string(), json!(pf.container_port));
213        if let Some(host_ip) = pf.host_ip {
214            arguments.insert("host_addr".to_string(), json!(host_ip.to_string()));
215        }
216
217        let response = Self::api_request(
218            &self.api_socket_path,
219            &json!({
220                "execute": "add_hostfwd",
221                "arguments": arguments,
222            }),
223        )?;
224
225        if let Some(error) = response.get("error") {
226            return Err(NucleusError::NetworkError(format!(
227                "slirp4netns add_hostfwd failed for {}:{}->{}/{}: {}",
228                pf.host_ip
229                    .map(|ip| ip.to_string())
230                    .unwrap_or_else(|| "0.0.0.0".to_string()),
231                pf.host_port,
232                pf.container_port,
233                pf.protocol,
234                error
235            )));
236        }
237
238        debug!(
239            "Configured slirp4netns port forward {}:{} -> {}:{}/{}",
240            pf.host_ip
241                .map(|ip| ip.to_string())
242                .unwrap_or_else(|| "0.0.0.0".to_string()),
243            pf.host_port,
244            self.guest_ip,
245            pf.container_port,
246            pf.protocol
247        );
248        Ok(())
249    }
250
251    fn api_request(socket_path: &Path, request: &serde_json::Value) -> Result<serde_json::Value> {
252        let mut stream = UnixStream::connect(socket_path).map_err(|e| {
253            NucleusError::NetworkError(format!(
254                "connect slirp4netns API socket {:?}: {}",
255                socket_path, e
256            ))
257        })?;
258        let payload = serde_json::to_vec(request).map_err(|e| {
259            NucleusError::NetworkError(format!("serialize slirp4netns API request: {}", e))
260        })?;
261        stream.write_all(&payload).map_err(|e| {
262            NucleusError::NetworkError(format!("write slirp4netns API request: {}", e))
263        })?;
264        stream
265            .shutdown(std::net::Shutdown::Write)
266            .map_err(|e| NucleusError::NetworkError(format!("shutdown slirp4netns API: {}", e)))?;
267
268        let mut buf = Vec::new();
269        stream.read_to_end(&mut buf).map_err(|e| {
270            NucleusError::NetworkError(format!("read slirp4netns API response: {}", e))
271        })?;
272
273        serde_json::from_slice(&buf).map_err(|e| {
274            NucleusError::NetworkError(format!(
275                "parse slirp4netns API response '{}': {}",
276                String::from_utf8_lossy(&buf),
277                e
278            ))
279        })
280    }
281
282    fn wait_until_ready(child: &mut Child, ready_read: OwnedFd) -> Result<()> {
283        let mut ready = std::fs::File::from(ready_read);
284        let mut buf = [0u8; 1];
285        match ready.read_exact(&mut buf) {
286            Ok(()) if buf == [b'1'] => Ok(()),
287            Ok(()) => Err(NucleusError::NetworkError(format!(
288                "slirp4netns ready-fd returned unexpected byte {:?}",
289                buf
290            ))),
291            Err(e) => {
292                if let Ok(Some(status)) = child.try_wait() {
293                    let detail = status
294                        .code()
295                        .map(|code| format!("exit code {}", code))
296                        .or_else(|| status.signal().map(|sig| format!("signal {}", sig)))
297                        .unwrap_or_else(|| "unknown status".to_string());
298                    Err(NucleusError::NetworkError(format!(
299                        "slirp4netns exited before ready: {}",
300                        detail
301                    )))
302                } else {
303                    Err(NucleusError::NetworkError(format!(
304                        "failed waiting for slirp4netns readiness: {}",
305                        e
306                    )))
307                }
308            }
309        }
310    }
311
312    fn stop_child(&mut self) -> Result<()> {
313        self.exit_signal.take();
314
315        let deadline = Instant::now() + Duration::from_secs(2);
316        loop {
317            match self.child.try_wait() {
318                Ok(Some(_)) => break,
319                Ok(None) if Instant::now() < deadline => {
320                    std::thread::sleep(Duration::from_millis(50))
321                }
322                Ok(None) => {
323                    self.child.kill().map_err(|e| {
324                        NucleusError::NetworkError(format!("kill slirp4netns: {}", e))
325                    })?;
326                    let _ = self.child.wait();
327                    break;
328                }
329                Err(e) => {
330                    return Err(NucleusError::NetworkError(format!(
331                        "wait for slirp4netns shutdown: {}",
332                        e
333                    )))
334                }
335            }
336        }
337
338        info!(
339            "Userspace NAT cleaned up for container {}",
340            self.container_id
341        );
342        Ok(())
343    }
344
345    fn cleanup_best_effort(&mut self) {
346        if self.state == NetworkState::Cleaned {
347            return;
348        }
349
350        self.exit_signal.take();
351
352        if let Ok(None) = self.child.try_wait() {
353            let deadline = Instant::now() + Duration::from_secs(1);
354            while Instant::now() < deadline {
355                match self.child.try_wait() {
356                    Ok(Some(_)) => break,
357                    Ok(None) => std::thread::sleep(Duration::from_millis(25)),
358                    Err(_) => break,
359                }
360            }
361
362            if let Ok(None) = self.child.try_wait() {
363                let _ = self.child.kill();
364                let _ = self.child.wait();
365            }
366        }
367
368        self.cleanup_runtime_dir();
369        self.state = NetworkState::Cleaned;
370        debug!(
371            "Userspace NAT cleaned up (best-effort via drop) for container {}",
372            self.container_id
373        );
374    }
375
376    fn cleanup_runtime_dir(&self) {
377        if let Err(e) = std::fs::remove_dir_all(&self.runtime_dir) {
378            if self.runtime_dir.exists() {
379                warn!(
380                    "Failed to remove slirp4netns runtime dir {:?}: {}",
381                    self.runtime_dir, e
382                );
383            }
384        }
385    }
386
387    fn validate_userspace_config(config: &BridgeConfig, guest_ip: &str) -> Result<()> {
388        let prefix = config
389            .subnet
390            .split_once('/')
391            .and_then(|(_, prefix)| prefix.parse::<u8>().ok())
392            .unwrap_or(24);
393        if prefix > 25 {
394            return Err(NucleusError::NetworkError(format!(
395                "Userspace NAT requires a subnet with at least 128 addresses; '{}' is too small",
396                config.subnet
397            )));
398        }
399
400        if let Some(requested_ip) = config.container_ip.as_deref() {
401            if requested_ip != guest_ip {
402                return Err(NucleusError::NetworkError(format!(
403                    "Userspace NAT uses the slirp4netns guest address {}; requested container IP {} is unsupported",
404                    guest_ip, requested_ip
405                )));
406            }
407        }
408
409        Ok(())
410    }
411
412    fn should_join_userns(_host_is_root: bool, rootless: bool) -> bool {
413        rootless
414    }
415
416    fn spawn_slirp(
417        slirp_bin: &Path,
418        pid: u32,
419        config: &BridgeConfig,
420        needs_userns: bool,
421        api_socket_path: &Path,
422        enable_sandbox: bool,
423    ) -> Result<(Child, OwnedFd)> {
424        let (ready_read, ready_write) = nix::unistd::pipe()
425            .map_err(|e| NucleusError::NetworkError(format!("ready pipe: {}", e)))?;
426        let (exit_read, exit_write) = nix::unistd::pipe()
427            .map_err(|e| NucleusError::NetworkError(format!("exit pipe: {}", e)))?;
428        Self::clear_cloexec(&ready_write)?;
429        Self::clear_cloexec(&exit_read)?;
430
431        let args = Self::command_args(
432            pid,
433            config,
434            needs_userns,
435            api_socket_path,
436            ready_write.as_raw_fd(),
437            exit_read.as_raw_fd(),
438            enable_sandbox,
439        );
440
441        let mut child = Command::new(slirp_bin)
442            .args(&args)
443            .spawn()
444            .map_err(|e| NucleusError::NetworkError(format!("spawn slirp4netns: {}", e)))?;
445
446        drop(ready_write);
447        drop(exit_read);
448
449        match Self::wait_until_ready(&mut child, ready_read) {
450            Ok(()) => Ok((child, exit_write)),
451            Err(e) => {
452                let _ = child.kill();
453                let _ = child.wait();
454                Err(e)
455            }
456        }
457    }
458
459    fn command_args(
460        pid: u32,
461        config: &BridgeConfig,
462        join_userns: bool,
463        api_socket_path: &Path,
464        ready_fd: i32,
465        exit_fd: i32,
466        enable_sandbox: bool,
467    ) -> Vec<String> {
468        let mut args = vec![
469            "--configure".to_string(),
470            "--ready-fd".to_string(),
471            ready_fd.to_string(),
472            "--exit-fd".to_string(),
473            exit_fd.to_string(),
474            "--api-socket".to_string(),
475            api_socket_path.display().to_string(),
476            "--cidr".to_string(),
477            config.subnet.clone(),
478            "--disable-host-loopback".to_string(),
479        ];
480
481        if enable_sandbox {
482            args.push("--enable-sandbox".to_string());
483        }
484
485        if !config.dns.is_empty() {
486            args.push("--disable-dns".to_string());
487        }
488
489        if join_userns {
490            args.push("--userns-path".to_string());
491            args.push(format!("/proc/{}/ns/user", pid));
492        }
493
494        args.push(pid.to_string());
495        args.push(SLIRP_TAP_NAME.to_string());
496        args
497    }
498
499    fn runtime_dir(container_id: &str) -> PathBuf {
500        let base = if nix::unistd::Uid::effective().is_root() {
501            PathBuf::from("/run/nucleus/userspace-net")
502        } else {
503            dirs::runtime_dir()
504                .map(|dir| dir.join("nucleus/userspace-net"))
505                .or_else(|| dirs::data_local_dir().map(|dir| dir.join("nucleus/userspace-net")))
506                .unwrap_or_else(|| std::env::temp_dir().join("nucleus-userspace-net"))
507        };
508        base.join(container_id)
509    }
510
511    fn ensure_runtime_dir(path: &Path) -> Result<()> {
512        if let Some(parent) = path.parent() {
513            std::fs::create_dir_all(parent).map_err(|e| {
514                NucleusError::NetworkError(format!(
515                    "create userspace-net parent dir {:?}: {}",
516                    parent, e
517                ))
518            })?;
519        }
520        std::fs::create_dir_all(path).map_err(|e| {
521            NucleusError::NetworkError(format!("create userspace-net dir {:?}: {}", path, e))
522        })?;
523        use std::os::unix::fs::PermissionsExt;
524        std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700)).map_err(|e| {
525            NucleusError::NetworkError(format!(
526                "secure userspace-net dir permissions for {:?}: {}",
527                path, e
528            ))
529        })?;
530        Ok(())
531    }
532
533    fn clear_cloexec(fd: &OwnedFd) -> Result<()> {
534        let flags = fcntl(fd, FcntlArg::F_GETFD).map_err(|e| {
535            NucleusError::NetworkError(format!("fcntl(F_GETFD) on fd {}: {}", fd.as_raw_fd(), e))
536        })?;
537        let fd_flags = FdFlag::from_bits_truncate(flags);
538        let new_flags = fd_flags & !FdFlag::FD_CLOEXEC;
539        fcntl(fd, FcntlArg::F_SETFD(new_flags)).map_err(|e| {
540            NucleusError::NetworkError(format!("fcntl(F_SETFD) on fd {}: {}", fd.as_raw_fd(), e))
541        })?;
542        Ok(())
543    }
544
545    fn guest_ip_from_subnet(subnet: &str) -> Result<String> {
546        Self::offset_ip_from_subnet(subnet, 100).map(|ip| ip.to_string())
547    }
548
549    fn dns_ip_from_subnet(subnet: &str) -> Result<String> {
550        Self::offset_ip_from_subnet(subnet, 3).map(|ip| ip.to_string())
551    }
552
553    fn offset_ip_from_subnet(subnet: &str, offset: u32) -> Result<std::net::Ipv4Addr> {
554        let (base, prefix) = subnet.split_once('/').ok_or_else(|| {
555            NucleusError::NetworkError(format!("Invalid CIDR (missing /prefix): '{}'", subnet))
556        })?;
557        let prefix = prefix.parse::<u8>().map_err(|e| {
558            NucleusError::NetworkError(format!("Invalid CIDR prefix '{}': {}", subnet, e))
559        })?;
560        let base_ip = base.parse::<std::net::Ipv4Addr>().map_err(|e| {
561            NucleusError::NetworkError(format!("Invalid CIDR base '{}': {}", subnet, e))
562        })?;
563
564        let host_capacity = if prefix == 32 {
565            1u64
566        } else {
567            1u64 << (32 - prefix)
568        };
569        if offset as u64 >= host_capacity {
570            return Err(NucleusError::NetworkError(format!(
571                "CIDR '{}' does not have room for host offset {}",
572                subnet, offset
573            )));
574        }
575
576        let candidate = u32::from(base_ip)
577            .checked_add(offset)
578            .ok_or_else(|| NucleusError::NetworkError(format!("CIDR '{}' overflowed", subnet)))?;
579        Ok(std::net::Ipv4Addr::from(candidate))
580    }
581}
582
583impl Drop for UserspaceNetwork {
584    fn drop(&mut self) {
585        self.cleanup_best_effort();
586    }
587}
588
589#[cfg(test)]
590mod tests {
591    use super::*;
592    use crate::network::Protocol;
593
594    #[test]
595    fn test_auto_nat_backend_prefers_kernel_for_rootful_hosts() {
596        let cfg = BridgeConfig::default();
597        assert_eq!(cfg.selected_nat_backend(true, false), NatBackend::Kernel);
598        assert_eq!(cfg.selected_nat_backend(true, true), NatBackend::Userspace);
599        assert_eq!(cfg.selected_nat_backend(false, true), NatBackend::Userspace);
600    }
601
602    #[test]
603    fn test_userspace_backend_rejects_too_small_subnets() {
604        let cfg = BridgeConfig {
605            subnet: "10.0.42.0/26".to_string(),
606            ..BridgeConfig::default()
607        };
608
609        let guest_ip = UserspaceNetwork::guest_ip_from_subnet(&cfg.subnet).unwrap_err();
610        assert!(
611            guest_ip.to_string().contains("does not have room"),
612            "unexpected error: {guest_ip}"
613        );
614    }
615
616    #[test]
617    fn test_userspace_backend_rejects_custom_guest_ip() {
618        let cfg = BridgeConfig {
619            container_ip: Some("10.0.42.2".to_string()),
620            ..BridgeConfig::default()
621        };
622
623        let err = UserspaceNetwork::validate_userspace_config(&cfg, "10.0.42.100").unwrap_err();
624        assert!(err
625            .to_string()
626            .contains("requested container IP 10.0.42.2 is unsupported"));
627    }
628
629    #[test]
630    fn test_slirp_command_args_disable_builtin_dns_when_explicit_dns_is_set() {
631        let cfg = BridgeConfig::default().with_dns(vec!["1.1.1.1".to_string()]);
632        let args = UserspaceNetwork::command_args(
633            4242,
634            &cfg,
635            true,
636            Path::new("/tmp/slirp.sock"),
637            5,
638            6,
639            true,
640        );
641
642        assert!(args.iter().any(|arg| arg == "--disable-dns"));
643        assert!(args.iter().any(|arg| arg == "--userns-path"));
644    }
645
646    #[test]
647    fn test_slirp_userns_join_is_kept_for_root_remapped_hosts() {
648        assert!(UserspaceNetwork::should_join_userns(true, true));
649        assert!(UserspaceNetwork::should_join_userns(false, true));
650        assert!(!UserspaceNetwork::should_join_userns(true, false));
651        assert!(!UserspaceNetwork::should_join_userns(false, false));
652    }
653
654    #[test]
655    fn test_slirp_command_args_keep_userns_without_sandbox() {
656        let cfg = BridgeConfig::default();
657        let args = UserspaceNetwork::command_args(
658            4242,
659            &cfg,
660            true,
661            Path::new("/tmp/slirp.sock"),
662            5,
663            6,
664            false,
665        );
666
667        assert!(!args.iter().any(|arg| arg == "--enable-sandbox"));
668        assert!(args.iter().any(|arg| arg == "--userns-path"));
669        assert!(args.iter().any(|arg| arg == "/proc/4242/ns/user"));
670    }
671
672    #[test]
673    fn test_userspace_port_forward_request_uses_slirp_hostfwd_shape() {
674        let pf = PortForward {
675            host_ip: Some(std::net::Ipv4Addr::new(127, 0, 0, 1)),
676            host_port: 8080,
677            container_port: 80,
678            protocol: Protocol::Tcp,
679        };
680
681        let mut arguments = serde_json::Map::new();
682        arguments.insert("proto".to_string(), json!(pf.protocol.as_str()));
683        arguments.insert("host_port".to_string(), json!(pf.host_port));
684        arguments.insert("guest_port".to_string(), json!(pf.container_port));
685        if let Some(host_ip) = pf.host_ip {
686            arguments.insert("host_addr".to_string(), json!(host_ip.to_string()));
687        }
688        let request = json!({
689            "execute": "add_hostfwd",
690            "arguments": arguments,
691        });
692
693        assert_eq!(request["execute"], "add_hostfwd");
694        assert_eq!(request["arguments"]["proto"], "tcp");
695        assert_eq!(request["arguments"]["host_addr"], "127.0.0.1");
696        assert_eq!(request["arguments"]["host_port"], 8080);
697        assert_eq!(request["arguments"]["guest_port"], 80);
698    }
699}