1use a3s_box_core::error::{BoxError, Result};
8use std::net::Ipv4Addr;
9use std::os::fd::{AsRawFd, FromRawFd, OwnedFd, RawFd};
10use std::path::{Path, PathBuf};
11use std::process::{Child, Command};
12use std::time::{Duration, Instant};
13
14const PASST_STARTUP_TIMEOUT: Duration = Duration::from_secs(5);
15const PASST_STARTUP_POLL_INTERVAL: Duration = Duration::from_millis(50);
16const PASST_STARTUP_STABILITY_WINDOW: Duration = Duration::from_millis(250);
17
18#[derive(Debug)]
20pub struct PasstManager {
21 socket_path: PathBuf,
23 pcap_path: PathBuf,
25 child: Option<Child>,
27 pid_file: PathBuf,
29 net_socket_fd: Option<OwnedFd>,
31 net_proxy_fd: Option<OwnedFd>,
33}
34
35impl PasstManager {
36 pub fn new(socket_dir: &Path) -> Self {
48 Self {
49 socket_path: socket_dir.join("passt.sock"),
50 pcap_path: socket_dir.join("passt.pcap"),
51 pid_file: socket_dir.join("passt.pid"),
52 child: None,
53 net_socket_fd: None,
54 net_proxy_fd: None,
55 }
56 }
57
58 pub fn socket_path(&self) -> &Path {
60 &self.socket_path
61 }
62
63 pub fn pcap_path(&self) -> &Path {
65 &self.pcap_path
66 }
67
68 pub fn enable_peer_bridge(&mut self) -> Result<()> {
70 if self.net_socket_fd.is_some() || self.net_proxy_fd.is_some() {
71 return Ok(());
72 }
73 let mut descriptors = [-1; 2];
74 #[cfg(target_os = "linux")]
75 let socket_type = libc::SOCK_STREAM | libc::SOCK_CLOEXEC;
76 #[cfg(not(target_os = "linux"))]
77 let socket_type = libc::SOCK_STREAM;
78 let result =
79 unsafe { libc::socketpair(libc::AF_UNIX, socket_type, 0, descriptors.as_mut_ptr()) };
80 if result != 0 {
81 return Err(BoxError::NetworkError(format!(
82 "failed to create passt bridge socketpair: {}",
83 std::io::Error::last_os_error()
84 )));
85 }
86 self.net_socket_fd = Some(unsafe { OwnedFd::from_raw_fd(descriptors[0]) });
88 self.net_proxy_fd = Some(unsafe { OwnedFd::from_raw_fd(descriptors[1]) });
89 Ok(())
90 }
91
92 pub fn net_socket_fd(&self) -> Option<RawFd> {
93 self.net_socket_fd.as_ref().map(AsRawFd::as_raw_fd)
94 }
95
96 pub fn net_proxy_fd(&self) -> Option<RawFd> {
97 self.net_proxy_fd.as_ref().map(AsRawFd::as_raw_fd)
98 }
99
100 pub fn spawn(
109 &mut self,
110 ip: Ipv4Addr,
111 gateway: Ipv4Addr,
112 prefix_len: u8,
113 dns_servers: &[Ipv4Addr],
114 port_map: &[String],
115 ) -> Result<()> {
116 if let Some(parent) = self.socket_path.parent() {
118 std::fs::create_dir_all(parent).map_err(|e| {
119 BoxError::NetworkError(format!(
120 "failed to create socket directory {}: {}",
121 parent.display(),
122 e
123 ))
124 })?;
125
126 #[cfg(unix)]
132 {
133 use std::os::unix::fs::PermissionsExt;
134 if let Err(e) =
135 std::fs::set_permissions(parent, std::fs::Permissions::from_mode(0o777))
136 {
137 tracing::warn!(
138 dir = %parent.display(),
139 error = %e,
140 "Failed to widen passt socket directory permissions; \
141 passt may be unable to bind its socket after dropping privileges"
142 );
143 }
144 }
145 }
146
147 self.remove_launch_artifacts();
148
149 match self.spawn_attempt(ip, gateway, prefix_len, dns_servers, port_map, false) {
150 Ok(()) => Ok(()),
151 Err(initial_error) => {
152 let stderr = self.read_stderr();
153 if !should_retry_passt_as_root(&stderr) {
154 return Err(initial_error);
155 }
156
157 tracing::warn!(
158 error = %initial_error,
159 "passt could not create its unprivileged user namespace; retrying with root as the sandbox identity"
160 );
161 self.cleanup_failed_launch();
162 self.spawn_attempt(ip, gateway, prefix_len, dns_servers, port_map, true)
163 }
164 }
165 }
166
167 fn spawn_attempt(
168 &mut self,
169 ip: Ipv4Addr,
170 gateway: Ipv4Addr,
171 prefix_len: u8,
172 dns_servers: &[Ipv4Addr],
173 port_map: &[String],
174 run_as_root: bool,
175 ) -> Result<()> {
176 self.remove_launch_artifacts();
177
178 let mut cmd = Command::new("passt");
179 if run_as_root {
180 cmd.arg("--runas").arg("0:0");
181 }
182 cmd.arg("--socket")
183 .arg(&self.socket_path)
184 .arg("--pid")
185 .arg(&self.pid_file)
186 .arg("--pcap")
187 .arg(&self.pcap_path)
188 .arg("--foreground")
190 .arg("--address")
192 .arg(ip.to_string())
193 .arg("--gateway")
194 .arg(gateway.to_string())
195 .arg("--netmask")
196 .arg(format!("{}", prefix_to_netmask(prefix_len)));
197
198 for dns in dns_servers {
200 cmd.arg("--dns").arg(dns.to_string());
201 }
202
203 let tcp_specs = passt_tcp_port_specs(port_map);
209 if !tcp_specs.is_empty() {
210 let spec = tcp_specs.join(",");
211 tracing::info!(tcp_ports = %spec, "Configuring passt inbound TCP port forwarding");
212 cmd.arg("--tcp-ports").arg(spec);
213 }
214
215 cmd.stdout(std::process::Stdio::null());
219 match self
220 .stderr_path()
221 .and_then(|p| std::fs::File::create(p).ok())
222 {
223 Some(file) => {
224 cmd.stderr(std::process::Stdio::from(file));
225 }
226 None => {
227 cmd.stderr(std::process::Stdio::null());
228 }
229 }
230
231 let child = cmd.spawn().map_err(|e| {
232 BoxError::NetworkError(format!(
233 "failed to spawn passt: {} (is passt installed?)",
234 e
235 ))
236 })?;
237
238 tracing::info!(
239 pid = child.id(),
240 socket = %self.socket_path.display(),
241 ip = %ip,
242 gateway = %gateway,
243 run_as_root,
244 "Passt daemon started"
245 );
246
247 self.child = Some(child);
248
249 self.wait_for_socket()?;
254
255 Ok(())
256 }
257
258 fn wait_for_socket(&mut self) -> Result<()> {
264 let started_at = Instant::now();
265 let mut socket_seen_at = None;
266 while started_at.elapsed() < PASST_STARTUP_TIMEOUT {
267 if let Some(child) = self.child.as_mut() {
268 if let Ok(Some(status)) = child.try_wait() {
269 return Err(BoxError::NetworkError(format!(
271 "passt exited early with {status} during startup{}",
272 self.stderr_tail()
273 )));
274 }
275 }
276
277 if self.socket_path.exists() {
278 let seen_at = socket_seen_at.get_or_insert_with(Instant::now);
279 if seen_at.elapsed() >= PASST_STARTUP_STABILITY_WINDOW {
280 return Ok(());
281 }
282 }
283 std::thread::sleep(PASST_STARTUP_POLL_INTERVAL);
284 }
285
286 if let Some(mut child) = self.child.take() {
292 let _ = child.kill();
293 let _ = child.wait();
294 }
295
296 Err(BoxError::NetworkError(format!(
297 "passt socket {} did not appear within 5 seconds{}",
298 self.socket_path.display(),
299 self.stderr_tail()
300 )))
301 }
302
303 fn stderr_path(&self) -> Option<PathBuf> {
304 self.socket_path
305 .parent()
306 .map(|parent| parent.join("passt.stderr.log"))
307 }
308
309 fn read_stderr(&self) -> String {
310 self.stderr_path()
311 .and_then(|path| std::fs::read_to_string(path).ok())
312 .unwrap_or_default()
313 }
314
315 fn stderr_tail(&self) -> String {
316 let stderr = self.read_stderr();
317 let mut tail: Vec<&str> = stderr.lines().rev().take(4).collect();
318 tail.reverse();
319 if tail.is_empty() {
320 String::new()
321 } else {
322 format!(" (passt stderr: {})", tail.join("; "))
323 }
324 }
325
326 fn cleanup_failed_launch(&mut self) {
327 if let Some(mut child) = self.child.take() {
328 match child.try_wait() {
329 Ok(Some(_)) => {}
330 Ok(None) | Err(_) => {
331 let _ = child.kill();
332 let _ = child.wait();
333 }
334 }
335 }
336 self.remove_launch_artifacts();
337 }
338
339 fn remove_launch_artifacts(&self) {
340 let _ = std::fs::remove_file(&self.socket_path);
341 let _ = std::fs::remove_file(&self.pcap_path);
342 let _ = std::fs::remove_file(&self.pid_file);
343 if let Some(stderr_path) = self.stderr_path() {
344 let _ = std::fs::remove_file(stderr_path);
345 }
346 }
347
348 pub fn stop(&mut self) {
350 if let Some(ref mut child) = self.child {
351 let pid = child.id();
352 if let Err(e) = child.kill() {
353 tracing::warn!(pid, error = %e, "Failed to kill passt process");
354 } else {
355 let _ = child.wait();
357 tracing::info!(pid, "Passt daemon stopped");
358 }
359 }
360 self.child = None;
361 self.net_socket_fd = None;
362 self.net_proxy_fd = None;
363
364 std::fs::remove_file(&self.socket_path).ok();
366 std::fs::remove_file(&self.pcap_path).ok();
367 std::fs::remove_file(&self.pid_file).ok();
368 }
369
370 pub fn is_running(&mut self) -> bool {
372 match self.child {
373 Some(ref mut child) => child.try_wait().ok().flatten().is_none(),
374 None => false,
375 }
376 }
377}
378
379fn passt_sandbox_was_denied(stderr: &str) -> bool {
380 stderr.contains("Failed to sandbox process")
381 && (stderr.contains("unshare: Operation not permitted")
382 || stderr.contains("Operation not permitted"))
383}
384
385#[cfg(target_os = "linux")]
386fn should_retry_passt_as_root(stderr: &str) -> bool {
387 let launcher_is_root = unsafe { libc::geteuid() == 0 };
388 launcher_is_root && passt_sandbox_was_denied(stderr)
389}
390
391#[cfg(not(target_os = "linux"))]
392fn should_retry_passt_as_root(_stderr: &str) -> bool {
393 false
394}
395
396impl Drop for PasstManager {
397 fn drop(&mut self) {
398 }
405}
406
407pub fn terminate_passt(socket_dir: &Path) {
413 let pid_file = socket_dir.join("passt.pid");
414 if let Ok(contents) = std::fs::read_to_string(&pid_file) {
415 if let Ok(pid) = contents.trim().parse::<i32>() {
416 if pid > 1 && pid_is_passt(pid) {
420 #[cfg(unix)]
422 unsafe {
423 libc::kill(pid, libc::SIGTERM);
424 }
425 tracing::info!(pid, "Terminated passt daemon");
426 }
427 }
428 }
429 let _ = std::fs::remove_file(&pid_file);
430 let _ = std::fs::remove_file(socket_dir.join("passt.sock"));
431 let _ = std::fs::remove_file(socket_dir.join("passt.pcap"));
432}
433
434#[cfg(target_os = "linux")]
440fn pid_is_passt(pid: i32) -> bool {
441 std::fs::read_to_string(format!("/proc/{pid}/comm"))
442 .map(|comm| passt_process_name_is_known(comm.trim()))
443 .unwrap_or(false)
444}
445
446#[cfg(target_os = "linux")]
447fn passt_process_name_is_known(name: &str) -> bool {
448 matches!(name, "passt" | "passt.avx2")
453}
454
455#[cfg(not(target_os = "linux"))]
456fn pid_is_passt(_pid: i32) -> bool {
457 true
460}
461
462impl super::NetworkBackend for PasstManager {
463 fn socket_path(&self) -> &std::path::Path {
464 self.socket_path()
465 }
466
467 fn stop(&mut self) {
468 self.stop();
469 }
470}
471
472fn passt_tcp_port_specs(port_map: &[String]) -> Vec<String> {
474 port_map
475 .iter()
476 .filter_map(|m| a3s_box_core::parse_port_mapping(m).ok())
477 .filter(|m| m.host_port != 0)
478 .map(|m| format!("{}:{}", m.host_port, m.guest_port))
479 .collect()
480}
481
482fn prefix_to_netmask(prefix: u8) -> Ipv4Addr {
484 if prefix == 0 {
485 return Ipv4Addr::new(0, 0, 0, 0);
486 }
487 let mask = !((1u32 << (32 - prefix)) - 1);
488 Ipv4Addr::from(mask)
489}
490
491#[cfg(test)]
492mod tests {
493 use super::*;
494
495 #[test]
496 fn test_prefix_to_netmask() {
497 assert_eq!(prefix_to_netmask(24), Ipv4Addr::new(255, 255, 255, 0));
498 assert_eq!(prefix_to_netmask(16), Ipv4Addr::new(255, 255, 0, 0));
499 assert_eq!(prefix_to_netmask(8), Ipv4Addr::new(255, 0, 0, 0));
500 assert_eq!(prefix_to_netmask(32), Ipv4Addr::new(255, 255, 255, 255));
501 assert_eq!(prefix_to_netmask(0), Ipv4Addr::new(0, 0, 0, 0));
502 assert_eq!(prefix_to_netmask(28), Ipv4Addr::new(255, 255, 255, 240));
503 }
504
505 #[test]
506 fn test_passt_tcp_port_specs_skips_invalid_and_auto_assigned_ports() {
507 let specs = passt_tcp_port_specs(&[
508 "8080:80".to_string(),
509 "0:443".to_string(),
510 "not-a-port-map".to_string(),
511 "9000:90/tcp".to_string(),
512 ]);
513
514 assert_eq!(specs, vec!["8080:80", "9000:90"]);
515 }
516
517 #[test]
518 fn passt_root_retry_requires_an_explicit_sandbox_permission_failure() {
519 assert!(passt_sandbox_was_denied(
520 "unshare: Operation not permitted\nFailed to sandbox process, exiting"
521 ));
522 assert!(!passt_sandbox_was_denied(
523 "Failed to bind UNIX domain socket"
524 ));
525 assert!(!passt_sandbox_was_denied(
526 "Failed to sandbox process, exiting"
527 ));
528 }
529
530 #[test]
531 fn test_passt_manager_new() {
532 let dir = tempfile::tempdir().unwrap();
533 let mgr = PasstManager::new(dir.path());
534 assert_eq!(mgr.socket_path(), dir.path().join("passt.sock"));
535 assert_eq!(mgr.pcap_path(), dir.path().join("passt.pcap"));
536 }
537
538 #[cfg(target_os = "linux")]
539 #[test]
540 fn peer_bridge_descriptors_are_close_on_exec_until_the_shim_claims_them() {
541 let dir = tempfile::tempdir().unwrap();
542 let mut manager = PasstManager::new(dir.path());
543
544 manager.enable_peer_bridge().unwrap();
545
546 for descriptor in [manager.net_socket_fd(), manager.net_proxy_fd()] {
547 let descriptor = descriptor.unwrap();
548 let flags = unsafe { libc::fcntl(descriptor, libc::F_GETFD) };
549 assert_ne!(flags, -1);
550 assert_ne!(flags & libc::FD_CLOEXEC, 0);
551 }
552 }
553
554 #[test]
555 fn test_passt_manager_implements_network_backend() {
556 let dir = tempfile::tempdir().unwrap();
557 let mut mgr = PasstManager::new(dir.path());
558 let socket_path = dir.path().join("passt.sock");
559 let backend: &mut dyn crate::network::NetworkBackend = &mut mgr;
560
561 assert_eq!(backend.socket_path(), socket_path.as_path());
562 backend.stop();
563 }
564
565 #[test]
566 fn test_passt_manager_not_running_initially() {
567 let dir = tempfile::tempdir().unwrap();
568 let mut mgr = PasstManager::new(dir.path());
569 assert!(!mgr.is_running());
570 }
571
572 #[test]
573 fn test_passt_manager_stop_when_not_started() {
574 let dir = tempfile::tempdir().unwrap();
575 let mut mgr = PasstManager::new(dir.path());
576 mgr.stop();
578 assert!(!mgr.is_running());
579 }
580
581 #[test]
582 fn test_passt_manager_stop_removes_artifacts_without_child() {
583 let dir = tempfile::tempdir().unwrap();
584 let mut mgr = PasstManager::new(dir.path());
585 std::fs::write(&mgr.socket_path, "socket").unwrap();
586 std::fs::write(&mgr.pcap_path, "pcap").unwrap();
587 std::fs::write(&mgr.pid_file, "123").unwrap();
588
589 mgr.stop();
590
591 assert!(!mgr.socket_path.exists());
592 assert!(!mgr.pcap_path.exists());
593 assert!(!mgr.pid_file.exists());
594 }
595
596 #[cfg(unix)]
597 #[test]
598 fn test_passt_manager_stop_kills_child_and_removes_artifacts() {
599 let dir = tempfile::tempdir().unwrap();
600 let mut mgr = PasstManager::new(dir.path());
601 mgr.child = Some(
602 Command::new("sh")
603 .arg("-c")
604 .arg("sleep 30")
605 .spawn()
606 .unwrap(),
607 );
608 std::fs::write(&mgr.socket_path, "socket").unwrap();
609 std::fs::write(&mgr.pcap_path, "pcap").unwrap();
610 std::fs::write(&mgr.pid_file, "123").unwrap();
611
612 assert!(mgr.is_running());
613 mgr.stop();
614
615 assert!(!mgr.is_running());
616 assert!(!mgr.socket_path.exists());
617 assert!(!mgr.pcap_path.exists());
618 assert!(!mgr.pid_file.exists());
619 }
620
621 #[test]
622 fn test_passt_manager_socket_path() {
623 let dir = tempfile::tempdir().unwrap();
624 let box_dir = dir.path().join("boxes").join("test-box-id");
625 let mgr = PasstManager::new(&box_dir);
626 assert_eq!(mgr.socket_path(), box_dir.join("passt.sock"));
627 assert_eq!(mgr.pcap_path(), box_dir.join("passt.pcap"));
628 }
629
630 #[test]
631 fn test_spawn_returns_directory_creation_error_before_running_passt() {
632 let dir = tempfile::tempdir().unwrap();
633 let socket_dir = dir.path().join("socket-dir-is-file");
634 std::fs::write(&socket_dir, "not a directory").unwrap();
635 let mut mgr = PasstManager::new(&socket_dir);
636
637 let err = mgr
638 .spawn(
639 Ipv4Addr::new(10, 0, 2, 15),
640 Ipv4Addr::new(10, 0, 2, 2),
641 24,
642 &[Ipv4Addr::new(1, 1, 1, 1)],
643 &["8080:80".to_string()],
644 )
645 .unwrap_err();
646
647 assert!(err
648 .to_string()
649 .contains("failed to create socket directory"));
650 assert!(!mgr.is_running());
651 }
652
653 #[test]
654 fn test_wait_for_socket_succeeds_when_socket_exists() {
655 let dir = tempfile::tempdir().unwrap();
656 let mut mgr = PasstManager::new(dir.path());
657 std::fs::write(&mgr.socket_path, "socket").unwrap();
658
659 mgr.wait_for_socket().unwrap();
660 }
661
662 #[cfg(unix)]
663 #[test]
664 fn test_wait_for_socket_reports_early_exit_with_stderr_tail() {
665 let dir = tempfile::tempdir().unwrap();
666 let mut mgr = PasstManager::new(dir.path());
667 std::fs::write(
668 dir.path().join("passt.stderr.log"),
669 "line1\nline2\nline3\nline4\nline5\n",
670 )
671 .unwrap();
672 mgr.child = Some(Command::new("sh").arg("-c").arg("exit 7").spawn().unwrap());
673
674 let err = mgr.wait_for_socket().unwrap_err();
675 let message = err.to_string();
676
677 assert!(message.contains("passt exited early"));
678 assert!(message.contains("line2; line3; line4; line5"));
679 assert!(!mgr.is_running());
680 }
681
682 #[test]
683 fn test_terminate_passt_removes_socket_and_pid_files() {
684 let dir = tempfile::tempdir().unwrap();
685 let socket_path = dir.path().join("passt.sock");
686 let pid_path = dir.path().join("passt.pid");
687 let pcap_path = dir.path().join("passt.pcap");
688
689 std::fs::write(&socket_path, "fake").unwrap();
691 std::fs::write(&pid_path, "2147483647").unwrap();
692 std::fs::write(&pcap_path, "fake pcap").unwrap();
693
694 terminate_passt(dir.path());
695
696 assert!(!socket_path.exists());
697 assert!(!pid_path.exists());
698 assert!(!pcap_path.exists());
699 }
700
701 #[test]
702 fn test_terminate_passt_removes_artifacts_with_invalid_pid_file() {
703 let dir = tempfile::tempdir().unwrap();
704 let socket_path = dir.path().join("passt.sock");
705 let pid_path = dir.path().join("passt.pid");
706 let pcap_path = dir.path().join("passt.pcap");
707
708 std::fs::write(&socket_path, "fake").unwrap();
709 std::fs::write(&pid_path, "not a pid").unwrap();
710 std::fs::write(&pcap_path, "fake pcap").unwrap();
711
712 terminate_passt(dir.path());
713
714 assert!(!socket_path.exists());
715 assert!(!pid_path.exists());
716 assert!(!pcap_path.exists());
717 }
718
719 #[cfg(target_os = "linux")]
720 #[test]
721 fn test_pid_is_passt_rejects_non_passt_processes() {
722 assert!(!pid_is_passt(std::process::id() as i32));
723 assert!(!pid_is_passt(2_147_483_647));
724 }
725
726 #[cfg(target_os = "linux")]
727 #[test]
728 fn passt_process_name_accepts_the_packaged_simd_variant() {
729 assert!(passt_process_name_is_known("passt"));
730 assert!(passt_process_name_is_known("passt.avx2"));
731 assert!(!passt_process_name_is_known("passt-helper"));
732 assert!(!passt_process_name_is_known("passt.avx2.old"));
733 }
734}