1use a3s_box_core::error::{BoxError, Result};
8use std::net::Ipv4Addr;
9use std::path::{Path, PathBuf};
10use std::process::{Child, Command};
11
12#[derive(Debug)]
14pub struct PasstManager {
15 socket_path: PathBuf,
17 pcap_path: PathBuf,
19 child: Option<Child>,
21 pid_file: PathBuf,
23}
24
25impl PasstManager {
26 pub fn new(socket_dir: &Path) -> Self {
38 Self {
39 socket_path: socket_dir.join("passt.sock"),
40 pcap_path: socket_dir.join("passt.pcap"),
41 pid_file: socket_dir.join("passt.pid"),
42 child: None,
43 }
44 }
45
46 pub fn socket_path(&self) -> &Path {
48 &self.socket_path
49 }
50
51 pub fn pcap_path(&self) -> &Path {
53 &self.pcap_path
54 }
55
56 pub fn spawn(
65 &mut self,
66 ip: Ipv4Addr,
67 gateway: Ipv4Addr,
68 prefix_len: u8,
69 dns_servers: &[Ipv4Addr],
70 port_map: &[String],
71 ) -> Result<()> {
72 if let Some(parent) = self.socket_path.parent() {
74 std::fs::create_dir_all(parent).map_err(|e| {
75 BoxError::NetworkError(format!(
76 "failed to create socket directory {}: {}",
77 parent.display(),
78 e
79 ))
80 })?;
81
82 #[cfg(unix)]
88 {
89 use std::os::unix::fs::PermissionsExt;
90 if let Err(e) =
91 std::fs::set_permissions(parent, std::fs::Permissions::from_mode(0o777))
92 {
93 tracing::warn!(
94 dir = %parent.display(),
95 error = %e,
96 "Failed to widen passt socket directory permissions; \
97 passt may be unable to bind its socket after dropping privileges"
98 );
99 }
100 }
101 }
102
103 if self.socket_path.exists() {
105 std::fs::remove_file(&self.socket_path).ok();
106 }
107 if self.pcap_path.exists() {
108 std::fs::remove_file(&self.pcap_path).ok();
109 }
110
111 let mut cmd = Command::new("passt");
112 cmd.arg("--socket")
113 .arg(&self.socket_path)
114 .arg("--pid")
115 .arg(&self.pid_file)
116 .arg("--pcap")
117 .arg(&self.pcap_path)
118 .arg("--foreground")
120 .arg("--address")
122 .arg(ip.to_string())
123 .arg("--gateway")
124 .arg(gateway.to_string())
125 .arg("--netmask")
126 .arg(format!("{}", prefix_to_netmask(prefix_len)));
127
128 for dns in dns_servers {
130 cmd.arg("--dns").arg(dns.to_string());
131 }
132
133 let tcp_specs = passt_tcp_port_specs(port_map);
139 if !tcp_specs.is_empty() {
140 let spec = tcp_specs.join(",");
141 tracing::info!(tcp_ports = %spec, "Configuring passt inbound TCP port forwarding");
142 cmd.arg("--tcp-ports").arg(spec);
143 }
144
145 cmd.stdout(std::process::Stdio::null());
149 match self
150 .socket_path
151 .parent()
152 .map(|p| p.join("passt.stderr.log"))
153 .and_then(|p| std::fs::File::create(p).ok())
154 {
155 Some(file) => {
156 cmd.stderr(std::process::Stdio::from(file));
157 }
158 None => {
159 cmd.stderr(std::process::Stdio::null());
160 }
161 }
162
163 let child = cmd.spawn().map_err(|e| {
164 BoxError::NetworkError(format!(
165 "failed to spawn passt: {} (is passt installed?)",
166 e
167 ))
168 })?;
169
170 tracing::info!(
171 pid = child.id(),
172 socket = %self.socket_path.display(),
173 ip = %ip,
174 gateway = %gateway,
175 "Passt daemon started"
176 );
177
178 self.child = Some(child);
179
180 self.wait_for_socket()?;
182
183 Ok(())
184 }
185
186 fn wait_for_socket(&mut self) -> Result<()> {
192 let stderr_path = self
193 .socket_path
194 .parent()
195 .map(|p| p.join("passt.stderr.log"));
196 let read_stderr = |path: &Option<PathBuf>| -> String {
197 path.as_ref()
198 .and_then(|p| std::fs::read_to_string(p).ok())
199 .map(|s| {
200 let mut tail: Vec<&str> = s.lines().rev().take(4).collect();
201 tail.reverse();
202 tail.join("; ")
203 })
204 .filter(|s| !s.trim().is_empty())
205 .map(|s| format!(" (passt stderr: {s})"))
206 .unwrap_or_default()
207 };
208
209 let max_attempts = 50; for _ in 0..max_attempts {
211 if self.socket_path.exists() {
212 return Ok(());
213 }
214 if let Some(child) = self.child.as_mut() {
215 if let Ok(Some(status)) = child.try_wait() {
216 return Err(BoxError::NetworkError(format!(
218 "passt exited early with {status} before creating its socket{}",
219 read_stderr(&stderr_path)
220 )));
221 }
222 }
223 std::thread::sleep(std::time::Duration::from_millis(100));
224 }
225
226 if let Some(mut child) = self.child.take() {
232 let _ = child.kill();
233 let _ = child.wait();
234 }
235
236 Err(BoxError::NetworkError(format!(
237 "passt socket {} did not appear within 5 seconds{}",
238 self.socket_path.display(),
239 read_stderr(&stderr_path)
240 )))
241 }
242
243 pub fn stop(&mut self) {
245 if let Some(ref mut child) = self.child {
246 let pid = child.id();
247 if let Err(e) = child.kill() {
248 tracing::warn!(pid, error = %e, "Failed to kill passt process");
249 } else {
250 let _ = child.wait();
252 tracing::info!(pid, "Passt daemon stopped");
253 }
254 }
255 self.child = None;
256
257 std::fs::remove_file(&self.socket_path).ok();
259 std::fs::remove_file(&self.pcap_path).ok();
260 std::fs::remove_file(&self.pid_file).ok();
261 }
262
263 pub fn is_running(&mut self) -> bool {
265 match self.child {
266 Some(ref mut child) => child.try_wait().ok().flatten().is_none(),
267 None => false,
268 }
269 }
270}
271
272impl Drop for PasstManager {
273 fn drop(&mut self) {
274 }
281}
282
283pub fn terminate_passt(socket_dir: &Path) {
289 let pid_file = socket_dir.join("passt.pid");
290 if let Ok(contents) = std::fs::read_to_string(&pid_file) {
291 if let Ok(pid) = contents.trim().parse::<i32>() {
292 if pid > 1 && pid_is_passt(pid) {
296 #[cfg(unix)]
298 unsafe {
299 libc::kill(pid, libc::SIGTERM);
300 }
301 tracing::info!(pid, "Terminated passt daemon");
302 }
303 }
304 }
305 let _ = std::fs::remove_file(&pid_file);
306 let _ = std::fs::remove_file(socket_dir.join("passt.sock"));
307 let _ = std::fs::remove_file(socket_dir.join("passt.pcap"));
308}
309
310#[cfg(target_os = "linux")]
316fn pid_is_passt(pid: i32) -> bool {
317 std::fs::read_to_string(format!("/proc/{pid}/comm"))
318 .map(|comm| comm.trim() == "passt")
319 .unwrap_or(false)
320}
321
322#[cfg(not(target_os = "linux"))]
323fn pid_is_passt(_pid: i32) -> bool {
324 true
327}
328
329impl super::NetworkBackend for PasstManager {
330 fn socket_path(&self) -> &std::path::Path {
331 self.socket_path()
332 }
333
334 fn stop(&mut self) {
335 self.stop();
336 }
337}
338
339fn passt_tcp_port_specs(port_map: &[String]) -> Vec<String> {
341 port_map
342 .iter()
343 .filter_map(|m| a3s_box_core::parse_port_mapping(m).ok())
344 .filter(|m| m.host_port != 0)
345 .map(|m| format!("{}:{}", m.host_port, m.guest_port))
346 .collect()
347}
348
349fn prefix_to_netmask(prefix: u8) -> Ipv4Addr {
351 if prefix == 0 {
352 return Ipv4Addr::new(0, 0, 0, 0);
353 }
354 let mask = !((1u32 << (32 - prefix)) - 1);
355 Ipv4Addr::from(mask)
356}
357
358#[cfg(test)]
359mod tests {
360 use super::*;
361
362 #[test]
363 fn test_prefix_to_netmask() {
364 assert_eq!(prefix_to_netmask(24), Ipv4Addr::new(255, 255, 255, 0));
365 assert_eq!(prefix_to_netmask(16), Ipv4Addr::new(255, 255, 0, 0));
366 assert_eq!(prefix_to_netmask(8), Ipv4Addr::new(255, 0, 0, 0));
367 assert_eq!(prefix_to_netmask(32), Ipv4Addr::new(255, 255, 255, 255));
368 assert_eq!(prefix_to_netmask(0), Ipv4Addr::new(0, 0, 0, 0));
369 assert_eq!(prefix_to_netmask(28), Ipv4Addr::new(255, 255, 255, 240));
370 }
371
372 #[test]
373 fn test_passt_tcp_port_specs_skips_invalid_and_auto_assigned_ports() {
374 let specs = passt_tcp_port_specs(&[
375 "8080:80".to_string(),
376 "0:443".to_string(),
377 "not-a-port-map".to_string(),
378 "9000:90/tcp".to_string(),
379 ]);
380
381 assert_eq!(specs, vec!["8080:80", "9000:90"]);
382 }
383
384 #[test]
385 fn test_passt_manager_new() {
386 let dir = tempfile::tempdir().unwrap();
387 let mgr = PasstManager::new(dir.path());
388 assert_eq!(mgr.socket_path(), dir.path().join("passt.sock"));
389 assert_eq!(mgr.pcap_path(), dir.path().join("passt.pcap"));
390 }
391
392 #[test]
393 fn test_passt_manager_implements_network_backend() {
394 let dir = tempfile::tempdir().unwrap();
395 let mut mgr = PasstManager::new(dir.path());
396 let socket_path = dir.path().join("passt.sock");
397 let backend: &mut dyn crate::network::NetworkBackend = &mut mgr;
398
399 assert_eq!(backend.socket_path(), socket_path.as_path());
400 backend.stop();
401 }
402
403 #[test]
404 fn test_passt_manager_not_running_initially() {
405 let dir = tempfile::tempdir().unwrap();
406 let mut mgr = PasstManager::new(dir.path());
407 assert!(!mgr.is_running());
408 }
409
410 #[test]
411 fn test_passt_manager_stop_when_not_started() {
412 let dir = tempfile::tempdir().unwrap();
413 let mut mgr = PasstManager::new(dir.path());
414 mgr.stop();
416 assert!(!mgr.is_running());
417 }
418
419 #[test]
420 fn test_passt_manager_stop_removes_artifacts_without_child() {
421 let dir = tempfile::tempdir().unwrap();
422 let mut mgr = PasstManager::new(dir.path());
423 std::fs::write(&mgr.socket_path, "socket").unwrap();
424 std::fs::write(&mgr.pcap_path, "pcap").unwrap();
425 std::fs::write(&mgr.pid_file, "123").unwrap();
426
427 mgr.stop();
428
429 assert!(!mgr.socket_path.exists());
430 assert!(!mgr.pcap_path.exists());
431 assert!(!mgr.pid_file.exists());
432 }
433
434 #[cfg(unix)]
435 #[test]
436 fn test_passt_manager_stop_kills_child_and_removes_artifacts() {
437 let dir = tempfile::tempdir().unwrap();
438 let mut mgr = PasstManager::new(dir.path());
439 mgr.child = Some(
440 Command::new("sh")
441 .arg("-c")
442 .arg("sleep 30")
443 .spawn()
444 .unwrap(),
445 );
446 std::fs::write(&mgr.socket_path, "socket").unwrap();
447 std::fs::write(&mgr.pcap_path, "pcap").unwrap();
448 std::fs::write(&mgr.pid_file, "123").unwrap();
449
450 assert!(mgr.is_running());
451 mgr.stop();
452
453 assert!(!mgr.is_running());
454 assert!(!mgr.socket_path.exists());
455 assert!(!mgr.pcap_path.exists());
456 assert!(!mgr.pid_file.exists());
457 }
458
459 #[test]
460 fn test_passt_manager_socket_path() {
461 let dir = tempfile::tempdir().unwrap();
462 let box_dir = dir.path().join("boxes").join("test-box-id");
463 let mgr = PasstManager::new(&box_dir);
464 assert_eq!(mgr.socket_path(), box_dir.join("passt.sock"));
465 assert_eq!(mgr.pcap_path(), box_dir.join("passt.pcap"));
466 }
467
468 #[test]
469 fn test_spawn_returns_directory_creation_error_before_running_passt() {
470 let dir = tempfile::tempdir().unwrap();
471 let socket_dir = dir.path().join("socket-dir-is-file");
472 std::fs::write(&socket_dir, "not a directory").unwrap();
473 let mut mgr = PasstManager::new(&socket_dir);
474
475 let err = mgr
476 .spawn(
477 Ipv4Addr::new(10, 0, 2, 15),
478 Ipv4Addr::new(10, 0, 2, 2),
479 24,
480 &[Ipv4Addr::new(1, 1, 1, 1)],
481 &["8080:80".to_string()],
482 )
483 .unwrap_err();
484
485 assert!(err
486 .to_string()
487 .contains("failed to create socket directory"));
488 assert!(!mgr.is_running());
489 }
490
491 #[test]
492 fn test_wait_for_socket_succeeds_when_socket_exists() {
493 let dir = tempfile::tempdir().unwrap();
494 let mut mgr = PasstManager::new(dir.path());
495 std::fs::write(&mgr.socket_path, "socket").unwrap();
496
497 mgr.wait_for_socket().unwrap();
498 }
499
500 #[cfg(unix)]
501 #[test]
502 fn test_wait_for_socket_reports_early_exit_with_stderr_tail() {
503 let dir = tempfile::tempdir().unwrap();
504 let mut mgr = PasstManager::new(dir.path());
505 std::fs::write(
506 dir.path().join("passt.stderr.log"),
507 "line1\nline2\nline3\nline4\nline5\n",
508 )
509 .unwrap();
510 mgr.child = Some(Command::new("sh").arg("-c").arg("exit 7").spawn().unwrap());
511
512 let err = mgr.wait_for_socket().unwrap_err();
513 let message = err.to_string();
514
515 assert!(message.contains("passt exited early"));
516 assert!(message.contains("line2; line3; line4; line5"));
517 assert!(!mgr.is_running());
518 }
519
520 #[test]
521 fn test_terminate_passt_removes_socket_and_pid_files() {
522 let dir = tempfile::tempdir().unwrap();
523 let socket_path = dir.path().join("passt.sock");
524 let pid_path = dir.path().join("passt.pid");
525 let pcap_path = dir.path().join("passt.pcap");
526
527 std::fs::write(&socket_path, "fake").unwrap();
529 std::fs::write(&pid_path, "2147483647").unwrap();
530 std::fs::write(&pcap_path, "fake pcap").unwrap();
531
532 terminate_passt(dir.path());
533
534 assert!(!socket_path.exists());
535 assert!(!pid_path.exists());
536 assert!(!pcap_path.exists());
537 }
538
539 #[test]
540 fn test_terminate_passt_removes_artifacts_with_invalid_pid_file() {
541 let dir = tempfile::tempdir().unwrap();
542 let socket_path = dir.path().join("passt.sock");
543 let pid_path = dir.path().join("passt.pid");
544 let pcap_path = dir.path().join("passt.pcap");
545
546 std::fs::write(&socket_path, "fake").unwrap();
547 std::fs::write(&pid_path, "not a pid").unwrap();
548 std::fs::write(&pcap_path, "fake pcap").unwrap();
549
550 terminate_passt(dir.path());
551
552 assert!(!socket_path.exists());
553 assert!(!pid_path.exists());
554 assert!(!pcap_path.exists());
555 }
556
557 #[cfg(target_os = "linux")]
558 #[test]
559 fn test_pid_is_passt_rejects_non_passt_processes() {
560 assert!(!pid_is_passt(std::process::id() as i32));
561 assert!(!pid_is_passt(2_147_483_647));
562 }
563}