pub mod oci;
#[cfg(test)]
#[cfg(feature = "sandbox-microvm")]
mod pty_harness;
pub mod rootfs;
pub mod runner;
pub mod ssh;
#[cfg(test)]
mod tests;
use std::net::TcpListener;
use std::path::PathBuf;
use std::time::Duration;
use ssh::{EphemeralKeys, HostKeys, ssh_exec, wait_for_ssh};
#[derive(Debug, Clone)]
pub struct MicrovmConfig {
pub image: String,
pub workspace: PathBuf,
pub cpus: u8,
pub memory_mib: u32,
pub ssh_port: u16,
pub cache_dir: PathBuf,
}
impl Default for MicrovmConfig {
fn default() -> Self {
Self {
image: if cfg!(target_os = "macos") {
"local://dirge-microvm:alpine".to_string()
} else {
"local://dirge-microvm:debian".to_string()
},
workspace: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
cpus: 1,
memory_mib: 512,
ssh_port: 0,
cache_dir: dirs::cache_dir()
.unwrap_or_else(|| PathBuf::from("/tmp"))
.join("dirge")
.join("microvm"),
}
}
}
impl MicrovmConfig {
pub fn snapshots_dir(&self) -> PathBuf {
self.cache_dir.join("snapshots")
}
pub fn cached_base_path(&self) -> PathBuf {
let image_safe = self.image.replace(['/', ':'], "_");
self.cache_dir.join(&image_safe).join("base")
}
}
pub struct MicrovmSandbox {
pub(crate) config: MicrovmConfig,
ssh_port: u16,
child: Option<std::process::Child>,
pub(crate) rootfs_path: Option<PathBuf>,
pub(crate) keys: Option<EphemeralKeys>,
pub(crate) host_keys: Option<HostKeys>,
}
impl std::fmt::Debug for MicrovmSandbox {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("MicrovmSandbox")
.field("config", &self.config)
.field("ssh_port", &self.ssh_port)
.field("child_pid", &self.child.as_ref().map(|c| c.id()))
.field("rootfs_path", &self.rootfs_path)
.finish()
}
}
impl MicrovmSandbox {
pub fn new(config: MicrovmConfig) -> Self {
Self {
config,
ssh_port: 0,
child: None,
rootfs_path: None,
keys: None,
host_keys: None,
}
}
pub fn ssh_port(&self) -> u16 {
self.ssh_port
}
pub async fn start(&mut self) -> anyhow::Result<()> {
let rootfs = rootfs::prepare(&self.config.image, &self.config.cache_dir).await?;
self.rootfs_path = Some(rootfs.path().to_path_buf());
std::mem::forget(rootfs);
let keys = EphemeralKeys::generate()?;
let host_keys = HostKeys::generate()?;
if let Some(ref rootfs_path) = self.rootfs_path {
let passwd_path = rootfs_path.join("etc").join("passwd");
let passwd = std::fs::read_to_string(&passwd_path).unwrap_or_default();
if !passwd.contains("sandbox:") {
std::fs::write(
&passwd_path,
format!(
"{passwd}sandbox:x:1000:1000::/home/sandbox:/bin/sh
"
),
)?;
}
let group_path = rootfs_path.join("etc").join("group");
let group = std::fs::read_to_string(&group_path).unwrap_or_default();
if !group.contains("sandbox:") {
std::fs::write(&group_path, format!("{group}sandbox:x:1000:\n"))?;
}
let ssh_dir = rootfs_path.join("home").join("sandbox").join(".ssh");
std::fs::create_dir_all(&ssh_dir)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&ssh_dir, std::fs::Permissions::from_mode(0o700))?;
std::fs::set_permissions(
rootfs_path.join("home").join("sandbox"),
std::fs::Permissions::from_mode(0o700),
)?;
}
let auth_keys_path = ssh_dir.join("authorized_keys");
std::fs::write(&auth_keys_path, format!("{}\n", keys.public_key))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&auth_keys_path, std::fs::Permissions::from_mode(0o600))?;
}
host_keys.inject(rootfs_path)?;
self.host_keys = Some(host_keys);
let krun_config = serde_json::json!({
"Cmd": [
"/bin/sh", "-c",
"mount -t tmpfs tmpfs /run \
&& mkdir -p /run/sshd \
&& mkdir -p /workspace \
&& mount -t virtiofs workspace /workspace \
&& chmod 755 /var/empty \
&& exec /usr/sbin/sshd -D -e -o StrictModes=no"
],
"mounts": [
{"destination": "/var/empty", "type": "tmpfs", "source": "tmpfs"},
{"destination": "/workspace", "type": "virtiofs", "source": "workspace"}
],
"Env": [],
"WorkingDir": "/"
});
std::fs::write(
rootfs_path.join(".krun_config.json"),
serde_json::to_string_pretty(&krun_config)?,
)?;
}
let port = if self.config.ssh_port == 0 {
let listener = TcpListener::bind("127.0.0.1:0")?;
let port = listener.local_addr()?.port();
drop(listener);
port
} else {
self.config.ssh_port
};
let binary = runner::find_runner_binary()?;
#[cfg(target_os = "macos")]
ensure_runner_signed(&binary)?;
#[allow(unused_mut)]
let mut config = serde_json::json!({
"rootfs_path": self.rootfs_path.as_ref().unwrap(),
"workspace_path": self.config.workspace,
"ssh_port": port,
"cpus": self.config.cpus,
"memory_mib": self.config.memory_mib,
});
#[cfg(target_os = "macos")]
#[cfg(target_os = "macos")]
{
let pubkey_escaped = keys.public_key.replace("'", "'\\''");
let cmd = format!(
"mkdir -p /var/empty && mount -t tmpfs -o uid=0,gid=0,mode=0755 tmpfs /var/empty && mkdir -p /etc/ssh && echo '{0}' > /etc/ssh/authorized_keys && chown -R 1000:1000 /home/sandbox 2>/dev/null || true && exec /usr/sbin/sshd -D -e -o StrictModes=no -o AuthorizedKeysFile=/etc/ssh/authorized_keys",
pubkey_escaped,
);
config["exec_cmd"] = serde_json::json!(["/bin/sh", "-c", cmd]);
}
let mut cmd = std::process::Command::new(&binary);
cmd.arg(serde_json::to_string(&config)?);
#[cfg(target_os = "macos")]
{
use std::ffi::OsStr;
use std::path::Path;
let brew_prefixes: Vec<String> = [
std::process::Command::new("brew")
.args(["--prefix", "libkrunfw"])
.stderr(std::process::Stdio::null())
.output()
.ok()
.and_then(|o| {
if o.status.success() || !o.stdout.is_empty() {
Some(
std::str::from_utf8(&o.stdout)
.unwrap_or("")
.trim()
.to_string(),
)
} else {
None
}
}),
std::process::Command::new("brew")
.args(["--prefix"])
.output()
.ok()
.and_then(|o| {
let s = std::str::from_utf8(&o.stdout)
.unwrap_or("")
.trim()
.to_string();
if s.is_empty() { None } else { Some(s) }
}),
]
.into_iter()
.flatten()
.filter(|p| !p.is_empty())
.map(|p| format!("{p}/lib"))
.collect::<Vec<_>>();
let mut seen = std::collections::HashSet::new();
let brew_lib_dirs: Vec<String> = brew_prefixes
.into_iter()
.filter(|d| seen.insert(d.clone()))
.collect();
let krunfw_name = crate::sandbox::check::LIBKRUNFW_LIB;
let krunfw_exists = brew_lib_dirs
.iter()
.any(|d| Path::new(d).join(krunfw_name).exists());
if !krunfw_exists {
anyhow::bail!(
"{krunfw_name} not found — install it: \
brew tap libkrun/krun && brew trust libkrun/krun && \
brew install libkrun libkrunfw"
);
}
let dyld_val = brew_lib_dirs.join(":");
let existing = std::env::var(OsStr::new("DYLD_FALLBACK_LIBRARY_PATH"))
.ok()
.filter(|v| !v.is_empty());
let dyld_full = if let Some(ref existing) = existing {
format!("{dyld_val}:{existing}")
} else {
format!("{dyld_val}:/usr/local/lib:/usr/lib")
};
cmd.env("DYLD_FALLBACK_LIBRARY_PATH", &dyld_full);
}
cmd.stdout(std::process::Stdio::null());
cmd.stderr(std::process::Stdio::piped());
let child = cmd
.spawn()
.map_err(|e| anyhow::anyhow!("failed to spawn dirge-microvm-runner: {e}"))?;
let _renice_status = std::process::Command::new("renice")
.arg("-n")
.arg("19")
.arg("-p")
.arg(child.id().to_string())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status();
let cpu_count = std::thread::available_parallelism()
.map(|n| n.get())
.unwrap_or(1);
let runner_cpu = cpu_count.saturating_sub(1);
let _taskset_status = std::process::Command::new("taskset")
.arg("-cp")
.arg(runner_cpu.to_string())
.arg(child.id().to_string())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status();
self.child = Some(child);
self.ssh_port = port;
self.keys = Some(keys);
let wait_port = port;
match tokio::task::spawn_blocking(move || {
wait_for_ssh("127.0.0.1", wait_port, Duration::from_secs(30))
})
.await
{
Ok(Ok(())) => {
if let Some(child) = self.child.as_mut()
&& let Ok(Some(status)) = child.try_wait()
{
let mut stderr = String::new();
if let Some(ref mut pipe) = child.stderr {
use std::io::Read;
let _ = pipe.read_to_string(&mut stderr);
}
anyhow::bail!(
"VM exited immediately after boot: {status} — stderr: {}",
if stderr.is_empty() {
"(empty)"
} else {
&stderr
}
);
}
}
Ok(Err(e)) => {
if let Some(mut child) = self.child.take() {
match child.try_wait() {
Ok(Some(status)) => {
let mut stderr = String::new();
if let Some(ref mut pipe) = child.stderr {
use std::io::Read;
let _ = pipe.read_to_string(&mut stderr);
}
let status_str = if let Some(code) = status.code() {
if code == 127 {
format!(
"{status} — the VM may be missing sshd. \
Try re-running `dirge sandbox setup`"
)
} else {
status.to_string()
}
} else {
status.to_string()
};
anyhow::bail!(
"runner exited {status_str} — stderr: {}",
if stderr.is_empty() {
"(empty)"
} else {
&stderr
}
);
}
Ok(None) => {
let _ = child.kill();
let _ = child.wait();
}
Err(_) => {}
}
}
return Err(e);
}
Err(join_err) => {
anyhow::bail!("spawn_blocking panicked or failed: {join_err}");
}
}
Ok(())
}
#[allow(dead_code)] pub fn exec(
&self,
command: &str,
_env: &[(&str, &str)],
cwd: &str,
) -> anyhow::Result<(String, String, i32)> {
let keys = self
.keys
.as_ref()
.ok_or_else(|| anyhow::anyhow!("VM not started"))?;
let host_key_bytes = self
.host_keys
.as_ref()
.map(|hk| hk.public_key_bytes())
.transpose()?;
let command = format!("cd {} && {}", cwd, command);
ssh_exec(
"127.0.0.1",
self.ssh_port,
&keys.private_key_path,
&command,
host_key_bytes.as_deref(),
)
}
pub fn stop(&mut self) -> anyhow::Result<()> {
let host_key_bytes = self
.host_keys
.as_ref()
.and_then(|hk| hk.public_key_bytes().ok());
if let Some(mut child) = self.child.take() {
if let (Some(keys), ssh_port) = (self.keys.as_ref(), self.ssh_port)
&& ssh_port != 0
{
let private_key = keys.private_key_path.clone();
let hkb = host_key_bytes;
std::thread::spawn(move || {
let _ = ssh_exec(
"127.0.0.1",
ssh_port,
&private_key,
"poweroff",
hkb.as_deref(),
);
});
std::thread::sleep(std::time::Duration::from_secs(1));
}
if child.try_wait().ok().flatten().is_none() {
let pid = child.id();
let _ = std::process::Command::new("kill")
.arg("-TERM")
.arg(pid.to_string())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status();
let start = std::time::Instant::now();
while start.elapsed() < std::time::Duration::from_secs(5) {
if child.try_wait().ok().flatten().is_some() {
break;
}
std::thread::sleep(std::time::Duration::from_millis(100));
}
}
if child.try_wait().ok().flatten().is_none() {
let _ = child.kill();
let _ = child.wait();
}
}
if let Some(ref path) = self.rootfs_path {
rootfs::cleanup(path)?;
}
self.ssh_port = 0;
self.keys = None;
Ok(())
}
pub(crate) fn validate_snapshot_name(name: &str) -> anyhow::Result<()> {
if name.is_empty()
|| name == "."
|| name == ".."
|| !name
.as_bytes()
.iter()
.all(|b| b.is_ascii_alphanumeric() || *b == b'.' || *b == b'_' || *b == b'-')
{
anyhow::bail!("invalid snapshot name '{name}'");
}
Ok(())
}
pub fn save_snapshot(&self, name: &str) -> anyhow::Result<()> {
Self::validate_snapshot_name(name)?;
let rootfs = self
.rootfs_path
.as_ref()
.ok_or_else(|| anyhow::anyhow!("VM not started — no rootfs to snapshot"))?;
let snap_dir = self.config.snapshots_dir().join(name);
if snap_dir.exists() {
anyhow::bail!(
"snapshot '{name}' already exists — delete it first or choose a different name"
);
}
std::fs::create_dir_all(self.config.snapshots_dir())?;
rootfs::cp_r(rootfs, &snap_dir)?;
Ok(())
}
pub fn list_snapshots(&self) -> anyhow::Result<Vec<String>> {
let snap_dir = self.config.snapshots_dir();
if !snap_dir.exists() {
return Ok(vec![]);
}
let mut names: Vec<String> = std::fs::read_dir(&snap_dir)?
.filter_map(|e| e.ok())
.filter(|e| e.file_type().map(|t| t.is_dir()).unwrap_or(false))
.filter_map(|e| e.file_name().into_string().ok())
.collect();
names.sort();
Ok(names)
}
pub fn restore_snapshot(&self, name: &str) -> anyhow::Result<()> {
Self::validate_snapshot_name(name)?;
if self.ssh_port != 0 {
anyhow::bail!("VM is running — stop it before restoring a snapshot");
}
let snap_path = self.config.snapshots_dir().join(name);
if !snap_path.exists() {
anyhow::bail!("snapshot '{name}' does not exist");
}
let base = self.config.cached_base_path();
if base.exists() {
std::fs::remove_dir_all(&base)?;
}
std::fs::create_dir_all(base.parent().unwrap())?;
rootfs::cp_r(&snap_path, &base)?;
Ok(())
}
pub fn delete_snapshot(&self, name: &str) -> anyhow::Result<()> {
Self::validate_snapshot_name(name)?;
let snap_path = self.config.snapshots_dir().join(name);
if !snap_path.exists() {
anyhow::bail!("snapshot '{name}' does not exist");
}
std::fs::remove_dir_all(&snap_path)?;
Ok(())
}
pub async fn reboot(&mut self) -> anyhow::Result<()> {
self.stop()?;
self.start().await
}
}
impl Drop for MicrovmSandbox {
fn drop(&mut self) {
let _ = self.stop();
}
}
#[cfg(target_os = "macos")]
fn ensure_runner_signed(binary: &std::path::Path) -> anyhow::Result<()> {
use std::process::Command;
let check = Command::new("codesign")
.args(["-d", "--entitlements", "-"])
.arg(binary)
.output();
if let Ok(ref out) = check {
let out_str = String::from_utf8_lossy(&out.stdout);
if out_str.contains("com.apple.security.hypervisor") {
return Ok(());
}
}
let tmp = std::env::temp_dir().join(format!(
"dirge-runner-entitlements-{}.plist",
uuid::Uuid::new_v4()
));
std::fs::write(
&tmp,
r#"<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.hypervisor</key>
<true/>
</dict>
</plist>"#,
)?;
let status = Command::new("codesign")
.args(["--force", "--sign", "-", "--entitlements"])
.arg(&tmp)
.arg(binary)
.status()
.map_err(|e| anyhow::anyhow!("failed to run codesign: {e}"))?;
let _ = std::fs::remove_file(&tmp);
if !status.success() {
anyhow::bail!(
"failed to codesign dirge-microvm-runner — Hypervisor.framework won't be available"
);
}
Ok(())
}