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    /// Block until the guest's vsock server answers.
279    ///
280    /// `start` returns when the VMM is running, which is earlier than the
281    /// guest is reachable — kernel and guest init still have to happen. A
282    /// caller that announces readiness at `start` hands its own caller a race
283    /// it cannot see, and the first request pays the whole wait.
284    pub fn wait_ready(&self) -> Result<()> {
285        self.connect_vsock().map(drop)
286    }
287
288    pub fn state_channel(&self) -> Receiver<VmState> {
289        self.vm.state_channel()
290    }
291
292    /// Send pending mount requests over an established vsock connection.
293    /// Drains the mount list so subsequent calls are no-ops.
294    fn send_mount_requests(&self, writer: &mut impl Write, reader: &mut impl Read) -> Result<()> {
295        let mounts = std::mem::take(&mut *self.mounts.lock().unwrap());
296        for req in &mounts {
297            frame::send_json(writer, frame::MOUNT_REQ, &req).context("sending mount request")?;
298            let (_msg_type, payload) = frame::read_frame(reader)
299                .context("reading mount response")?
300                .context("guest closed connection during mount init")?;
301            let resp: MountResponse = match serde_json::from_slice(&payload) {
302                Ok(r) => r,
303                Err(_) => {
304                    bail!(
305                        "guest does not support directory mounts. \
306                         Run `hanzo-vm upgrade` and recreate the checkpoint to enable --mount."
307                    );
308                }
309            };
310            if !resp.ok {
311                bail!(
312                    "mount failed: {} -> {}: {}",
313                    req.tag,
314                    req.guest_path,
315                    resp.error.unwrap_or_else(|| "unknown error".into())
316                );
317            }
318        }
319        Ok(())
320    }
321
322    /// Run a command non-interactively over vsock, streaming output to the
323    /// provided writers. Returns the guest process exit code.
324    pub fn exec(
325        &self,
326        argv: &[impl AsRef<str>],
327        stdout: &mut impl Write,
328        stderr: &mut impl Write,
329    ) -> Result<i32> {
330        self.exec_with_env(argv, &HashMap::new(), stdout, stderr)
331    }
332
333    pub fn exec_with_env(
334        &self,
335        argv: &[impl AsRef<str>],
336        env: &HashMap<String, String>,
337        stdout: &mut impl Write,
338        stderr: &mut impl Write,
339    ) -> Result<i32> {
340        let stream = self.connect_vsock()?;
341        let mut writer = stream.try_clone()?;
342        let mut reader = stream;
343
344        self.send_mount_requests(&mut writer, &mut reader)?;
345
346        let req = ExecRequest {
347            argv: argv.iter().map(|s| s.as_ref().to_string()).collect(),
348            env: env.clone(),
349            tty: None,
350            rows: None,
351            cols: None,
352            cwd: None,
353        };
354        frame::send_json(&mut writer, frame::EXEC_REQ, &req)?;
355
356        let mut exit_code = 0;
357
358        loop {
359            match frame::read_frame(&mut reader).context("reading vsock response")? {
360                Some((frame::STDOUT, payload)) => {
361                    stdout.write_all(&payload)?;
362                }
363                Some((frame::STDERR, payload)) => {
364                    stderr.write_all(&payload)?;
365                }
366                Some((frame::EXIT, payload)) => {
367                    exit_code = frame::parse_exit_code(&payload).unwrap_or(0);
368                    break;
369                }
370                Some((frame::ERROR, payload)) => {
371                    let msg = String::from_utf8_lossy(&payload);
372                    write!(stderr, "guest error: {}", msg)?;
373                    exit_code = 1;
374                    break;
375                }
376                Some(_) => {}  // unknown type, skip
377                None => break, // EOF
378            }
379        }
380
381        Ok(exit_code)
382    }
383
384    /// Ask the guest's platform for a report over `bind`.
385    ///
386    /// The 64 bytes are what a verifier will check the report's caller field
387    /// against — [`Measurement::bind`](vm_measure::Measurement::bind). A guest
388    /// on ordinary hardware answers `none`, which is an answer: nothing here
389    /// will sign for a measurement.
390    ///
391    /// The read is bounded because a guest that predates this request drops
392    /// the frame in silence, and a caller must not wait out a vm's whole life
393    /// on a question it was never able to hear.
394    pub fn attest(&self, bind: &[u8; 64]) -> Result<vm_measure::attest::Status> {
395        let stream = self.connect_vsock()?;
396        let mut writer = stream.try_clone()?;
397        let mut reader = stream;
398        reader.set_read_timeout(Some(Duration::from_secs(5)))?;
399
400        self.send_mount_requests(&mut writer, &mut reader)?;
401
402        frame::write_frame(&mut writer, frame::ATTEST_REQ, bind)?;
403        match frame::read_frame(&mut reader).context("reading attest response")? {
404            Some((frame::ATTEST_RESP, payload)) => {
405                serde_json::from_slice(&payload).context("reading the guest's attestation status")
406            }
407            Some((frame::ERROR, payload)) => bail!("{}", String::from_utf8_lossy(&payload)),
408            Some((other, _)) => bail!("unexpected frame type 0x{other:02x} in attest response"),
409            None => bail!("guest closed connection during attest"),
410        }
411    }
412
413    pub fn read_file(&self, path: &str) -> Result<Vec<u8>> {
414        let stream = self.connect_vsock()?;
415        let mut writer = stream.try_clone()?;
416        let mut reader = stream;
417
418        self.send_mount_requests(&mut writer, &mut reader)?;
419
420        let req = ReadFileRequest {
421            path: path.to_string(),
422        };
423        frame::send_json(&mut writer, frame::READ_FILE_REQ, &req)?;
424
425        match frame::read_frame(&mut reader).context("reading read_file response")? {
426            Some((frame::READ_FILE_RESP, payload)) => Ok(payload),
427            Some((frame::ERROR, payload)) => {
428                bail!("{}", String::from_utf8_lossy(&payload));
429            }
430            Some((other, _)) => {
431                bail!(
432                    "unexpected frame type 0x{:02x} in read_file response",
433                    other
434                );
435            }
436            None => bail!("guest closed connection during read_file"),
437        }
438    }
439
440    pub fn write_file(&self, path: &str, content: &[u8]) -> Result<()> {
441        let stream = self.connect_vsock()?;
442        let mut writer = stream.try_clone()?;
443        let mut reader = stream;
444
445        self.send_mount_requests(&mut writer, &mut reader)?;
446
447        let req = WriteFileRequest {
448            path: path.to_string(),
449            len: content.len() as u64,
450        };
451        frame::send_json(&mut writer, frame::WRITE_FILE_REQ, &req)?;
452        frame::write_frame(&mut writer, frame::WRITE_FILE_DATA, content)?;
453
454        let (_msg_type, payload) = frame::read_frame(&mut reader)
455            .context("reading write_file response")?
456            .context("guest closed connection during write_file")?;
457
458        let resp: WriteFileResponse =
459            serde_json::from_slice(&payload).context("parsing write_file response")?;
460
461        if !resp.ok {
462            bail!(
463                "write_file failed: {}",
464                resp.error.unwrap_or_else(|| "unknown error".into())
465            );
466        }
467
468        Ok(())
469    }
470
471    /// Send a request and expect FS_OK_RESP or ERROR. Used by void fs ops.
472    fn void_fs_op(&self, req_frame: u8, req: &impl serde::Serialize) -> Result<()> {
473        let stream = self.connect_vsock()?;
474        let mut writer = stream.try_clone()?;
475        let mut reader = stream;
476
477        self.send_mount_requests(&mut writer, &mut reader)?;
478
479        frame::send_json(&mut writer, req_frame, req)?;
480
481        match frame::read_frame(&mut reader).context("reading fs op response")? {
482            Some((frame::FS_OK_RESP, payload)) => {
483                let resp: FsOkResponse =
484                    serde_json::from_slice(&payload).context("parsing fs ok response")?;
485                if !resp.ok {
486                    bail!("{}", resp.error.unwrap_or_else(|| "unknown error".into()));
487                }
488                Ok(())
489            }
490            Some((frame::ERROR, payload)) => {
491                bail!("{}", String::from_utf8_lossy(&payload));
492            }
493            Some((other, _)) => {
494                bail!("unexpected frame type 0x{:02x}", other);
495            }
496            None => bail!("guest closed connection"),
497        }
498    }
499
500    pub fn mkdir(&self, path: &str, recursive: bool) -> Result<()> {
501        self.void_fs_op(
502            frame::MKDIR_REQ,
503            &MkdirRequest {
504                path: path.to_string(),
505                recursive,
506            },
507        )
508    }
509
510    /// Download a URL into the sandbox. Streams progress via the callback.
511    pub fn download(
512        &self,
513        url: &str,
514        path: &str,
515        extract: bool,
516        strip_components: u32,
517        on_progress: impl Fn(vm_proto::DownloadProgress),
518    ) -> Result<()> {
519        let stream = self.connect_vsock()?;
520        let mut writer = stream.try_clone()?;
521        let mut reader = stream;
522
523        self.send_mount_requests(&mut writer, &mut reader)?;
524
525        let req = vm_proto::DownloadRequest {
526            url: url.to_string(),
527            path: path.to_string(),
528            extract,
529            strip_components,
530        };
531        frame::send_json(&mut writer, frame::DOWNLOAD_REQ, &req)?;
532
533        // Read progress frames until FS_OK_RESP or ERROR
534        loop {
535            match frame::read_frame(&mut reader)? {
536                Some((frame::DOWNLOAD_PROGRESS, payload)) => {
537                    if let Ok(progress) =
538                        serde_json::from_slice::<vm_proto::DownloadProgress>(&payload)
539                    {
540                        on_progress(progress);
541                    }
542                }
543                Some((frame::FS_OK_RESP, payload)) => {
544                    let resp: FsOkResponse = serde_json::from_slice(&payload)?;
545                    if !resp.ok {
546                        bail!("{}", resp.error.unwrap_or_else(|| "download failed".into()));
547                    }
548                    return Ok(());
549                }
550                Some((frame::ERROR, payload)) => {
551                    bail!("{}", String::from_utf8_lossy(&payload));
552                }
553                Some((other, _)) => {
554                    bail!("unexpected frame 0x{:02x} during download", other);
555                }
556                None => bail!("connection closed during download"),
557            }
558        }
559    }
560
561    pub fn read_dir(&self, path: &str) -> Result<ReadDirResponse> {
562        let stream = self.connect_vsock()?;
563        let mut writer = stream.try_clone()?;
564        let mut reader = stream;
565
566        self.send_mount_requests(&mut writer, &mut reader)?;
567
568        let req = ReadDirRequest {
569            path: path.to_string(),
570        };
571        frame::send_json(&mut writer, frame::READ_DIR_REQ, &req)?;
572
573        match frame::read_frame(&mut reader).context("reading read_dir response")? {
574            Some((frame::READ_DIR_RESP, payload)) => {
575                Ok(serde_json::from_slice(&payload).context("parsing read_dir response")?)
576            }
577            Some((frame::ERROR, payload)) => {
578                bail!("{}", String::from_utf8_lossy(&payload));
579            }
580            Some((other, _)) => {
581                bail!("unexpected frame type 0x{:02x} in read_dir response", other);
582            }
583            None => bail!("guest closed connection during read_dir"),
584        }
585    }
586
587    pub fn stat(&self, path: &str) -> Result<StatResponse> {
588        let stream = self.connect_vsock()?;
589        let mut writer = stream.try_clone()?;
590        let mut reader = stream;
591
592        self.send_mount_requests(&mut writer, &mut reader)?;
593
594        let req = StatRequest {
595            path: path.to_string(),
596        };
597        frame::send_json(&mut writer, frame::STAT_REQ, &req)?;
598
599        match frame::read_frame(&mut reader).context("reading stat response")? {
600            Some((frame::STAT_RESP, payload)) => {
601                Ok(serde_json::from_slice(&payload).context("parsing stat response")?)
602            }
603            Some((frame::ERROR, payload)) => {
604                bail!("{}", String::from_utf8_lossy(&payload));
605            }
606            Some((other, _)) => {
607                bail!("unexpected frame type 0x{:02x} in stat response", other);
608            }
609            None => bail!("guest closed connection during stat"),
610        }
611    }
612
613    pub fn remove(&self, path: &str, recursive: bool) -> Result<()> {
614        self.void_fs_op(
615            frame::REMOVE_REQ,
616            &RemoveRequest {
617                path: path.to_string(),
618                recursive,
619            },
620        )
621    }
622
623    /// Discard overlay changes for a file: removes it from the upper dir,
624    /// revealing the original host version from the lower layer.
625    pub fn discard_overlay(&self, path: &str) -> Result<()> {
626        self.void_fs_op(
627            frame::DISCARD_REQ,
628            &DiscardRequest {
629                path: path.to_string(),
630            },
631        )
632    }
633
634    pub fn rename(&self, old_path: &str, new_path: &str) -> Result<()> {
635        self.void_fs_op(
636            frame::RENAME_REQ,
637            &RenameRequest {
638                old_path: old_path.to_string(),
639                new_path: new_path.to_string(),
640            },
641        )
642    }
643
644    pub fn copy(&self, src: &str, dst: &str, recursive: bool) -> Result<()> {
645        self.void_fs_op(
646            frame::COPY_REQ,
647            &CopyRequest {
648                src: src.to_string(),
649                dst: dst.to_string(),
650                recursive,
651            },
652        )
653    }
654
655    pub fn chmod(&self, path: &str, mode: u32) -> Result<()> {
656        self.void_fs_op(
657            frame::CHMOD_REQ,
658            &ChmodRequest {
659                path: path.to_string(),
660                mode,
661            },
662        )
663    }
664
665    /// Open a vsock connection for streaming exec. Returns the raw stream
666    /// after sending mounts + ExecRequest. Caller manages I/O (reads
667    /// STDOUT/STDERR/EXIT frames, writes STDIN/KILL frames).
668    pub fn open_exec(
669        &self,
670        argv: &[impl AsRef<str>],
671        env: &HashMap<String, String>,
672        cwd: Option<&str>,
673    ) -> Result<TcpStream> {
674        let stream = self.connect_vsock()?;
675        let mut writer = stream.try_clone()?;
676        let mut reader = stream.try_clone()?;
677
678        self.send_mount_requests(&mut writer, &mut reader)?;
679
680        let req = ExecRequest {
681            argv: argv.iter().map(|s| s.as_ref().to_string()).collect(),
682            env: env.clone(),
683            tty: None,
684            rows: None,
685            cols: None,
686            cwd: cwd.map(|s| s.to_string()),
687        };
688        frame::send_json(&mut writer, frame::EXEC_REQ, &req)?;
689
690        Ok(stream)
691    }
692
693    /// Open a vsock connection for an interactive shell with PTY support.
694    /// Like `open_exec` but with `tty=true`. Returns the raw stream after
695    /// sending mounts + ExecRequest. Caller manages I/O using the binary
696    /// frame protocol (STDIN/STDOUT/RESIZE/EXIT frames).
697    pub fn open_shell(
698        &self,
699        argv: &[impl AsRef<str>],
700        env: &HashMap<String, String>,
701        rows: u16,
702        cols: u16,
703    ) -> Result<TcpStream> {
704        self.open_shell_with_cwd(argv, env, rows, cols, None)
705    }
706
707    pub fn open_shell_with_cwd(
708        &self,
709        argv: &[impl AsRef<str>],
710        env: &HashMap<String, String>,
711        rows: u16,
712        cols: u16,
713        cwd: Option<&str>,
714    ) -> Result<TcpStream> {
715        let stream = self.connect_vsock()?;
716        let mut writer = stream.try_clone()?;
717        let mut reader = stream.try_clone()?;
718
719        self.send_mount_requests(&mut writer, &mut reader)?;
720
721        let req = ExecRequest {
722            argv: argv.iter().map(|s| s.as_ref().to_string()).collect(),
723            env: env.clone(),
724            tty: Some(true),
725            rows: Some(rows),
726            cols: Some(cols),
727            cwd: cwd.map(|s| s.to_string()),
728        };
729        frame::send_json(&mut writer, frame::EXEC_REQ, &req)?;
730
731        Ok(stream)
732    }
733
734    /// Open a vsock connection for file watching. Returns a stream that
735    /// emits WATCH_EVENT frames until the connection is closed.
736    pub fn open_watch(&self, path: &str, recursive: bool) -> Result<TcpStream> {
737        let stream = self.connect_vsock()?;
738        let mut writer = stream.try_clone()?;
739        let mut reader = stream.try_clone()?;
740
741        self.send_mount_requests(&mut writer, &mut reader)?;
742
743        let req = WatchRequest {
744            path: path.to_string(),
745            recursive,
746        };
747        frame::send_json(&mut writer, frame::WATCH_REQ, &req)?;
748
749        Ok(stream)
750    }
751
752    /// Run an interactive shell session with PTY support.
753    /// Puts the host terminal in raw mode, relays I/O bidirectionally over
754    /// vsock, and handles SIGWINCH for window resize.
755    /// Returns the guest process exit code.
756    pub fn shell(&self, argv: &[impl AsRef<str>], env: &HashMap<String, String>) -> Result<i32> {
757        let stdin_fd = std::io::stdin().as_raw_fd();
758        let (rows, cols) = terminal::terminal_size(stdin_fd);
759
760        let stream = self.connect_vsock()?;
761        let mut writer = stream.try_clone()?;
762        let mut reader = stream;
763
764        // Mount phase (sync, before raw mode)
765        self.send_mount_requests(&mut writer, &mut reader)?;
766
767        // Send ExecRequest with tty=true
768        let req = ExecRequest {
769            argv: argv.iter().map(|s| s.as_ref().to_string()).collect(),
770            env: env.clone(),
771            tty: Some(true),
772            rows: Some(rows),
773            cols: Some(cols),
774            cwd: None,
775        };
776        frame::send_json(&mut writer, frame::EXEC_REQ, &req)?;
777
778        // Enter raw mode - TerminalState restores on drop
779        let _raw_guard = terminal::TerminalState::enter_raw_mode(stdin_fd);
780
781        // Set up kqueue-based stdin relay (zero-latency I/O multiplexing)
782        let (relay, shutdown_signal) =
783            terminal::StdinRelay::new(stdin_fd).expect("failed to init stdin relay");
784
785        let exit_code = Arc::new(Mutex::new(0i32));
786
787        // Thread A: stdin → vsock (kqueue blocks until data/resize/shutdown)
788        let mut vsock_writer = writer.try_clone()?;
789        let stdin_thread = std::thread::spawn(move || {
790            let mut buf = [0u8; 4096];
791            loop {
792                match relay.wait() {
793                    terminal::StdinEvent::Ready => {
794                        let n = terminal::read_raw(stdin_fd, &mut buf);
795                        if n == 0 {
796                            break;
797                        }
798                        if frame::write_frame(&mut vsock_writer, frame::STDIN, &buf[..n]).is_err() {
799                            break;
800                        }
801                    }
802                    terminal::StdinEvent::Resize => {
803                        let (rows, cols) = terminal::terminal_size(stdin_fd);
804                        let payload = frame::resize_payload(rows, cols);
805                        if frame::write_frame(&mut vsock_writer, frame::RESIZE, &payload).is_err() {
806                            break;
807                        }
808                    }
809                    terminal::StdinEvent::Shutdown => break,
810                }
811            }
812        });
813
814        // Thread B: vsock -> stdout (read binary frames, write raw output)
815        // Uses BufWriter + deferred flush to batch rapid TUI updates into
816        // fewer terminal writes, preventing visible tearing/flickering.
817        let exit_code_b = exit_code.clone();
818        let vsock_thread = std::thread::spawn(move || {
819            let mut reader = BufReader::new(reader);
820            let mut stdout = BufWriter::new(std::io::stdout());
821            loop {
822                match frame::read_frame(&mut reader) {
823                    Ok(Some((frame::STDOUT, payload))) => {
824                        let _ = stdout.write_all(&payload);
825                        // Only flush to the terminal when no more data is
826                        // already buffered from the vsock. This batches
827                        // rapid sequential messages (e.g. a full TUI
828                        // screen redraw) into a single terminal write.
829                        if reader.buffer().is_empty() {
830                            let _ = stdout.flush();
831                        }
832                    }
833                    Ok(Some((frame::EXIT, payload))) => {
834                        let _ = stdout.flush();
835                        *exit_code_b.lock().unwrap() =
836                            frame::parse_exit_code(&payload).unwrap_or(0);
837                        break;
838                    }
839                    Ok(Some((frame::ERROR, payload))) => {
840                        let _ = stdout.flush();
841                        let msg = String::from_utf8_lossy(&payload);
842                        let _ = std::io::stderr()
843                            .write_all(format!("guest error: {}\r\n", msg).as_bytes());
844                        *exit_code_b.lock().unwrap() = 1;
845                        break;
846                    }
847                    Ok(Some(_)) => {} // unknown type, skip
848                    Ok(None) | Err(_) => break,
849                }
850            }
851            let _ = stdout.flush();
852            shutdown_signal.signal();
853        });
854
855        // Wait for threads
856        let _ = vsock_thread.join();
857        let _ = stdin_thread.join();
858
859        // Terminal restored by _raw_guard drop
860        // SIGWINCH restored by StdinRelay drop
861        let code = *exit_code.lock().unwrap();
862        Ok(code)
863    }
864
865    /// Start port forwarding proxies. Returns a handle that stops all
866    /// listeners when dropped.
867    pub fn start_port_forwarding(&self, forwards: &[PortMapping]) -> Result<PortForwardHandle> {
868        let stop = Arc::new(AtomicBool::new(false));
869        let mut listeners = Vec::new();
870
871        for mapping in forwards {
872            let addr: std::net::SocketAddr = format!("127.0.0.1:{}", mapping.host_port)
873                .parse()
874                .with_context(|| format!("Invalid port {}", mapping.host_port))?;
875            let socket = socket2::Socket::new(socket2::Domain::IPV4, socket2::Type::STREAM, None)
876                .with_context(|| {
877                format!("Failed to create socket for port {}", mapping.host_port)
878            })?;
879            socket.set_reuse_address(true)?;
880            socket
881                .bind(&addr.into())
882                .with_context(|| format!("Failed to bind port {}", mapping.host_port))?;
883            socket
884                .listen(128)
885                .with_context(|| format!("Failed to listen on port {}", mapping.host_port))?;
886            socket.set_nonblocking(true)?;
887            let tcp_listener: TcpListener = socket.into();
888
889            let guest_port = mapping.guest_port;
890            let vm = Arc::clone(&self.vm);
891            let stop_flag = stop.clone();
892
893            eprintln!(
894                "hanzo-vm: forwarding 127.0.0.1:{} -> guest:{}",
895                mapping.host_port, mapping.guest_port
896            );
897
898            let handle = std::thread::spawn(move || {
899                while !stop_flag.load(Ordering::Relaxed) {
900                    match tcp_listener.accept() {
901                        Ok((tcp_stream, _)) => {
902                            // macOS accept() inherits non-blocking from the
903                            // listener — force blocking for the relay.
904                            let _ = tcp_stream.set_nonblocking(false);
905                            let vm = Arc::clone(&vm);
906                            std::thread::spawn(move || {
907                                if let Err(e) =
908                                    handle_forward_connection(tcp_stream, &vm, guest_port)
909                                {
910                                    tracing::debug!("port forward error: {}", e);
911                                }
912                            });
913                        }
914                        Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => {
915                            std::thread::sleep(Duration::from_millis(50));
916                        }
917                        Err(e) => {
918                            if !stop_flag.load(Ordering::Relaxed) {
919                                tracing::debug!("accept error on port forward listener: {}", e);
920                            }
921                            break;
922                        }
923                    }
924                }
925            });
926
927            listeners.push(handle);
928        }
929
930        Ok(PortForwardHandle {
931            stop,
932            threads: listeners,
933        })
934    }
935
936    /// Open a raw bidirectional stream to a TCP port listening inside the guest.
937    ///
938    /// Unlike [`start_port_forwarding`](Self::start_port_forwarding), this does
939    /// not bind a host listener. It completes the vsock forward handshake and
940    /// hands back the connected stream directly, so callers can bridge a guest
941    /// service to an arbitrary transport (e.g. a tunnel) without a local port.
942    /// The returned stream talks to `127.0.0.1:guest_port` inside the guest and
943    /// works whether or not networking (`--allow-net`) is enabled.
944    pub fn connect_forward(&self, guest_port: u16) -> Result<TcpStream> {
945        open_forward_stream(&self.vm, guest_port)
946    }
947
948    /// How long a guest gets to answer its first connection.
949    ///
950    /// It is a WALL CLOCK, not a count of attempts. The budget used to be a
951    /// thousand tries — a hundred at 1 ms then nine hundred at 10 ms, so about
952    /// nine seconds — and a guest boots in a quarter of one on an idle
953    /// machine. On a machine under real load it does not: at a load average
954    /// past a hundred, the same guest took longer than the budget, and the
955    /// error blamed the refusal that was still being retried rather than the
956    /// wait that ran out. A minute is past the point where waiting longer
957    /// tells anyone anything.
958    const REACHABLE: Duration = Duration::from_secs(60);
959
960    fn connect_vsock(&self) -> Result<TcpStream> {
961        let state_rx = self.vm.state_channel();
962        let start = std::time::Instant::now();
963        let mut last;
964        loop {
965            // Check if VM died (e.g. guest mount failure -> reboot POWER_OFF)
966            if let Ok(state) = state_rx.try_recv() {
967                match state {
968                    VmState::Stopped => {
969                        bail!("VM stopped during startup - check boot output above for errors")
970                    }
971                    VmState::Error => bail!("VM encountered an error during startup"),
972                    _ => {}
973                }
974            }
975            match self.vm.connect_to_vsock_port(VSOCK_PORT) {
976                Ok(s) => {
977                    let _ = s.set_nodelay(true);
978                    return Ok(s);
979                }
980                Err(e) => last = e,
981            }
982            let waited = start.elapsed();
983            if waited >= Self::REACHABLE {
984                bail!(
985                    "guest did not answer in {}s (last: {last})",
986                    Self::REACHABLE.as_secs()
987                );
988            }
989            tracing::debug!("vsock connect failed after {waited:?}: {last}");
990            // Fine-grained polling while the guest is expected any millisecond
991            // now; back off once it is clearly slow.
992            let interval = if waited < Duration::from_millis(100) {
993                1
994            } else {
995                10
996            };
997            std::thread::sleep(Duration::from_millis(interval));
998        }
999    }
1000}
1001
1002// --- Port forwarding ---
1003
1004/// Handle returned by `start_port_forwarding`. Signals all listener threads
1005/// to stop and joins them when dropped.
1006pub struct PortForwardHandle {
1007    stop: Arc<AtomicBool>,
1008    threads: Vec<std::thread::JoinHandle<()>>,
1009}
1010
1011impl Drop for PortForwardHandle {
1012    fn drop(&mut self) {
1013        self.stop.store(true, Ordering::Relaxed);
1014        for thread in self.threads.drain(..) {
1015            let _ = thread.join();
1016        }
1017    }
1018}
1019
1020fn handle_forward_connection(
1021    tcp_stream: TcpStream,
1022    vm: &VirtualMachine,
1023    guest_port: u16,
1024) -> Result<()> {
1025    let vsock_stream = open_forward_stream(vm, guest_port)?;
1026    // Bidirectional relay between TCP and vsock
1027    relay(tcp_stream, vsock_stream);
1028    Ok(())
1029}
1030
1031/// Open a vsock forward channel to a TCP port inside the guest and complete
1032/// the forward handshake, returning the connected stream. The stream is a raw
1033/// bidirectional pipe to `127.0.0.1:guest_port` inside the guest.
1034fn open_forward_stream(vm: &VirtualMachine, guest_port: u16) -> Result<TcpStream> {
1035    let mut vsock_stream = vm
1036        .connect_to_vsock_port(VSOCK_PORT_FORWARD)
1037        .map_err(|e| anyhow::anyhow!("vsock connect for port forward: {}", e))?;
1038    let _ = vsock_stream.set_nodelay(true);
1039
1040    // Send forward request
1041    let req = ForwardRequest { port: guest_port };
1042    frame::send_json(&mut vsock_stream, frame::FWD_REQ, &req)?;
1043
1044    // Read response frame
1045    let (_msg_type, payload) = frame::read_frame(&mut vsock_stream)
1046        .context("reading forward response")?
1047        .context("guest closed connection during forward handshake")?;
1048    let resp: ForwardResponse =
1049        serde_json::from_slice(&payload).context("parsing forward response")?;
1050
1051    if resp.status != "ok" {
1052        bail!(
1053            "guest refused forward: {}",
1054            resp.message.unwrap_or_default()
1055        );
1056    }
1057
1058    Ok(vsock_stream)
1059}
1060
1061fn relay(a: TcpStream, b: TcpStream) {
1062    let mut a_read = a.try_clone().expect("clone tcp stream");
1063    let mut b_write = b.try_clone().expect("clone vsock stream");
1064    let mut b_read = b;
1065    let mut a_write = a;
1066
1067    let t1 = std::thread::spawn(move || {
1068        let _ = std::io::copy(&mut a_read, &mut b_write);
1069        let _ = b_write.shutdown(Shutdown::Write);
1070    });
1071    let t2 = std::thread::spawn(move || {
1072        let _ = std::io::copy(&mut b_read, &mut a_write);
1073        let _ = a_write.shutdown(Shutdown::Write);
1074    });
1075    let _ = t1.join();
1076    let _ = t2.join();
1077}