Skip to main content

a3s_box_runtime/network/
passt.rs

1//! Passt process management for virtio-net networking.
2//!
3//! Manages the lifecycle of `passt` daemon instances that provide
4//! the virtio-net backend for bridge-mode networking. Each box gets
5//! its own passt process with a dedicated Unix socket.
6
7use a3s_box_core::error::{BoxError, Result};
8use std::net::Ipv4Addr;
9use std::path::{Path, PathBuf};
10use std::process::{Child, Command};
11
12/// Manages a passt daemon instance for a single box.
13#[derive(Debug)]
14pub struct PasstManager {
15    /// Path to the passt Unix socket.
16    socket_path: PathBuf,
17    /// Path to passt's guest-side packet capture.
18    pcap_path: PathBuf,
19    /// Child process handle (None if not started).
20    child: Option<Child>,
21    /// PID file path for the passt process.
22    pid_file: PathBuf,
23}
24
25impl PasstManager {
26    /// Create a new PasstManager.
27    ///
28    /// The socket and PID file are placed directly in the provided runtime
29    /// socket directory (the same directory that holds the exec/PTY control
30    /// sockets, e.g. `/tmp/a3s-box-sockets/<box_id>`).
31    ///
32    /// This directory MUST be reachable by the user passt runs as. When passt
33    /// is started as root it drops privileges to `nobody`, so the socket
34    /// directory has to be world-traversable — the box's `~/.a3s/boxes/<id>`
35    /// home is mode 0700 for root and would leave passt unable to bind its
36    /// socket, silently breaking all bridge networking and Compose.
37    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    /// Get the passt socket path.
47    pub fn socket_path(&self) -> &Path {
48        &self.socket_path
49    }
50
51    /// Get the passt packet capture path.
52    pub fn pcap_path(&self) -> &Path {
53        &self.pcap_path
54    }
55
56    /// Spawn the passt daemon.
57    ///
58    /// Configures passt with:
59    /// - Unix socket mode (no PID namespace)
60    /// - The assigned IP, gateway, prefix length
61    /// - DNS forwarding
62    /// - No DHCP (static IP assignment)
63    /// - Inbound TCP port forwarding for any published ports (`port_map`)
64    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        // Ensure parent directory exists.
73        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            // passt drops privileges to `nobody` when launched as root, so the
83            // directory it binds its socket (and writes its PID file) in must be
84            // writable by that user. Widen the directory permissions; the path
85            // is an ephemeral, per-box runtime directory under a world-traversable
86            // base, so this only affects this box's control sockets.
87            #[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        // Remove stale socket if it exists
104        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            // Run in foreground (we manage the process)
119            .arg("--foreground")
120            // Configure the network
121            .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        // Add DNS servers
129        for dns in dns_servers {
130            cmd.arg("--dns").arg(dns.to_string());
131        }
132
133        // Forward published TCP ports into the guest. libkrun discards the
134        // TSI host_port_map once a virtio-net device is attached, so passt is
135        // what actually publishes `-p host:guest` in bridge mode. Auto-assigned
136        // host ports (host_port == 0) cannot be forwarded by passt and are
137        // skipped. passt accepts a comma-separated `host:guest,...` spec.
138        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        // Capture passt's stderr to a log file so spawn failures (bad args,
146        // unsupported flags, permission errors after dropping privileges) are
147        // diagnosable instead of silently discarded to /dev/null.
148        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        // Wait briefly for the socket to appear
181        self.wait_for_socket()?;
182
183        Ok(())
184    }
185
186    /// Wait for the passt socket to become available.
187    ///
188    /// Also detects immediate passt exit (e.g. bad args or a permission failure
189    /// after dropping privileges) so the real cause is surfaced instead of a
190    /// misleading 5-second timeout.
191    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; // 5 seconds total
210        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                    // Early exit — try_wait reaped it, so nothing lingers.
217                    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        // Timed out with passt still alive: kill the child we spawned so it does
227        // not linger holding the published port. `Drop` deliberately leaves a
228        // healthy passt running (detached use), and the pid-file fallback used by
229        // boot-failure cleanup can miss it if passt hasn't written the file yet —
230        // so reap it here directly via the handle we hold.
231        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    /// Stop the passt daemon.
244    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                // Reap the child to avoid zombies
251                let _ = child.wait();
252                tracing::info!(pid, "Passt daemon stopped");
253            }
254        }
255        self.child = None;
256
257        // Clean up socket and PID file
258        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    /// Check if the passt process is still running.
264    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        // Intentionally does NOT kill passt. passt must outlive the process that
275        // spawned it: a detached `run -d` returns while the box keeps running,
276        // and the VM (driven by the shim) outlives the CLI. Killing on drop here
277        // is exactly what previously left detached bridge boxes with dead
278        // networking. passt is reaped on box stop/rm via `terminate_passt`, which
279        // uses the PID file as the source of truth.
280    }
281}
282
283/// Terminate a passt daemon by its PID file and remove its socket/PID files.
284///
285/// passt outlives the `PasstManager` that launched it (so detached boxes keep
286/// working after the CLI exits), so box teardown cannot rely on a live handle —
287/// the PID file written into the box's runtime socket directory is authoritative.
288pub 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            // Verify the pid is still passt before signalling: the pid file is a
293            // stale snapshot, so if passt already exited and the kernel recycled
294            // its pid, a bare kill would SIGTERM an unrelated process.
295            if pid > 1 && pid_is_passt(pid) {
296                // SIGTERM; passt exits and is reaped by its (re)parent.
297                #[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/// Best-effort check that `pid` is actually a passt process, to avoid SIGTERM-ing
311/// an unrelated process that recycled the pid after passt exited. Reads
312/// `/proc/<pid>/comm`; on any error (pid gone, no `/proc`) returns false so the
313/// stale pid is left alone — a genuinely-dead passt was already reaped by its
314/// reparent.
315#[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    // No procfs to consult; passt is Linux-only, so this path is unreachable in
325    // practice — preserve the prior kill-by-pid-file behavior.
326    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
339/// Convert published ports to passt's inbound TCP forwarding spec entries.
340fn 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
349/// Convert a prefix length to a dotted-decimal netmask string.
350fn 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        // Should not panic
415        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        // A non-existent PID so the SIGTERM is a harmless no-op (ESRCH).
528        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}