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    /// Child process handle (None if not started).
18    child: Option<Child>,
19    /// PID file path for the passt process.
20    pid_file: PathBuf,
21}
22
23impl PasstManager {
24    /// Create a new PasstManager.
25    ///
26    /// The socket and PID file are placed directly in the provided runtime
27    /// socket directory (the same directory that holds the exec/PTY control
28    /// sockets, e.g. `/tmp/a3s-box-sockets/<box_id>`).
29    ///
30    /// This directory MUST be reachable by the user passt runs as. When passt
31    /// is started as root it drops privileges to `nobody`, so the socket
32    /// directory has to be world-traversable — the box's `~/.a3s/boxes/<id>`
33    /// home is mode 0700 for root and would leave passt unable to bind its
34    /// socket, silently breaking all bridge networking and Compose.
35    pub fn new(socket_dir: &Path) -> Self {
36        Self {
37            socket_path: socket_dir.join("passt.sock"),
38            pid_file: socket_dir.join("passt.pid"),
39            child: None,
40        }
41    }
42
43    /// Get the passt socket path.
44    pub fn socket_path(&self) -> &Path {
45        &self.socket_path
46    }
47
48    /// Spawn the passt daemon.
49    ///
50    /// Configures passt with:
51    /// - Unix socket mode (no PID namespace)
52    /// - The assigned IP, gateway, prefix length
53    /// - DNS forwarding
54    /// - No DHCP (static IP assignment)
55    /// - Inbound TCP port forwarding for any published ports (`port_map`)
56    pub fn spawn(
57        &mut self,
58        ip: Ipv4Addr,
59        gateway: Ipv4Addr,
60        prefix_len: u8,
61        dns_servers: &[Ipv4Addr],
62        port_map: &[String],
63    ) -> Result<()> {
64        // Ensure parent directory exists.
65        if let Some(parent) = self.socket_path.parent() {
66            std::fs::create_dir_all(parent).map_err(|e| {
67                BoxError::NetworkError(format!(
68                    "failed to create socket directory {}: {}",
69                    parent.display(),
70                    e
71                ))
72            })?;
73
74            // passt drops privileges to `nobody` when launched as root, so the
75            // directory it binds its socket (and writes its PID file) in must be
76            // writable by that user. Widen the directory permissions; the path
77            // is an ephemeral, per-box runtime directory under a world-traversable
78            // base, so this only affects this box's control sockets.
79            #[cfg(unix)]
80            {
81                use std::os::unix::fs::PermissionsExt;
82                if let Err(e) =
83                    std::fs::set_permissions(parent, std::fs::Permissions::from_mode(0o777))
84                {
85                    tracing::warn!(
86                        dir = %parent.display(),
87                        error = %e,
88                        "Failed to widen passt socket directory permissions; \
89                         passt may be unable to bind its socket after dropping privileges"
90                    );
91                }
92            }
93        }
94
95        // Remove stale socket if it exists
96        if self.socket_path.exists() {
97            std::fs::remove_file(&self.socket_path).ok();
98        }
99
100        let mut cmd = Command::new("passt");
101        cmd.arg("--socket")
102            .arg(&self.socket_path)
103            .arg("--pid")
104            .arg(&self.pid_file)
105            // Run in foreground (we manage the process)
106            .arg("--foreground")
107            // Configure the network
108            .arg("--address")
109            .arg(ip.to_string())
110            .arg("--gateway")
111            .arg(gateway.to_string())
112            .arg("--netmask")
113            .arg(format!("{}", prefix_to_netmask(prefix_len)));
114
115        // Add DNS servers
116        for dns in dns_servers {
117            cmd.arg("--dns").arg(dns.to_string());
118        }
119
120        // Forward published TCP ports into the guest. libkrun discards the
121        // TSI host_port_map once a virtio-net device is attached, so passt is
122        // what actually publishes `-p host:guest` in bridge mode. Auto-assigned
123        // host ports (host_port == 0) cannot be forwarded by passt and are
124        // skipped. passt accepts a comma-separated `host:guest,...` spec.
125        let tcp_specs: Vec<String> = port_map
126            .iter()
127            .filter_map(|m| a3s_box_core::parse_port_mapping(m).ok())
128            .filter(|m| m.host_port != 0)
129            .map(|m| format!("{}:{}", m.host_port, m.guest_port))
130            .collect();
131        if !tcp_specs.is_empty() {
132            let spec = tcp_specs.join(",");
133            tracing::info!(tcp_ports = %spec, "Configuring passt inbound TCP port forwarding");
134            cmd.arg("--tcp-ports").arg(spec);
135        }
136
137        // Capture passt's stderr to a log file so spawn failures (bad args,
138        // unsupported flags, permission errors after dropping privileges) are
139        // diagnosable instead of silently discarded to /dev/null.
140        cmd.stdout(std::process::Stdio::null());
141        match self
142            .socket_path
143            .parent()
144            .map(|p| p.join("passt.stderr.log"))
145            .and_then(|p| std::fs::File::create(p).ok())
146        {
147            Some(file) => {
148                cmd.stderr(std::process::Stdio::from(file));
149            }
150            None => {
151                cmd.stderr(std::process::Stdio::null());
152            }
153        }
154
155        let child = cmd.spawn().map_err(|e| {
156            BoxError::NetworkError(format!(
157                "failed to spawn passt: {} (is passt installed?)",
158                e
159            ))
160        })?;
161
162        tracing::info!(
163            pid = child.id(),
164            socket = %self.socket_path.display(),
165            ip = %ip,
166            gateway = %gateway,
167            "Passt daemon started"
168        );
169
170        self.child = Some(child);
171
172        // Wait briefly for the socket to appear
173        self.wait_for_socket()?;
174
175        Ok(())
176    }
177
178    /// Wait for the passt socket to become available.
179    ///
180    /// Also detects immediate passt exit (e.g. bad args or a permission failure
181    /// after dropping privileges) so the real cause is surfaced instead of a
182    /// misleading 5-second timeout.
183    fn wait_for_socket(&mut self) -> Result<()> {
184        let stderr_path = self
185            .socket_path
186            .parent()
187            .map(|p| p.join("passt.stderr.log"));
188        let read_stderr = |path: &Option<PathBuf>| -> String {
189            path.as_ref()
190                .and_then(|p| std::fs::read_to_string(p).ok())
191                .map(|s| {
192                    let mut tail: Vec<&str> = s.lines().rev().take(4).collect();
193                    tail.reverse();
194                    tail.join("; ")
195                })
196                .filter(|s| !s.trim().is_empty())
197                .map(|s| format!(" (passt stderr: {s})"))
198                .unwrap_or_default()
199        };
200
201        let max_attempts = 50; // 5 seconds total
202        for _ in 0..max_attempts {
203            if self.socket_path.exists() {
204                return Ok(());
205            }
206            if let Some(child) = self.child.as_mut() {
207                if let Ok(Some(status)) = child.try_wait() {
208                    // Early exit — try_wait reaped it, so nothing lingers.
209                    return Err(BoxError::NetworkError(format!(
210                        "passt exited early with {status} before creating its socket{}",
211                        read_stderr(&stderr_path)
212                    )));
213                }
214            }
215            std::thread::sleep(std::time::Duration::from_millis(100));
216        }
217
218        // Timed out with passt still alive: kill the child we spawned so it does
219        // not linger holding the published port. `Drop` deliberately leaves a
220        // healthy passt running (detached use), and the pid-file fallback used by
221        // boot-failure cleanup can miss it if passt hasn't written the file yet —
222        // so reap it here directly via the handle we hold.
223        if let Some(mut child) = self.child.take() {
224            let _ = child.kill();
225            let _ = child.wait();
226        }
227
228        Err(BoxError::NetworkError(format!(
229            "passt socket {} did not appear within 5 seconds{}",
230            self.socket_path.display(),
231            read_stderr(&stderr_path)
232        )))
233    }
234
235    /// Stop the passt daemon.
236    pub fn stop(&mut self) {
237        if let Some(ref mut child) = self.child {
238            let pid = child.id();
239            if let Err(e) = child.kill() {
240                tracing::warn!(pid, error = %e, "Failed to kill passt process");
241            } else {
242                // Reap the child to avoid zombies
243                let _ = child.wait();
244                tracing::info!(pid, "Passt daemon stopped");
245            }
246        }
247        self.child = None;
248
249        // Clean up socket and PID file
250        std::fs::remove_file(&self.socket_path).ok();
251        std::fs::remove_file(&self.pid_file).ok();
252    }
253
254    /// Check if the passt process is still running.
255    pub fn is_running(&mut self) -> bool {
256        match self.child {
257            Some(ref mut child) => child.try_wait().ok().flatten().is_none(),
258            None => false,
259        }
260    }
261}
262
263impl Drop for PasstManager {
264    fn drop(&mut self) {
265        // Intentionally does NOT kill passt. passt must outlive the process that
266        // spawned it: a detached `run -d` returns while the box keeps running,
267        // and the VM (driven by the shim) outlives the CLI. Killing on drop here
268        // is exactly what previously left detached bridge boxes with dead
269        // networking. passt is reaped on box stop/rm via `terminate_passt`, which
270        // uses the PID file as the source of truth.
271    }
272}
273
274/// Terminate a passt daemon by its PID file and remove its socket/PID files.
275///
276/// passt outlives the `PasstManager` that launched it (so detached boxes keep
277/// working after the CLI exits), so box teardown cannot rely on a live handle —
278/// the PID file written into the box's runtime socket directory is authoritative.
279pub fn terminate_passt(socket_dir: &Path) {
280    let pid_file = socket_dir.join("passt.pid");
281    if let Ok(contents) = std::fs::read_to_string(&pid_file) {
282        if let Ok(pid) = contents.trim().parse::<i32>() {
283            // Verify the pid is still passt before signalling: the pid file is a
284            // stale snapshot, so if passt already exited and the kernel recycled
285            // its pid, a bare kill would SIGTERM an unrelated process.
286            if pid > 1 && pid_is_passt(pid) {
287                // SIGTERM; passt exits and is reaped by its (re)parent.
288                #[cfg(unix)]
289                unsafe {
290                    libc::kill(pid, libc::SIGTERM);
291                }
292                tracing::info!(pid, "Terminated passt daemon");
293            }
294        }
295    }
296    let _ = std::fs::remove_file(&pid_file);
297    let _ = std::fs::remove_file(socket_dir.join("passt.sock"));
298}
299
300/// Best-effort check that `pid` is actually a passt process, to avoid SIGTERM-ing
301/// an unrelated process that recycled the pid after passt exited. Reads
302/// `/proc/<pid>/comm`; on any error (pid gone, no `/proc`) returns false so the
303/// stale pid is left alone — a genuinely-dead passt was already reaped by its
304/// reparent.
305#[cfg(target_os = "linux")]
306fn pid_is_passt(pid: i32) -> bool {
307    std::fs::read_to_string(format!("/proc/{pid}/comm"))
308        .map(|comm| comm.trim() == "passt")
309        .unwrap_or(false)
310}
311
312#[cfg(not(target_os = "linux"))]
313fn pid_is_passt(_pid: i32) -> bool {
314    // No procfs to consult; passt is Linux-only, so this path is unreachable in
315    // practice — preserve the prior kill-by-pid-file behavior.
316    true
317}
318
319impl super::NetworkBackend for PasstManager {
320    fn socket_path(&self) -> &std::path::Path {
321        self.socket_path()
322    }
323
324    fn stop(&mut self) {
325        self.stop();
326    }
327}
328
329/// Convert a prefix length to a dotted-decimal netmask string.
330fn prefix_to_netmask(prefix: u8) -> Ipv4Addr {
331    if prefix == 0 {
332        return Ipv4Addr::new(0, 0, 0, 0);
333    }
334    let mask = !((1u32 << (32 - prefix)) - 1);
335    Ipv4Addr::from(mask)
336}
337
338#[cfg(test)]
339mod tests {
340    use super::*;
341
342    #[test]
343    fn test_prefix_to_netmask() {
344        assert_eq!(prefix_to_netmask(24), Ipv4Addr::new(255, 255, 255, 0));
345        assert_eq!(prefix_to_netmask(16), Ipv4Addr::new(255, 255, 0, 0));
346        assert_eq!(prefix_to_netmask(8), Ipv4Addr::new(255, 0, 0, 0));
347        assert_eq!(prefix_to_netmask(32), Ipv4Addr::new(255, 255, 255, 255));
348        assert_eq!(prefix_to_netmask(0), Ipv4Addr::new(0, 0, 0, 0));
349        assert_eq!(prefix_to_netmask(28), Ipv4Addr::new(255, 255, 255, 240));
350    }
351
352    #[test]
353    fn test_passt_manager_new() {
354        let dir = tempfile::tempdir().unwrap();
355        let mgr = PasstManager::new(dir.path());
356        assert_eq!(mgr.socket_path(), dir.path().join("passt.sock"));
357    }
358
359    #[test]
360    fn test_passt_manager_not_running_initially() {
361        let dir = tempfile::tempdir().unwrap();
362        let mut mgr = PasstManager::new(dir.path());
363        assert!(!mgr.is_running());
364    }
365
366    #[test]
367    fn test_passt_manager_stop_when_not_started() {
368        let dir = tempfile::tempdir().unwrap();
369        let mut mgr = PasstManager::new(dir.path());
370        // Should not panic
371        mgr.stop();
372        assert!(!mgr.is_running());
373    }
374
375    #[test]
376    fn test_passt_manager_socket_path() {
377        let dir = tempfile::tempdir().unwrap();
378        let box_dir = dir.path().join("boxes").join("test-box-id");
379        let mgr = PasstManager::new(&box_dir);
380        assert_eq!(mgr.socket_path(), box_dir.join("passt.sock"));
381    }
382
383    #[test]
384    fn test_terminate_passt_removes_socket_and_pid_files() {
385        let dir = tempfile::tempdir().unwrap();
386        let socket_path = dir.path().join("passt.sock");
387        let pid_path = dir.path().join("passt.pid");
388
389        // A non-existent PID so the SIGTERM is a harmless no-op (ESRCH).
390        std::fs::write(&socket_path, "fake").unwrap();
391        std::fs::write(&pid_path, "2147483647").unwrap();
392
393        terminate_passt(dir.path());
394
395        assert!(!socket_path.exists());
396        assert!(!pid_path.exists());
397    }
398}