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: Vec<String> = port_map
139            .iter()
140            .filter_map(|m| a3s_box_core::parse_port_mapping(m).ok())
141            .filter(|m| m.host_port != 0)
142            .map(|m| format!("{}:{}", m.host_port, m.guest_port))
143            .collect();
144        if !tcp_specs.is_empty() {
145            let spec = tcp_specs.join(",");
146            tracing::info!(tcp_ports = %spec, "Configuring passt inbound TCP port forwarding");
147            cmd.arg("--tcp-ports").arg(spec);
148        }
149
150        // Capture passt's stderr to a log file so spawn failures (bad args,
151        // unsupported flags, permission errors after dropping privileges) are
152        // diagnosable instead of silently discarded to /dev/null.
153        cmd.stdout(std::process::Stdio::null());
154        match self
155            .socket_path
156            .parent()
157            .map(|p| p.join("passt.stderr.log"))
158            .and_then(|p| std::fs::File::create(p).ok())
159        {
160            Some(file) => {
161                cmd.stderr(std::process::Stdio::from(file));
162            }
163            None => {
164                cmd.stderr(std::process::Stdio::null());
165            }
166        }
167
168        let child = cmd.spawn().map_err(|e| {
169            BoxError::NetworkError(format!(
170                "failed to spawn passt: {} (is passt installed?)",
171                e
172            ))
173        })?;
174
175        tracing::info!(
176            pid = child.id(),
177            socket = %self.socket_path.display(),
178            ip = %ip,
179            gateway = %gateway,
180            "Passt daemon started"
181        );
182
183        self.child = Some(child);
184
185        // Wait briefly for the socket to appear
186        self.wait_for_socket()?;
187
188        Ok(())
189    }
190
191    /// Wait for the passt socket to become available.
192    ///
193    /// Also detects immediate passt exit (e.g. bad args or a permission failure
194    /// after dropping privileges) so the real cause is surfaced instead of a
195    /// misleading 5-second timeout.
196    fn wait_for_socket(&mut self) -> Result<()> {
197        let stderr_path = self
198            .socket_path
199            .parent()
200            .map(|p| p.join("passt.stderr.log"));
201        let read_stderr = |path: &Option<PathBuf>| -> String {
202            path.as_ref()
203                .and_then(|p| std::fs::read_to_string(p).ok())
204                .map(|s| {
205                    let mut tail: Vec<&str> = s.lines().rev().take(4).collect();
206                    tail.reverse();
207                    tail.join("; ")
208                })
209                .filter(|s| !s.trim().is_empty())
210                .map(|s| format!(" (passt stderr: {s})"))
211                .unwrap_or_default()
212        };
213
214        let max_attempts = 50; // 5 seconds total
215        for _ in 0..max_attempts {
216            if self.socket_path.exists() {
217                return Ok(());
218            }
219            if let Some(child) = self.child.as_mut() {
220                if let Ok(Some(status)) = child.try_wait() {
221                    // Early exit — try_wait reaped it, so nothing lingers.
222                    return Err(BoxError::NetworkError(format!(
223                        "passt exited early with {status} before creating its socket{}",
224                        read_stderr(&stderr_path)
225                    )));
226                }
227            }
228            std::thread::sleep(std::time::Duration::from_millis(100));
229        }
230
231        // Timed out with passt still alive: kill the child we spawned so it does
232        // not linger holding the published port. `Drop` deliberately leaves a
233        // healthy passt running (detached use), and the pid-file fallback used by
234        // boot-failure cleanup can miss it if passt hasn't written the file yet —
235        // so reap it here directly via the handle we hold.
236        if let Some(mut child) = self.child.take() {
237            let _ = child.kill();
238            let _ = child.wait();
239        }
240
241        Err(BoxError::NetworkError(format!(
242            "passt socket {} did not appear within 5 seconds{}",
243            self.socket_path.display(),
244            read_stderr(&stderr_path)
245        )))
246    }
247
248    /// Stop the passt daemon.
249    pub fn stop(&mut self) {
250        if let Some(ref mut child) = self.child {
251            let pid = child.id();
252            if let Err(e) = child.kill() {
253                tracing::warn!(pid, error = %e, "Failed to kill passt process");
254            } else {
255                // Reap the child to avoid zombies
256                let _ = child.wait();
257                tracing::info!(pid, "Passt daemon stopped");
258            }
259        }
260        self.child = None;
261
262        // Clean up socket and PID file
263        std::fs::remove_file(&self.socket_path).ok();
264        std::fs::remove_file(&self.pcap_path).ok();
265        std::fs::remove_file(&self.pid_file).ok();
266    }
267
268    /// Check if the passt process is still running.
269    pub fn is_running(&mut self) -> bool {
270        match self.child {
271            Some(ref mut child) => child.try_wait().ok().flatten().is_none(),
272            None => false,
273        }
274    }
275}
276
277impl Drop for PasstManager {
278    fn drop(&mut self) {
279        // Intentionally does NOT kill passt. passt must outlive the process that
280        // spawned it: a detached `run -d` returns while the box keeps running,
281        // and the VM (driven by the shim) outlives the CLI. Killing on drop here
282        // is exactly what previously left detached bridge boxes with dead
283        // networking. passt is reaped on box stop/rm via `terminate_passt`, which
284        // uses the PID file as the source of truth.
285    }
286}
287
288/// Terminate a passt daemon by its PID file and remove its socket/PID files.
289///
290/// passt outlives the `PasstManager` that launched it (so detached boxes keep
291/// working after the CLI exits), so box teardown cannot rely on a live handle —
292/// the PID file written into the box's runtime socket directory is authoritative.
293pub fn terminate_passt(socket_dir: &Path) {
294    let pid_file = socket_dir.join("passt.pid");
295    if let Ok(contents) = std::fs::read_to_string(&pid_file) {
296        if let Ok(pid) = contents.trim().parse::<i32>() {
297            // Verify the pid is still passt before signalling: the pid file is a
298            // stale snapshot, so if passt already exited and the kernel recycled
299            // its pid, a bare kill would SIGTERM an unrelated process.
300            if pid > 1 && pid_is_passt(pid) {
301                // SIGTERM; passt exits and is reaped by its (re)parent.
302                #[cfg(unix)]
303                unsafe {
304                    libc::kill(pid, libc::SIGTERM);
305                }
306                tracing::info!(pid, "Terminated passt daemon");
307            }
308        }
309    }
310    let _ = std::fs::remove_file(&pid_file);
311    let _ = std::fs::remove_file(socket_dir.join("passt.sock"));
312    let _ = std::fs::remove_file(socket_dir.join("passt.pcap"));
313}
314
315/// Best-effort check that `pid` is actually a passt process, to avoid SIGTERM-ing
316/// an unrelated process that recycled the pid after passt exited. Reads
317/// `/proc/<pid>/comm`; on any error (pid gone, no `/proc`) returns false so the
318/// stale pid is left alone — a genuinely-dead passt was already reaped by its
319/// reparent.
320#[cfg(target_os = "linux")]
321fn pid_is_passt(pid: i32) -> bool {
322    std::fs::read_to_string(format!("/proc/{pid}/comm"))
323        .map(|comm| comm.trim() == "passt")
324        .unwrap_or(false)
325}
326
327#[cfg(not(target_os = "linux"))]
328fn pid_is_passt(_pid: i32) -> bool {
329    // No procfs to consult; passt is Linux-only, so this path is unreachable in
330    // practice — preserve the prior kill-by-pid-file behavior.
331    true
332}
333
334impl super::NetworkBackend for PasstManager {
335    fn socket_path(&self) -> &std::path::Path {
336        self.socket_path()
337    }
338
339    fn stop(&mut self) {
340        self.stop();
341    }
342}
343
344/// Convert a prefix length to a dotted-decimal netmask string.
345fn prefix_to_netmask(prefix: u8) -> Ipv4Addr {
346    if prefix == 0 {
347        return Ipv4Addr::new(0, 0, 0, 0);
348    }
349    let mask = !((1u32 << (32 - prefix)) - 1);
350    Ipv4Addr::from(mask)
351}
352
353#[cfg(test)]
354mod tests {
355    use super::*;
356
357    #[test]
358    fn test_prefix_to_netmask() {
359        assert_eq!(prefix_to_netmask(24), Ipv4Addr::new(255, 255, 255, 0));
360        assert_eq!(prefix_to_netmask(16), Ipv4Addr::new(255, 255, 0, 0));
361        assert_eq!(prefix_to_netmask(8), Ipv4Addr::new(255, 0, 0, 0));
362        assert_eq!(prefix_to_netmask(32), Ipv4Addr::new(255, 255, 255, 255));
363        assert_eq!(prefix_to_netmask(0), Ipv4Addr::new(0, 0, 0, 0));
364        assert_eq!(prefix_to_netmask(28), Ipv4Addr::new(255, 255, 255, 240));
365    }
366
367    #[test]
368    fn test_passt_manager_new() {
369        let dir = tempfile::tempdir().unwrap();
370        let mgr = PasstManager::new(dir.path());
371        assert_eq!(mgr.socket_path(), dir.path().join("passt.sock"));
372        assert_eq!(mgr.pcap_path(), dir.path().join("passt.pcap"));
373    }
374
375    #[test]
376    fn test_passt_manager_not_running_initially() {
377        let dir = tempfile::tempdir().unwrap();
378        let mut mgr = PasstManager::new(dir.path());
379        assert!(!mgr.is_running());
380    }
381
382    #[test]
383    fn test_passt_manager_stop_when_not_started() {
384        let dir = tempfile::tempdir().unwrap();
385        let mut mgr = PasstManager::new(dir.path());
386        // Should not panic
387        mgr.stop();
388        assert!(!mgr.is_running());
389    }
390
391    #[test]
392    fn test_passt_manager_socket_path() {
393        let dir = tempfile::tempdir().unwrap();
394        let box_dir = dir.path().join("boxes").join("test-box-id");
395        let mgr = PasstManager::new(&box_dir);
396        assert_eq!(mgr.socket_path(), box_dir.join("passt.sock"));
397        assert_eq!(mgr.pcap_path(), box_dir.join("passt.pcap"));
398    }
399
400    #[test]
401    fn test_terminate_passt_removes_socket_and_pid_files() {
402        let dir = tempfile::tempdir().unwrap();
403        let socket_path = dir.path().join("passt.sock");
404        let pid_path = dir.path().join("passt.pid");
405        let pcap_path = dir.path().join("passt.pcap");
406
407        // A non-existent PID so the SIGTERM is a harmless no-op (ESRCH).
408        std::fs::write(&socket_path, "fake").unwrap();
409        std::fs::write(&pid_path, "2147483647").unwrap();
410        std::fs::write(&pcap_path, "fake pcap").unwrap();
411
412        terminate_passt(dir.path());
413
414        assert!(!socket_path.exists());
415        assert!(!pid_path.exists());
416        assert!(!pcap_path.exists());
417    }
418}