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