//! Sandbox backends + a PTY-backed sandbox the broker drives. //! //! The broker (owner's client) spawns a sandbox shell inside a PTY. Output bytes //! are pumped out of a reader thread onto an mpsc channel; the broker encrypts //! them with the room key and relays them to the clergy as `sbx pty_data` frames. //! Input frames (`sbx pty_input`) are written back into the PTY. The server only //! ever sees ciphertext — identical trust model to chat/file transfer. use anyhow::{Context, Result}; use portable_pty::{native_pty_system, Child, CommandBuilder, MasterPty, PtySize}; use std::io::{Read, Write}; use std::process::{Command, Stdio}; use std::sync::mpsc; /// Helper that ensures the Docker daemon is running (ships in hh/scripts/). const ENSURE_DOCKER: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/scripts/ensure-docker.sh"); /// Detect-first VirtualBox installer (ships in hh/scripts/). const ENSURE_VBOX: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/scripts/ensure-vbox.sh"); /// Baseline dev-toolchain installer run inside Docker sandboxes (hh/scripts/). const SBX_BOOTSTRAP: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/scripts/sandbox-bootstrap.sh"); /// Editable package schema the bootstrap installs — vim+curl always added. const SBX_TOOLS_JSON: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/scripts/sandbox-tools.json"); /// Creates a fresh, cloud-init-provisioned Ubuntu VirtualBox VM (hh/scripts/). const VBOX_NEW: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/scripts/vbox-new.sh"); /// Is the Docker daemon accepting connections? (`docker info` succeeds.) pub fn docker_daemon_up() -> bool { Command::new("docker") .arg("info") .stdout(Stdio::null()) .stderr(Stdio::null()) .status() .map(|s| s.success()) .unwrap_or(false) } /// Start the Docker daemon via `ensure-docker.sh --yes`, waiting until it's /// ready. Returns the script's last error line on failure (e.g. needs sudo). fn start_docker_daemon() -> Result<()> { let out = Command::new("bash") .arg(ENSURE_DOCKER) .arg("--yes") .output() .context("running ensure-docker.sh")?; if !out.status.success() { let err = String::from_utf8_lossy(&out.stderr); let last = err .lines() .last() .unwrap_or("could not start the docker daemon"); anyhow::bail!("{last}"); } Ok(()) } // ---- VirtualBox (local GUI VMs) --------------------------------------------- // VirtualBox is integrated as a *local* facility rather than a shared-PTY // backend: a room shares a VM by handing out its appliance, and each member // boots it in the real VirtualBox GUI on their own machine. None of this relays // over the room — only the (separately `/send`-ed) image does — so the // zero-knowledge model is untouched. A Windows guest has no sshd/guestcontrol // shell to drive, so the GUI launch is the honest fit, not a faked PTY. /// Is VirtualBox installed? (`VBoxManage --version` succeeds.) pub fn vbox_installed() -> bool { Command::new("VBoxManage") .arg("--version") .stdout(Stdio::null()) .stderr(Stdio::null()) .status() .map(|s| s.success()) .unwrap_or(false) } /// VirtualBox version string (e.g. `7.1.2r164945`), or None if not installed. pub fn vbox_version() -> Option { let out = Command::new("VBoxManage").arg("--version").output().ok()?; let v = String::from_utf8_lossy(&out.stdout).trim().to_string(); (out.status.success() && !v.is_empty()).then_some(v) } /// Install VirtualBox via `ensure-vbox.sh --yes`. Consent is the caller's job /// (they passed `--install`); detection is the script's (idempotent if present). /// Returns the script's last error line on failure (e.g. needs sudo). pub fn ensure_vbox_install() -> Result<()> { let out = Command::new("bash") .arg(ENSURE_VBOX) .arg("--yes") .output() .context("running ensure-vbox.sh")?; if !out.status.success() { let err = String::from_utf8_lossy(&out.stderr); let last = err.lines().last().unwrap_or("could not install VirtualBox"); anyhow::bail!("{last}"); } Ok(()) } /// Names of registered VirtualBox VMs (`VBoxManage list vms`). Each line is /// `"name" {uuid}`; we return the unquoted names. pub fn list_vms() -> Result> { let out = Command::new("VBoxManage") .args(["list", "vms"]) .output() .context("VBoxManage list vms (is VirtualBox installed?)")?; Ok(String::from_utf8_lossy(&out.stdout) .lines() .filter_map(|l| { let start = l.find('"')? + 1; let end = l[start..].find('"')? + start; Some(l[start..end].to_string()) }) .filter(|s| !s.is_empty()) .collect()) } /// Is a VM with this name registered locally? (i.e. present in `list_vms`). /// Used to tell a "host" (already has the appliance imported) from someone who /// still needs it pulled in before `gui_launch` can find it. pub fn vm_registered(name: &str) -> bool { list_vms() .map(|vms| vms.iter().any(|v| v == name)) .unwrap_or(false) } /// Import a VirtualBox appliance (`.ova`/`.ovf`) so its VM registers locally and /// becomes launchable via `gui_launch`. This is how a non-host "pulls" a shared /// VM onto their own machine after the appliance arrives over the encrypted /// channel. Blocking — run off the UI thread. Returns the imported VM's name. pub fn import_appliance(ova: &std::path::Path) -> Result { let before = list_vms().unwrap_or_default(); let out = Command::new("VBoxManage") .arg("import") .arg(ova) .output() .context("VBoxManage import (is VirtualBox installed?)")?; if !out.status.success() { let err = String::from_utf8_lossy(&out.stderr); anyhow::bail!( "VBoxManage import failed: {}", err.lines().last().unwrap_or("").trim() ); } // The imported name is whatever's newly registered; fall back to the file's // stem if the diff is ambiguous (e.g. a re-import of an existing name). let after = list_vms().unwrap_or_default(); let added = after.into_iter().find(|v| !before.contains(v)); Ok(added.unwrap_or_else(|| { ova.file_stem() .and_then(|s| s.to_str()) .unwrap_or("imported-vm") .to_string() })) } /// Is a VM currently running? (`VBoxManage list runningvms`) pub fn vm_running(name: &str) -> bool { Command::new("VBoxManage") .args(["list", "runningvms"]) .output() .map(|o| { let needle = format!("\"{name}\""); String::from_utf8_lossy(&o.stdout) .lines() .any(|l| l.contains(&needle)) }) .unwrap_or(false) } /// Launch a registered VM's GUI locally (`VBoxManage startvm --type gui`). /// The window opens on the caller's own desktop — this is the "share a VM, run /// it locally" path; nothing about the display is relayed to the room. pub fn gui_launch(name: &str) -> Result { if vm_running(name) { return Ok(format!("{name} is already running")); } let out = Command::new("VBoxManage") .args(["startvm", name, "--type", "gui"]) .output() .context("VBoxManage startvm (is VirtualBox installed?)")?; if !out.status.success() { let err = String::from_utf8_lossy(&out.stderr); anyhow::bail!( "startvm failed: {}", err.lines().last().unwrap_or("").trim() ); } Ok(format!("launched {name} (GUI)")) } /// Create + boot a *fresh* Ubuntu VirtualBox VM pre-provisioned with the dev /// toolchain, by driving `scripts/vbox-new.sh` (downloads a cloud image, builds /// a cloud-init NoCloud seed installing scripts/sandbox-tools.json, then boots /// the GUI). Unlike `/sbx launch vbox `, which opens an *existing* image, /// this builds one from scratch — the only path that doesn't need a pre-made VM. /// /// Blocking and slow on first run (~600MB image download). Runs the script /// non-interactively (`--yes`); returns its final status line(s) — including the /// generated login password, which is shown exactly once. pub fn vbox_new(name: &str, user: &str) -> Result { let out = Command::new("bash") .arg(VBOX_NEW) .args(["--yes", "--name", name, "--user", user]) .output() .context("running vbox-new.sh (is bash available?)")?; let stderr = String::from_utf8_lossy(&out.stderr); if !out.status.success() { anyhow::bail!( "vbox-new failed: {}", stderr.lines().rev().find(|l| !l.trim().is_empty()).unwrap_or("").trim() ); } // The script narrates progress on stderr. Surface the final success line and, // if one was generated, the one-time login password. let last = stderr .lines() .rev() .find(|l| l.trim_start().starts_with('✓')) .unwrap_or("VM created") .trim(); let pw = stderr.lines().find(|l| l.contains("login password")); Ok(match pw { Some(p) => format!("{last} · {}", p.trim()), None => last.to_string(), }) } /// A running hypervisor that holds the CPU's VT-x/VMX root mode. While one is /// live, VirtualBox can't boot a *hardware* VM (it aborts with /// `VERR_VMX_IN_VMX_ROOT_MODE`). We only mark holders we know how to stop /// cleanly (`stoppable`); an unrecognised qemu/kvm process is reported but never /// killed — the caller aborts and lets the user deal with it. #[derive(Clone, Debug)] pub struct VtxHolder { /// Human label, e.g. "Docker Desktop" or "multipass VM 'molt-mania-vm'". pub label: String, /// "docker" | "multipass" | "unknown" — selects the stop strategy. pub kind: String, /// Instance name to `multipass stop`; empty for non-multipass holders. pub instance: String, /// Whether we know a clean, reversible way to stop it (restartable after). pub stoppable: bool, } /// Is a KVM kernel module loaded? (Cheap early-out before scanning processes.) fn kvm_loaded() -> bool { std::fs::read_to_string("/proc/modules") .map(|s| { s.lines() .any(|l| l.starts_with("kvm_intel") || l.starts_with("kvm_amd")) }) .unwrap_or(false) } /// Detect running KVM-accelerated hypervisors that hold VT-x and would block a /// VirtualBox hardware VM. Pure detection — stops nothing. Empty vec = clear. pub fn vtx_holders() -> Vec { if !kvm_loaded() { return Vec::new(); } let out = match Command::new("ps").args(["-eo", "args="]).output() { Ok(o) => o, Err(_) => return Vec::new(), }; classify_vtx_holders(&String::from_utf8_lossy(&out.stdout)) } /// Pure classifier: turn `ps -eo args=` output into the VT-x holders we know how /// to (or refuse to) stop. Split out from `vtx_holders` so it can be unit-tested /// without a live process table. Only KVM-accelerated qemu lines count; Docker /// Desktop collapses to a single holder; multipass is named; anything else is an /// unknown, non-stoppable holder (we won't kill what we can't cleanly restart). fn classify_vtx_holders(text: &str) -> Vec { let mut holders = Vec::new(); let mut docker_seen = false; for args in text.lines() { let is_qemu = args.contains("qemu-system"); let uses_kvm = args.contains("--enable-kvm") || args.contains("-accel kvm") || args.contains("accel=kvm"); if !(is_qemu && uses_kvm) { continue; } if args.contains("docker-desktop") || args.contains("/.docker/") { // Docker Desktop runs one linuxkit VM; collapse to a single holder. if !docker_seen { docker_seen = true; holders.push(VtxHolder { label: "Docker Desktop".into(), kind: "docker".into(), instance: String::new(), stoppable: true, }); } } else if let Some(name) = multipass_instance(args) { holders.push(VtxHolder { label: format!("multipass VM '{name}'"), kind: "multipass".into(), instance: name, stoppable: true, }); } else { holders.push(VtxHolder { label: "an unknown KVM/QEMU virtual machine".into(), kind: "unknown".into(), instance: String::new(), stoppable: false, }); } } holders } /// Pull a multipass instance name out of a qemu command line. Multipass keeps /// each disk under `.../multipassd/vault/instances//...`. fn multipass_instance(args: &str) -> Option { if !args.contains("multipass") { return None; } let marker = "/instances/"; let i = args.find(marker)? + marker.len(); let rest = &args[i..]; let end = rest.find('/').unwrap_or(rest.len()); let name = &rest[..end]; (!name.is_empty()).then(|| name.to_string()) } /// Stop one VT-x holder with a clean, *reversible* command so it can be /// restarted later. Refuses to touch holders marked `stoppable=false`. pub fn stop_vtx_holder(h: &VtxHolder) -> Result<()> { match h.kind.as_str() { "docker" => { let out = Command::new("systemctl") .args(["--user", "stop", "docker-desktop"]) .output() .context("stopping Docker Desktop")?; if !out.status.success() { let err = String::from_utf8_lossy(&out.stderr); anyhow::bail!( "could not stop Docker Desktop: {}", err.lines().last().unwrap_or("").trim() ); } Ok(()) } "multipass" => { let out = Command::new("multipass") .args(["stop", &h.instance]) .output() .context("stopping multipass VM")?; if !out.status.success() { let err = String::from_utf8_lossy(&out.stderr); anyhow::bail!( "could not stop multipass '{}': {}", h.instance, err.lines().last().unwrap_or("").trim() ); } Ok(()) } _ => anyhow::bail!("refusing to stop {} automatically", h.label), } } /// Which sandbox to summon. Multipass = strong isolation (default for real use), /// Docker = fast, Local = no isolation (dev/testing only). #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Backend { Local, Docker, Multipass, } impl Backend { pub fn parse(s: &str) -> Option { match s { "local" => Some(Backend::Local), "docker" => Some(Backend::Docker), "multipass" => Some(Backend::Multipass), _ => None, } } pub fn label(self) -> &'static str { match self { Backend::Local => "local-shell", Backend::Docker => "docker", Backend::Multipass => "multipass", } } /// Default image/release when the user doesn't specify one. pub fn default_image(self) -> &'static str { match self { Backend::Multipass => "24.04", Backend::Docker => "ubuntu:24.04", Backend::Local => "", } } } /// One-time setup before the PTY shell is spawned. Blocking — run off the UI /// thread (Multipass boots a real VM, ~20-30s). Idempotent: reuses an instance /// that already exists. pub fn prepare(backend: Backend, name: &str, image: &str, start_daemon: bool) -> Result<()> { match backend { Backend::Local => Ok(()), Backend::Multipass => { let exists = Command::new("multipass") .args(["info", name]) .output() .map(|o| o.status.success()) .unwrap_or(false); if !exists { // Capture output so it can't bleed onto the TUI surface; surface // the failure reason through the returned error instead. let out = Command::new("multipass") .args([ "launch", "--name", name, "--cpus", "1", "--memory", "1G", "--disk", "5G", image, ]) .output() .context("multipass launch (is multipass installed?)")?; if !out.status.success() { let err = String::from_utf8_lossy(&out.stderr); anyhow::bail!( "multipass launch failed: {}", err.lines().last().unwrap_or("").trim() ); } } else { let _ = Command::new("multipass") .args(["start", name]) .stdout(Stdio::null()) .stderr(Stdio::null()) .status(); } Ok(()) } Backend::Docker => { // The daemon must be up before any `docker` call. Rather than fail // with a raw connection error, start it (the caller confirmed via // `/sbx launch docker --start`). if !docker_daemon_up() { if start_daemon { start_docker_daemon().context("starting docker daemon")?; } else { anyhow::bail!( "docker daemon is not running — retry with `/sbx launch docker --start`" ); } } // Persistent container so we can exec in to provision users + shells. let _ = Command::new("docker") .args(["rm", "-f", name]) .stdout(Stdio::null()) .stderr(Stdio::null()) .status(); // Capture output so a failure can't paint over the TUI; the reason is // surfaced through the returned error (shown in the error popup). let out = Command::new("docker") .args([ "run", "-d", "--name", name, "--hostname", name, "-w", "/root", image, "sleep", "infinity", ]) .output() .context("docker run (is docker installed?)")?; if !out.status.success() { let err = String::from_utf8_lossy(&out.stderr); anyhow::bail!( "docker run failed: {}", err.lines().last().unwrap_or("").trim() ); } Ok(()) } } } /// Destroy ephemeral resources after stop. Multipass instance is purged; /// the Docker container is removed; Local is a no-op. pub fn teardown(backend: Backend, name: &str) { match backend { Backend::Multipass => { // Multipass snapshots live *inside* the instance, so `delete --purge` // would destroy any saved state. If the user has snapshots worth // keeping (so `/sbx load