Skip to main content

vm/
sandbox.rs

1use std::collections::HashMap;
2use std::io::{BufReader, BufWriter, Read, Write};
3use std::net::{Shutdown, TcpListener, TcpStream};
4use std::os::fd::AsRawFd;
5use std::sync::atomic::{AtomicBool, Ordering};
6use std::sync::{Arc, Mutex};
7use std::time::Duration;
8
9use anyhow::{bail, Context, Result};
10use crossbeam_channel::Receiver;
11
12use crate::backend::network::FileHandleNetworkAttachment;
13use crate::backend::terminal;
14use crate::backend::*;
15
16use vm_proto::{
17    frame, ChmodRequest, CopyRequest, DiscardRequest, ExecRequest, ForwardRequest, ForwardResponse,
18    FsOkResponse, MkdirRequest, MountRequest, MountResponse, PortMapping, ReadDirRequest,
19    ReadDirResponse, ReadFileRequest, RemoveRequest, RenameRequest, StatRequest, StatResponse,
20    WatchRequest, WriteFileRequest, WriteFileResponse, VSOCK_PORT, VSOCK_PORT_FORWARD,
21};
22
23// --- Mount types ---
24
25#[derive(Debug, Clone)]
26pub struct MountConfig {
27    pub host_path: String,
28    pub guest_path: String,
29    pub read_only: bool,
30}
31
32// --- VmConfigBuilder ---
33
34pub struct VmConfigBuilder {
35    kernel: Option<String>,
36    rootfs: Option<String>,
37    initrd: Option<String>,
38    cpus: usize,
39    memory_mb: u64,
40    console: bool,
41    verbose: bool,
42    network_fd: Option<i32>,
43    nbd_uri: Option<String>,
44    mounts: Vec<MountConfig>,
45    sync_disk: bool,
46}
47
48impl VmConfigBuilder {
49    pub(crate) fn new() -> Self {
50        VmConfigBuilder {
51            kernel: None,
52            rootfs: None,
53            initrd: None,
54            cpus: 2,
55            memory_mb: 2048,
56            console: true,
57            verbose: false,
58            network_fd: None,
59            nbd_uri: None,
60            mounts: Vec::new(),
61            sync_disk: true,
62        }
63    }
64
65    /// When false, the disk image is attached with no host durability
66    /// (SynchronizationMode::None). Only for throwaway disks that are
67    /// discarded when the VM stops — never for disks read back afterwards
68    /// (checkpoints).
69    pub fn sync_disk(mut self, enabled: bool) -> Self {
70        self.sync_disk = enabled;
71        self
72    }
73
74    /// When false, serial console stdin is disconnected and stdout goes to
75    /// stderr. This prevents the serial console from consuming host stdin
76    /// in exec/shell mode.
77    pub fn console(mut self, enabled: bool) -> Self {
78        self.console = enabled;
79        self
80    }
81
82    /// When true, serial console output (kernel dmesg, initramfs) is shown
83    /// even in non-console mode. Default is false (quiet).
84    pub fn verbose(mut self, enabled: bool) -> Self {
85        self.verbose = enabled;
86        self
87    }
88
89    pub fn kernel(mut self, path: impl Into<String>) -> Self {
90        self.kernel = Some(path.into());
91        self
92    }
93
94    pub fn rootfs(mut self, path: impl Into<String>) -> Self {
95        self.rootfs = Some(path.into());
96        self
97    }
98
99    pub fn initrd(mut self, path: impl Into<String>) -> Self {
100        self.initrd = Some(path.into());
101        self
102    }
103
104    pub fn cpus(mut self, n: usize) -> Self {
105        self.cpus = n;
106        self
107    }
108
109    pub fn memory_mb(mut self, mb: u64) -> Self {
110        self.memory_mb = mb;
111        self
112    }
113
114    /// Attach a network device via a socketpair fd for proxy-based networking.
115    pub fn network_fd(mut self, fd: i32) -> Self {
116        self.network_fd = Some(fd);
117        self
118    }
119
120    /// Use an NBD server for the root disk instead of a direct disk image.
121    pub fn nbd_uri(mut self, uri: impl Into<String>) -> Self {
122        self.nbd_uri = Some(uri.into());
123        self
124    }
125
126    /// Add a host directory mount (virtio-fs).
127    pub fn mount(mut self, config: MountConfig) -> Self {
128        self.mounts.push(config);
129        self
130    }
131
132    pub fn build(self) -> Result<Sandbox> {
133        let kernel_path = self.kernel.context("kernel path is required")?;
134        let rootfs_path = self.rootfs.context("rootfs path is required")?;
135
136        if !VirtualMachine::supported() {
137            bail!("Virtualization is not supported on this machine");
138        }
139
140        let boot_loader = LinuxBootLoader::new_with_kernel(&kernel_path);
141        if let Some(ref initrd) = self.initrd {
142            boot_loader.set_initrd(initrd);
143        }
144
145        // mitigations=off: the guest is disposable and holds no secrets
146        // (the proxy substitutes secret placeholders host-side), so CPU
147        // vulnerability mitigations inside it buy nothing and cost cycles.
148        // A console is only named when something reads it — registering
149        // one costs the kernel a device probe plus the log replay, and a
150        // plain exec run has nowhere to show it (dmesg still works).
151        let base = "root=/dev/vda rw init=/usr/bin/vm-guest mitigations=off printk.time=1";
152        let cmdline = if self.verbose {
153            format!("console={} {}", CONSOLE_DEVICE, base)
154        } else if self.console {
155            format!("console={} {} quiet", CONSOLE_DEVICE, base)
156        } else {
157            format!("{} quiet", base)
158        };
159        boot_loader.set_command_line(&cmdline);
160
161        let memory_bytes = self.memory_mb * 1024 * 1024;
162        let config = VirtualMachineConfiguration::new(&boot_loader, self.cpus, memory_bytes);
163
164        // No console named on the cmdline means no serial port needed:
165        // skipping the device saves its probe during guest boot.
166        if self.console || self.verbose {
167            let serial_attachment = if self.console {
168                FileHandleSerialAttachment::new(
169                    std::io::stdin().as_raw_fd(),
170                    std::io::stdout().as_raw_fd(),
171                )
172            } else {
173                FileHandleSerialAttachment::new_write_only(std::io::stderr().as_raw_fd())
174            };
175            let serial = VirtioConsoleSerialPort::new_with_attachment(&serial_attachment);
176            config.set_serial_ports(&[serial]);
177        }
178
179        let nbd_attachment;
180        let disk_attachment;
181        let block_device = if let Some(ref uri) = self.nbd_uri {
182            nbd_attachment = NbdAttachment::new(uri, 30.0, false)
183                .map_err(|e| anyhow::anyhow!("Failed to create NBD attachment: {}", e))?;
184            VirtioBlockDevice::new(&nbd_attachment)
185        } else {
186            let sync_mode = if self.sync_disk {
187                DiskImageSynchronizationMode::Fsync
188            } else {
189                DiskImageSynchronizationMode::None
190            };
191            disk_attachment = DiskImageAttachment::new_with_options(
192                &rootfs_path,
193                false,
194                DiskImageCachingMode::Cached,
195                sync_mode,
196            )
197            .map_err(|e| anyhow::anyhow!("Failed to create disk attachment: {}", e))?;
198            VirtioBlockDevice::new(&disk_attachment)
199        };
200        config.set_storage_devices(&[&block_device]);
201
202        if let Some(fd) = self.network_fd {
203            let net_attachment = FileHandleNetworkAttachment::new(fd);
204            let net_device = VirtioNetworkDevice::new_with_attachment(&net_attachment);
205            net_device.set_mac_address(&MACAddress::random_local());
206            config.set_network_devices(&[net_device]);
207        }
208
209        // Set up directory sharing devices (virtio-fs) and mount metadata
210        let mut fs_devices: Vec<VirtioFileSystemDevice> = Vec::new();
211        let mut mount_requests: Vec<MountRequest> = Vec::new();
212
213        for (i, m) in self.mounts.iter().enumerate() {
214            let tag = format!("mount{}", i);
215            let shared_dir = SharedDirectory::new(&m.host_path, m.read_only);
216            fs_devices.push(VirtioFileSystemDevice::new(&tag, &shared_dir));
217            mount_requests.push(MountRequest {
218                tag,
219                guest_path: m.guest_path.clone(),
220                read_only: m.read_only,
221            });
222        }
223
224        if !fs_devices.is_empty() {
225            config.set_directory_sharing_devices(&fs_devices);
226        }
227
228        let socket_device = VirtioSocketDevice::new();
229        config.set_socket_devices(&[socket_device]);
230
231        config.set_entropy_devices(&[VirtioEntropyDevice::new()]);
232
233        config
234            .validate()
235            .map_err(|e| anyhow::anyhow!("VM configuration invalid: {}", e))?;
236
237        Ok(Sandbox {
238            vm: Arc::new(VirtualMachine::new(&config)),
239            mounts: Mutex::new(mount_requests),
240        })
241    }
242}
243
244// --- Sandbox ---
245
246pub struct Sandbox {
247    vm: Arc<VirtualMachine>,
248    mounts: Mutex<Vec<MountRequest>>,
249}
250
251impl Sandbox {
252    pub fn builder() -> VmConfigBuilder {
253        VmConfigBuilder::new()
254    }
255
256    pub fn start(&self) -> Result<()> {
257        self.vm
258            .start()
259            .map_err(|e| anyhow::anyhow!("Failed to start VM: {}", e))
260    }
261
262    pub fn stop(&self) -> Result<()> {
263        self.vm
264            .stop()
265            .map_err(|e| anyhow::anyhow!("Failed to stop VM: {}", e))
266    }
267
268    /// Block until the guest control server accepts vsock connections.
269    pub fn wait_ready(&self) -> Result<()> {
270        let stream = self.connect_vsock()?;
271        drop(stream);
272        Ok(())
273    }
274
275    pub fn state_channel(&self) -> Receiver<VmState> {
276        self.vm.state_channel()
277    }
278
279    /// Send pending mount requests over an established vsock connection.
280    /// Drains the mount list so subsequent calls are no-ops.
281    fn send_mount_requests(&self, writer: &mut impl Write, reader: &mut impl Read) -> Result<()> {
282        let mounts = std::mem::take(&mut *self.mounts.lock().unwrap());
283        for req in &mounts {
284            frame::send_json(writer, frame::MOUNT_REQ, &req).context("sending mount request")?;
285            let (_msg_type, payload) = frame::read_frame(reader)
286                .context("reading mount response")?
287                .context("guest closed connection during mount init")?;
288            let resp: MountResponse = match serde_json::from_slice(&payload) {
289                Ok(r) => r,
290                Err(_) => {
291                    bail!(
292                        "guest does not support directory mounts. \
293                         Run `hanzo-vm upgrade` and recreate the checkpoint to enable --mount."
294                    );
295                }
296            };
297            if !resp.ok {
298                bail!(
299                    "mount failed: {} -> {}: {}",
300                    req.tag,
301                    req.guest_path,
302                    resp.error.unwrap_or_else(|| "unknown error".into())
303                );
304            }
305        }
306        Ok(())
307    }
308
309    /// Run a command non-interactively over vsock, streaming output to the
310    /// provided writers. Returns the guest process exit code.
311    pub fn exec(
312        &self,
313        argv: &[impl AsRef<str>],
314        stdout: &mut impl Write,
315        stderr: &mut impl Write,
316    ) -> Result<i32> {
317        self.exec_with_env(argv, &HashMap::new(), stdout, stderr)
318    }
319
320    pub fn exec_with_env(
321        &self,
322        argv: &[impl AsRef<str>],
323        env: &HashMap<String, String>,
324        stdout: &mut impl Write,
325        stderr: &mut impl Write,
326    ) -> Result<i32> {
327        let stream = self.connect_vsock()?;
328        let mut writer = stream.try_clone()?;
329        let mut reader = stream;
330
331        self.send_mount_requests(&mut writer, &mut reader)?;
332
333        let req = ExecRequest {
334            argv: argv.iter().map(|s| s.as_ref().to_string()).collect(),
335            env: env.clone(),
336            tty: None,
337            rows: None,
338            cols: None,
339            cwd: None,
340        };
341        frame::send_json(&mut writer, frame::EXEC_REQ, &req)?;
342
343        let mut exit_code = 0;
344
345        loop {
346            match frame::read_frame(&mut reader).context("reading vsock response")? {
347                Some((frame::STDOUT, payload)) => {
348                    stdout.write_all(&payload)?;
349                }
350                Some((frame::STDERR, payload)) => {
351                    stderr.write_all(&payload)?;
352                }
353                Some((frame::EXIT, payload)) => {
354                    exit_code = frame::parse_exit_code(&payload).unwrap_or(0);
355                    break;
356                }
357                Some((frame::ERROR, payload)) => {
358                    let msg = String::from_utf8_lossy(&payload);
359                    write!(stderr, "guest error: {}", msg)?;
360                    exit_code = 1;
361                    break;
362                }
363                Some(_) => {}  // unknown type, skip
364                None => break, // EOF
365            }
366        }
367
368        Ok(exit_code)
369    }
370
371    pub fn read_file(&self, path: &str) -> Result<Vec<u8>> {
372        let stream = self.connect_vsock()?;
373        let mut writer = stream.try_clone()?;
374        let mut reader = stream;
375
376        self.send_mount_requests(&mut writer, &mut reader)?;
377
378        let req = ReadFileRequest {
379            path: path.to_string(),
380        };
381        frame::send_json(&mut writer, frame::READ_FILE_REQ, &req)?;
382
383        match frame::read_frame(&mut reader).context("reading read_file response")? {
384            Some((frame::READ_FILE_RESP, payload)) => Ok(payload),
385            Some((frame::ERROR, payload)) => {
386                bail!("{}", String::from_utf8_lossy(&payload));
387            }
388            Some((other, _)) => {
389                bail!(
390                    "unexpected frame type 0x{:02x} in read_file response",
391                    other
392                );
393            }
394            None => bail!("guest closed connection during read_file"),
395        }
396    }
397
398    pub fn write_file(&self, path: &str, content: &[u8]) -> Result<()> {
399        let stream = self.connect_vsock()?;
400        let mut writer = stream.try_clone()?;
401        let mut reader = stream;
402
403        self.send_mount_requests(&mut writer, &mut reader)?;
404
405        let req = WriteFileRequest {
406            path: path.to_string(),
407            len: content.len() as u64,
408        };
409        frame::send_json(&mut writer, frame::WRITE_FILE_REQ, &req)?;
410        frame::write_frame(&mut writer, frame::WRITE_FILE_DATA, content)?;
411
412        let (_msg_type, payload) = frame::read_frame(&mut reader)
413            .context("reading write_file response")?
414            .context("guest closed connection during write_file")?;
415
416        let resp: WriteFileResponse =
417            serde_json::from_slice(&payload).context("parsing write_file response")?;
418
419        if !resp.ok {
420            bail!(
421                "write_file failed: {}",
422                resp.error.unwrap_or_else(|| "unknown error".into())
423            );
424        }
425
426        Ok(())
427    }
428
429    /// Send a request and expect FS_OK_RESP or ERROR. Used by void fs ops.
430    fn void_fs_op(&self, req_frame: u8, req: &impl serde::Serialize) -> Result<()> {
431        let stream = self.connect_vsock()?;
432        let mut writer = stream.try_clone()?;
433        let mut reader = stream;
434
435        self.send_mount_requests(&mut writer, &mut reader)?;
436
437        frame::send_json(&mut writer, req_frame, req)?;
438
439        match frame::read_frame(&mut reader).context("reading fs op response")? {
440            Some((frame::FS_OK_RESP, payload)) => {
441                let resp: FsOkResponse =
442                    serde_json::from_slice(&payload).context("parsing fs ok response")?;
443                if !resp.ok {
444                    bail!("{}", resp.error.unwrap_or_else(|| "unknown error".into()));
445                }
446                Ok(())
447            }
448            Some((frame::ERROR, payload)) => {
449                bail!("{}", String::from_utf8_lossy(&payload));
450            }
451            Some((other, _)) => {
452                bail!("unexpected frame type 0x{:02x}", other);
453            }
454            None => bail!("guest closed connection"),
455        }
456    }
457
458    pub fn mkdir(&self, path: &str, recursive: bool) -> Result<()> {
459        self.void_fs_op(
460            frame::MKDIR_REQ,
461            &MkdirRequest {
462                path: path.to_string(),
463                recursive,
464            },
465        )
466    }
467
468    /// Download a URL into the sandbox. Streams progress via the callback.
469    pub fn download(
470        &self,
471        url: &str,
472        path: &str,
473        extract: bool,
474        strip_components: u32,
475        on_progress: impl Fn(vm_proto::DownloadProgress),
476    ) -> Result<()> {
477        let stream = self.connect_vsock()?;
478        let mut writer = stream.try_clone()?;
479        let mut reader = stream;
480
481        self.send_mount_requests(&mut writer, &mut reader)?;
482
483        let req = vm_proto::DownloadRequest {
484            url: url.to_string(),
485            path: path.to_string(),
486            extract,
487            strip_components,
488        };
489        frame::send_json(&mut writer, frame::DOWNLOAD_REQ, &req)?;
490
491        // Read progress frames until FS_OK_RESP or ERROR
492        loop {
493            match frame::read_frame(&mut reader)? {
494                Some((frame::DOWNLOAD_PROGRESS, payload)) => {
495                    if let Ok(progress) =
496                        serde_json::from_slice::<vm_proto::DownloadProgress>(&payload)
497                    {
498                        on_progress(progress);
499                    }
500                }
501                Some((frame::FS_OK_RESP, payload)) => {
502                    let resp: FsOkResponse = serde_json::from_slice(&payload)?;
503                    if !resp.ok {
504                        bail!("{}", resp.error.unwrap_or_else(|| "download failed".into()));
505                    }
506                    return Ok(());
507                }
508                Some((frame::ERROR, payload)) => {
509                    bail!("{}", String::from_utf8_lossy(&payload));
510                }
511                Some((other, _)) => {
512                    bail!("unexpected frame 0x{:02x} during download", other);
513                }
514                None => bail!("connection closed during download"),
515            }
516        }
517    }
518
519    pub fn read_dir(&self, path: &str) -> Result<ReadDirResponse> {
520        let stream = self.connect_vsock()?;
521        let mut writer = stream.try_clone()?;
522        let mut reader = stream;
523
524        self.send_mount_requests(&mut writer, &mut reader)?;
525
526        let req = ReadDirRequest {
527            path: path.to_string(),
528        };
529        frame::send_json(&mut writer, frame::READ_DIR_REQ, &req)?;
530
531        match frame::read_frame(&mut reader).context("reading read_dir response")? {
532            Some((frame::READ_DIR_RESP, payload)) => {
533                Ok(serde_json::from_slice(&payload).context("parsing read_dir response")?)
534            }
535            Some((frame::ERROR, payload)) => {
536                bail!("{}", String::from_utf8_lossy(&payload));
537            }
538            Some((other, _)) => {
539                bail!("unexpected frame type 0x{:02x} in read_dir response", other);
540            }
541            None => bail!("guest closed connection during read_dir"),
542        }
543    }
544
545    pub fn stat(&self, path: &str) -> Result<StatResponse> {
546        let stream = self.connect_vsock()?;
547        let mut writer = stream.try_clone()?;
548        let mut reader = stream;
549
550        self.send_mount_requests(&mut writer, &mut reader)?;
551
552        let req = StatRequest {
553            path: path.to_string(),
554        };
555        frame::send_json(&mut writer, frame::STAT_REQ, &req)?;
556
557        match frame::read_frame(&mut reader).context("reading stat response")? {
558            Some((frame::STAT_RESP, payload)) => {
559                Ok(serde_json::from_slice(&payload).context("parsing stat response")?)
560            }
561            Some((frame::ERROR, payload)) => {
562                bail!("{}", String::from_utf8_lossy(&payload));
563            }
564            Some((other, _)) => {
565                bail!("unexpected frame type 0x{:02x} in stat response", other);
566            }
567            None => bail!("guest closed connection during stat"),
568        }
569    }
570
571    pub fn remove(&self, path: &str, recursive: bool) -> Result<()> {
572        self.void_fs_op(
573            frame::REMOVE_REQ,
574            &RemoveRequest {
575                path: path.to_string(),
576                recursive,
577            },
578        )
579    }
580
581    /// Discard overlay changes for a file: removes it from the upper dir,
582    /// revealing the original host version from the lower layer.
583    pub fn discard_overlay(&self, path: &str) -> Result<()> {
584        self.void_fs_op(
585            frame::DISCARD_REQ,
586            &DiscardRequest {
587                path: path.to_string(),
588            },
589        )
590    }
591
592    pub fn rename(&self, old_path: &str, new_path: &str) -> Result<()> {
593        self.void_fs_op(
594            frame::RENAME_REQ,
595            &RenameRequest {
596                old_path: old_path.to_string(),
597                new_path: new_path.to_string(),
598            },
599        )
600    }
601
602    pub fn copy(&self, src: &str, dst: &str, recursive: bool) -> Result<()> {
603        self.void_fs_op(
604            frame::COPY_REQ,
605            &CopyRequest {
606                src: src.to_string(),
607                dst: dst.to_string(),
608                recursive,
609            },
610        )
611    }
612
613    pub fn chmod(&self, path: &str, mode: u32) -> Result<()> {
614        self.void_fs_op(
615            frame::CHMOD_REQ,
616            &ChmodRequest {
617                path: path.to_string(),
618                mode,
619            },
620        )
621    }
622
623    /// Open a vsock connection for streaming exec. Returns the raw stream
624    /// after sending mounts + ExecRequest. Caller manages I/O (reads
625    /// STDOUT/STDERR/EXIT frames, writes STDIN/KILL frames).
626    pub fn open_exec(
627        &self,
628        argv: &[impl AsRef<str>],
629        env: &HashMap<String, String>,
630        cwd: Option<&str>,
631    ) -> Result<TcpStream> {
632        let stream = self.connect_vsock()?;
633        let mut writer = stream.try_clone()?;
634        let mut reader = stream.try_clone()?;
635
636        self.send_mount_requests(&mut writer, &mut reader)?;
637
638        let req = ExecRequest {
639            argv: argv.iter().map(|s| s.as_ref().to_string()).collect(),
640            env: env.clone(),
641            tty: None,
642            rows: None,
643            cols: None,
644            cwd: cwd.map(|s| s.to_string()),
645        };
646        frame::send_json(&mut writer, frame::EXEC_REQ, &req)?;
647
648        Ok(stream)
649    }
650
651    /// Open a vsock connection for an interactive shell with PTY support.
652    /// Like `open_exec` but with `tty=true`. Returns the raw stream after
653    /// sending mounts + ExecRequest. Caller manages I/O using the binary
654    /// frame protocol (STDIN/STDOUT/RESIZE/EXIT frames).
655    pub fn open_shell(
656        &self,
657        argv: &[impl AsRef<str>],
658        env: &HashMap<String, String>,
659        rows: u16,
660        cols: u16,
661    ) -> Result<TcpStream> {
662        self.open_shell_with_cwd(argv, env, rows, cols, None)
663    }
664
665    pub fn open_shell_with_cwd(
666        &self,
667        argv: &[impl AsRef<str>],
668        env: &HashMap<String, String>,
669        rows: u16,
670        cols: u16,
671        cwd: Option<&str>,
672    ) -> Result<TcpStream> {
673        let stream = self.connect_vsock()?;
674        let mut writer = stream.try_clone()?;
675        let mut reader = stream.try_clone()?;
676
677        self.send_mount_requests(&mut writer, &mut reader)?;
678
679        let req = ExecRequest {
680            argv: argv.iter().map(|s| s.as_ref().to_string()).collect(),
681            env: env.clone(),
682            tty: Some(true),
683            rows: Some(rows),
684            cols: Some(cols),
685            cwd: cwd.map(|s| s.to_string()),
686        };
687        frame::send_json(&mut writer, frame::EXEC_REQ, &req)?;
688
689        Ok(stream)
690    }
691
692    /// Open a vsock connection for file watching. Returns a stream that
693    /// emits WATCH_EVENT frames until the connection is closed.
694    pub fn open_watch(&self, path: &str, recursive: bool) -> Result<TcpStream> {
695        let stream = self.connect_vsock()?;
696        let mut writer = stream.try_clone()?;
697        let mut reader = stream.try_clone()?;
698
699        self.send_mount_requests(&mut writer, &mut reader)?;
700
701        let req = WatchRequest {
702            path: path.to_string(),
703            recursive,
704        };
705        frame::send_json(&mut writer, frame::WATCH_REQ, &req)?;
706
707        Ok(stream)
708    }
709
710    /// Run an interactive shell session with PTY support.
711    /// Puts the host terminal in raw mode, relays I/O bidirectionally over
712    /// vsock, and handles SIGWINCH for window resize.
713    /// Returns the guest process exit code.
714    pub fn shell(&self, argv: &[impl AsRef<str>], env: &HashMap<String, String>) -> Result<i32> {
715        let stdin_fd = std::io::stdin().as_raw_fd();
716        let (rows, cols) = terminal::terminal_size(stdin_fd);
717
718        let stream = self.connect_vsock()?;
719        let mut writer = stream.try_clone()?;
720        let mut reader = stream;
721
722        // Mount phase (sync, before raw mode)
723        self.send_mount_requests(&mut writer, &mut reader)?;
724
725        // Send ExecRequest with tty=true
726        let req = ExecRequest {
727            argv: argv.iter().map(|s| s.as_ref().to_string()).collect(),
728            env: env.clone(),
729            tty: Some(true),
730            rows: Some(rows),
731            cols: Some(cols),
732            cwd: None,
733        };
734        frame::send_json(&mut writer, frame::EXEC_REQ, &req)?;
735
736        // Enter raw mode - TerminalState restores on drop
737        let _raw_guard = terminal::TerminalState::enter_raw_mode(stdin_fd);
738
739        // Set up kqueue-based stdin relay (zero-latency I/O multiplexing)
740        let (relay, shutdown_signal) =
741            terminal::StdinRelay::new(stdin_fd).expect("failed to init stdin relay");
742
743        let exit_code = Arc::new(Mutex::new(0i32));
744
745        // Thread A: stdin → vsock (kqueue blocks until data/resize/shutdown)
746        let mut vsock_writer = writer.try_clone()?;
747        let stdin_thread = std::thread::spawn(move || {
748            let mut buf = [0u8; 4096];
749            loop {
750                match relay.wait() {
751                    terminal::StdinEvent::Ready => {
752                        let n = terminal::read_raw(stdin_fd, &mut buf);
753                        if n == 0 {
754                            break;
755                        }
756                        if frame::write_frame(&mut vsock_writer, frame::STDIN, &buf[..n]).is_err() {
757                            break;
758                        }
759                    }
760                    terminal::StdinEvent::Resize => {
761                        let (rows, cols) = terminal::terminal_size(stdin_fd);
762                        let payload = frame::resize_payload(rows, cols);
763                        if frame::write_frame(&mut vsock_writer, frame::RESIZE, &payload).is_err() {
764                            break;
765                        }
766                    }
767                    terminal::StdinEvent::Shutdown => break,
768                }
769            }
770        });
771
772        // Thread B: vsock -> stdout (read binary frames, write raw output)
773        // Uses BufWriter + deferred flush to batch rapid TUI updates into
774        // fewer terminal writes, preventing visible tearing/flickering.
775        let exit_code_b = exit_code.clone();
776        let vsock_thread = std::thread::spawn(move || {
777            let mut reader = BufReader::new(reader);
778            let mut stdout = BufWriter::new(std::io::stdout());
779            loop {
780                match frame::read_frame(&mut reader) {
781                    Ok(Some((frame::STDOUT, payload))) => {
782                        let _ = stdout.write_all(&payload);
783                        // Only flush to the terminal when no more data is
784                        // already buffered from the vsock. This batches
785                        // rapid sequential messages (e.g. a full TUI
786                        // screen redraw) into a single terminal write.
787                        if reader.buffer().is_empty() {
788                            let _ = stdout.flush();
789                        }
790                    }
791                    Ok(Some((frame::EXIT, payload))) => {
792                        let _ = stdout.flush();
793                        *exit_code_b.lock().unwrap() =
794                            frame::parse_exit_code(&payload).unwrap_or(0);
795                        break;
796                    }
797                    Ok(Some((frame::ERROR, payload))) => {
798                        let _ = stdout.flush();
799                        let msg = String::from_utf8_lossy(&payload);
800                        let _ = std::io::stderr()
801                            .write_all(format!("guest error: {}\r\n", msg).as_bytes());
802                        *exit_code_b.lock().unwrap() = 1;
803                        break;
804                    }
805                    Ok(Some(_)) => {} // unknown type, skip
806                    Ok(None) | Err(_) => break,
807                }
808            }
809            let _ = stdout.flush();
810            shutdown_signal.signal();
811        });
812
813        // Wait for threads
814        let _ = vsock_thread.join();
815        let _ = stdin_thread.join();
816
817        // Terminal restored by _raw_guard drop
818        // SIGWINCH restored by StdinRelay drop
819        let code = *exit_code.lock().unwrap();
820        Ok(code)
821    }
822
823    /// Start port forwarding proxies. Returns a handle that stops all
824    /// listeners when dropped.
825    pub fn start_port_forwarding(&self, forwards: &[PortMapping]) -> Result<PortForwardHandle> {
826        let stop = Arc::new(AtomicBool::new(false));
827        let mut listeners = Vec::new();
828
829        for mapping in forwards {
830            let addr: std::net::SocketAddr = format!("127.0.0.1:{}", mapping.host_port)
831                .parse()
832                .with_context(|| format!("Invalid port {}", mapping.host_port))?;
833            let socket = socket2::Socket::new(socket2::Domain::IPV4, socket2::Type::STREAM, None)
834                .with_context(|| {
835                format!("Failed to create socket for port {}", mapping.host_port)
836            })?;
837            socket.set_reuse_address(true)?;
838            socket
839                .bind(&addr.into())
840                .with_context(|| format!("Failed to bind port {}", mapping.host_port))?;
841            socket
842                .listen(128)
843                .with_context(|| format!("Failed to listen on port {}", mapping.host_port))?;
844            socket.set_nonblocking(true)?;
845            let tcp_listener: TcpListener = socket.into();
846
847            let guest_port = mapping.guest_port;
848            let vm = Arc::clone(&self.vm);
849            let stop_flag = stop.clone();
850
851            eprintln!(
852                "hanzo-vm: forwarding 127.0.0.1:{} -> guest:{}",
853                mapping.host_port, mapping.guest_port
854            );
855
856            let handle = std::thread::spawn(move || {
857                while !stop_flag.load(Ordering::Relaxed) {
858                    match tcp_listener.accept() {
859                        Ok((tcp_stream, _)) => {
860                            // macOS accept() inherits non-blocking from the
861                            // listener — force blocking for the relay.
862                            let _ = tcp_stream.set_nonblocking(false);
863                            let vm = Arc::clone(&vm);
864                            std::thread::spawn(move || {
865                                if let Err(e) =
866                                    handle_forward_connection(tcp_stream, &vm, guest_port)
867                                {
868                                    tracing::debug!("port forward error: {}", e);
869                                }
870                            });
871                        }
872                        Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => {
873                            std::thread::sleep(Duration::from_millis(50));
874                        }
875                        Err(e) => {
876                            if !stop_flag.load(Ordering::Relaxed) {
877                                tracing::debug!("accept error on port forward listener: {}", e);
878                            }
879                            break;
880                        }
881                    }
882                }
883            });
884
885            listeners.push(handle);
886        }
887
888        Ok(PortForwardHandle {
889            stop,
890            threads: listeners,
891        })
892    }
893
894    /// Open a raw bidirectional stream to a TCP port listening inside the guest.
895    ///
896    /// Unlike [`start_port_forwarding`](Self::start_port_forwarding), this does
897    /// not bind a host listener. It completes the vsock forward handshake and
898    /// hands back the connected stream directly, so callers can bridge a guest
899    /// service to an arbitrary transport (e.g. a tunnel) without a local port.
900    /// The returned stream talks to `127.0.0.1:guest_port` inside the guest and
901    /// works whether or not networking (`--allow-net`) is enabled.
902    pub fn connect_forward(&self, guest_port: u16) -> Result<TcpStream> {
903        open_forward_stream(&self.vm, guest_port)
904    }
905
906    fn connect_vsock(&self) -> Result<TcpStream> {
907        let state_rx = self.vm.state_channel();
908        for attempt in 1..=1000 {
909            // Check if VM died (e.g. guest mount failure -> reboot POWER_OFF)
910            if let Ok(state) = state_rx.try_recv() {
911                match state {
912                    VmState::Stopped => {
913                        bail!("VM stopped during startup - check boot output above for errors")
914                    }
915                    VmState::Error => bail!("VM encountered an error during startup"),
916                    _ => {}
917                }
918            }
919            match self.vm.connect_to_vsock_port(VSOCK_PORT) {
920                Ok(s) => {
921                    let _ = s.set_nodelay(true);
922                    return Ok(s);
923                }
924                Err(e) => {
925                    if attempt == 1000 {
926                        bail!(
927                            "Failed to connect to guest after {} attempts: {}",
928                            attempt,
929                            e
930                        );
931                    }
932                    tracing::debug!("vsock connect attempt {} failed: {}", attempt, e);
933                    // Fine-grained polling while the guest is expected any
934                    // millisecond now; back off once it is clearly slow.
935                    let interval = if attempt < 100 { 1 } else { 10 };
936                    std::thread::sleep(Duration::from_millis(interval));
937                }
938            }
939        }
940        unreachable!()
941    }
942}
943
944// --- Port forwarding ---
945
946/// Handle returned by `start_port_forwarding`. Signals all listener threads
947/// to stop and joins them when dropped.
948pub struct PortForwardHandle {
949    stop: Arc<AtomicBool>,
950    threads: Vec<std::thread::JoinHandle<()>>,
951}
952
953impl Drop for PortForwardHandle {
954    fn drop(&mut self) {
955        self.stop.store(true, Ordering::Relaxed);
956        for thread in self.threads.drain(..) {
957            let _ = thread.join();
958        }
959    }
960}
961
962fn handle_forward_connection(
963    tcp_stream: TcpStream,
964    vm: &VirtualMachine,
965    guest_port: u16,
966) -> Result<()> {
967    let vsock_stream = open_forward_stream(vm, guest_port)?;
968    // Bidirectional relay between TCP and vsock
969    relay(tcp_stream, vsock_stream);
970    Ok(())
971}
972
973/// Open a vsock forward channel to a TCP port inside the guest and complete
974/// the forward handshake, returning the connected stream. The stream is a raw
975/// bidirectional pipe to `127.0.0.1:guest_port` inside the guest.
976fn open_forward_stream(vm: &VirtualMachine, guest_port: u16) -> Result<TcpStream> {
977    let mut vsock_stream = vm
978        .connect_to_vsock_port(VSOCK_PORT_FORWARD)
979        .map_err(|e| anyhow::anyhow!("vsock connect for port forward: {}", e))?;
980    let _ = vsock_stream.set_nodelay(true);
981
982    // Send forward request
983    let req = ForwardRequest { port: guest_port };
984    frame::send_json(&mut vsock_stream, frame::FWD_REQ, &req)?;
985
986    // Read response frame
987    let (_msg_type, payload) = frame::read_frame(&mut vsock_stream)
988        .context("reading forward response")?
989        .context("guest closed connection during forward handshake")?;
990    let resp: ForwardResponse =
991        serde_json::from_slice(&payload).context("parsing forward response")?;
992
993    if resp.status != "ok" {
994        bail!(
995            "guest refused forward: {}",
996            resp.message.unwrap_or_default()
997        );
998    }
999
1000    Ok(vsock_stream)
1001}
1002
1003fn relay(a: TcpStream, b: TcpStream) {
1004    let mut a_read = a.try_clone().expect("clone tcp stream");
1005    let mut b_write = b.try_clone().expect("clone vsock stream");
1006    let mut b_read = b;
1007    let mut a_write = a;
1008
1009    let t1 = std::thread::spawn(move || {
1010        let _ = std::io::copy(&mut a_read, &mut b_write);
1011        let _ = b_write.shutdown(Shutdown::Write);
1012    });
1013    let t2 = std::thread::spawn(move || {
1014        let _ = std::io::copy(&mut b_read, &mut a_write);
1015        let _ = a_write.shutdown(Shutdown::Write);
1016    });
1017    let _ = t1.join();
1018    let _ = t2.join();
1019}