Compare commits

1 Commits
Author SHA1 Message Date
marvin 1206728446 virtiofs: add --mount-mode {unshare,direct}
CI / build (pull_request) Failing after 8s
Adds a boot flag choosing how virtiofsd is launched per --mount:

- unshare (default, previous behavior): virtiofsd runs inside
  'podman unshare' with --sandbox chroot, mapping guest UIDs into
  the invoking user's subuid range.

- direct: virtiofsd runs as the invoking user with --sandbox none,
  mapping guest UIDs 1:1 to host UIDs.

The direct mode is needed when the shared directory itself lives on
a virtiofs/fuse mount that rejects subuid-range UIDs (e.g. nested
boco): guest writes through the subuid mapping then fail with
EINVAL, breaking anything that writes to shares as a non-root guest
user (databases, agent workspaces).

Direct mode also cannot use the chroot sandbox (unprivileged
chroot() is not permitted), so it falls back to --sandbox none;
served trees are then not symlink-escape-protected.

Verified both modes boot and serve mounts (alpine guest).
2026-09-26 14:36:49 +02:00
5 changed files with 95 additions and 65 deletions

No files matched your search

+7 -5
View File
@@ -245,7 +245,13 @@ fn setup_virtiofs_shares(workdir: &str) {
};
try_io(&format!("mkdir {dest}"), || fs::create_dir_all(&dest));
try_mount(Some(tag), &dest, Some("virtiofs"), MsFlags::empty(), None);
try_mount(
Some(tag),
&dest,
Some("virtiofs"),
MsFlags::empty(),
None,
);
}
}
}
@@ -353,9 +359,6 @@ fn exec_command(argv: &[String], user: Option<UserSpec>) -> ! {
let c_prog = cstrings[0].clone();
let c_refs: Vec<&CString> = cstrings.iter().collect();
// If UserSpec is 0:0, don't fork.
let user = user.filter(|u| !u.uid.is_root() || u.gid != Gid::from_raw(0));
match user {
None => {
// No privilege drop: exec directly, PID 1 becomes the command.
@@ -365,7 +368,6 @@ fn exec_command(argv: &[String], user: Option<UserSpec>) -> ! {
std::process::exit(1);
}
Some(user) => {
warn(&format!("dropping privileges to {}:{}", user.uid, user.gid));
// Drop privileges in a child so PID 1 (root) can poweroff afterwards.
match unsafe { fork() } {
Ok(ForkResult::Child) => {
-12
View File
@@ -1,12 +0,0 @@
#!/usr/bin/env bash
set -ex
cd "$(dirname "$0")/.."
mkdir -p "$HOME/.local/share/boco"
cargo build --release --target x86_64-unknown-linux-musl -p boco-init
target_dir="${CARGO_TARGET_DIR:-target}"
cp "$target_dir/x86_64-unknown-linux-musl/release/boco-init" "$HOME/.local/share/boco/"
+25 -23
View File
@@ -1,4 +1,15 @@
//! See [`boot`]
//! QEMU: boot a registry entry with qemu-system-x86_64 using direct kernel
//! boot (-kernel/-initrd or -drive). The kernel is shared across all VMs
//! at $XDG_DATA_HOME/boco/registry/vmlinuz. A universal /boco/init script (injected
//! at build time) handles VM bootstrap; /boco/exec runs the container's
//! CMD/ENTRYPOINT. Runtime overrides are delivered as `boco.cmd=<base64>`
//! on the kernel cmdline. Host directories are shared into the guest via
//! virtio-fs: one virtiofsd daemon per `--mount`, plus a `-chardev socket`
//! + `-device vhost-user-fs-pci` pair on the QEMU side. virtiofsd runs
//! under `podman unshare` (rootless UID mapping) by default, or directly
//! as the invoking user with `--mount-mode direct` (1:1 UID mapping).
//! The guest mounts each share with `mount -t virtiofs <tag> <dest>`
//! (see `boco.mount=` cmdline).
use anyhow::{Context, Result, bail};
use base64::Engine;
@@ -7,8 +18,8 @@ use std::{fmt::Write as _, process::Command};
use crate::{
forward::PortForward,
registry::{registry_dir, vmlinuz_path},
virtiofs::{self, VirtiofsShare},
registry::{registry_base_dir, registry_dir},
virtiofs::{self, MountMode, VirtiofsShare},
};
#[derive(Args, Debug)]
@@ -24,10 +35,6 @@ pub struct BootCmd {
#[clap(long)]
forward: Vec<PortForward>,
/// Pass a `guestfwd=...` argument to qemu `-nic` argument.
#[clap(long)]
guestfwd: Vec<String>,
/// Override the command to exec in the VM (base64-encoded on the kernel
/// cmdline as `boco.cmd=<b64>`). Overrides the CMD inferred at build time.
#[clap(long)]
@@ -40,33 +47,32 @@ pub struct BootCmd {
#[clap(long)]
mount: Vec<String>,
/// Extra arguments to qemu
extra: Vec<String>,
/// How virtiofsd is launched: `unshare` runs it inside
/// `podman unshare` (rootless UID mapping, guest UIDs map into the
/// subuid range); `direct` runs it as the invoking user (guest UIDs
/// map 1:1 to host UIDs — needed for nested virtiofs shares).
#[clap(long, value_enum, default_value = "unshare")]
mount_mode: MountMode,
}
/// Invoke `qemu` to boot a boco image. See [`BootCmd`] for options
///
/// This uses qemu's direct-kernel-boot with `-kernel`, so it does not require a bootloader.
/// The boco image is just an ext4 rootfs.
pub(crate) fn boot(
BootCmd {
name,
memory,
forward,
guestfwd,
cmd,
mount,
extra,
mount_mode,
}: BootCmd,
) -> Result<()> {
let reg_dir = registry_dir(&name)?;
let vmlinuz_path = vmlinuz_path()?;
let vmlinuz_path = registry_base_dir()?.join("vmlinuz");
let initrd_path = reg_dir.join("initrd");
let qcow2_path = reg_dir.join("image.qcow2");
if !vmlinuz_path.exists() {
// TODO: guide user in how to set up a kernel
bail!(
"No kernel available. Expected vmlinuz at {}",
"No kernel available. Place a vmlinuz at {}",
vmlinuz_path.display()
);
}
@@ -112,7 +118,7 @@ pub(crate) fn boot(
Some((h, g)) => (h, g.to_string()),
None => (spec.as_str(), spec.to_string()),
};
let share = virtiofs::spawn_share(i, host_path, guest_path)?;
let share = virtiofs::spawn_share(i, host_path, guest_path, mount_mode)?;
let tag = share.tag.clone();
let chardev = format!("char{i}");
let socket = share
@@ -158,9 +164,6 @@ pub(crate) fn boot(
for forward in &forward {
_ = write!(&mut network, ",hostfwd={forward}");
}
for guestfwd in &guestfwd {
_ = write!(&mut network, ",guestfwd={guestfwd}");
}
let mut command = Command::new("qemu-system-x86_64");
@@ -177,8 +180,7 @@ pub(crate) fn boot(
.args(["-no-reboot"])
.args(["-append", &cmdline])
.args(["-nographic"])
.args(["-nic", &network])
.args(&extra);
.args(["-nic", &network]);
println!("{command:?}");
-6
View File
@@ -26,12 +26,6 @@ pub(crate) fn validate_image_name(name: &str) -> Result<()> {
Ok(())
}
pub(crate) fn vmlinuz_path() -> Result<PathBuf> {
let base = xdg::BaseDirectories::with_prefix("boco");
base.place_data_file("vmlinuz")
.context("Failed to create XDG_DATA_HOME subdirectory")
}
pub(crate) fn registry_base_dir() -> Result<PathBuf> {
let base = xdg::BaseDirectories::with_prefix("boco");
base.create_data_directory("registry")
+63 -19
View File
@@ -6,13 +6,21 @@
//! hands the socket path to QEMU via `-chardev socket,path=...`, and
//! cleans up when the VM exits.
//!
//! To serve files owned by arbitrary guest UIDs (e.g. when an image
//! uses the `USER` directive) without running as root, virtiofsd is
//! launched under `podman unshare` in a user namespace with
//! `--sandbox chroot`. This reuses rootless podman's existing
//! subuid/subgid mapping: guest UID 0 maps to the invoking user, and
//! other guest UIDs map into the subuid range, so each guest UID
//! becomes a distinct host UID.
//! Launch modes ([`MountMode`]):
//!
//! * `unshare` (default): virtiofsd runs under `podman unshare` in a
//! user namespace with `--sandbox chroot`. This serves files owned
//! by arbitrary guest UIDs (e.g. when an image uses the `USER`
//! directive) without running as root, reusing rootless podman's
//! subuid/subgid mapping: guest UID 0 maps to the invoking user, and
//! other guest UIDs map into the subuid range, so each guest UID
//! becomes a distinct host UID.
//!
//! * `direct`: virtiofsd runs as the invoking user with `--sandbox
//! none`. Guest UIDs map 1:1 to host UIDs. Use this when the
//! shared directory lives on a filesystem that rejects subuid-range
//! UIDs (e.g. nested virtiofs), or when the guest should act as the
//! invoking user rather than the subuid range.
//!
//! Lifecycle: boco spawns all virtiofsd children before launching QEMU,
//! waits for each listening socket to appear, then drops the
@@ -28,10 +36,26 @@ use std::thread;
use std::time::{Duration, Instant};
use anyhow::{Context, Result, anyhow, bail};
use clap::ValueEnum;
use tempfile::TempDir;
use crate::command::cmd;
/// How virtiofsd is launched for a share.
#[derive(Clone, Copy, Debug, PartialEq, Eq, ValueEnum)]
pub enum MountMode {
/// Run virtiofsd inside `podman unshare` (rootless UID mapping:
/// guest UID 0 maps to the invoking user, other guest UIDs map
/// into the subuid range).
Unshare,
/// Run virtiofsd directly as the invoking user. Guest UIDs map 1:1
/// to host UIDs. Needed when the shared directory itself lives on
/// a virtiofs/fuse mount that rejects subuid-range UIDs (e.g.
/// nested boco), where writes through the subuid mapping fail with
/// EINVAL.
Direct,
}
/// Locations searched for the virtiofsd binary, in order.
const VIRTIOFSD_CANDIDATES: &[&str] = &[
"/usr/libexec/virtiofsd",
@@ -114,7 +138,12 @@ fn wait_for_socket(socket: &Path) -> Result<()> {
///
/// `index` becomes the suffix of the tag (`boco{index}`) and is used to
/// derive a unique socket filename.
pub fn spawn_share(index: usize, host_path: &str, guest_path: String) -> Result<VirtiofsShare> {
pub fn spawn_share(
index: usize,
host_path: &str,
guest_path: String,
mode: MountMode,
) -> Result<VirtiofsShare> {
let virtiofsd = find_virtiofsd()?;
let canonical = resolve_host_dir(host_path)?;
let host_str = canonical
@@ -128,17 +157,29 @@ pub fn spawn_share(index: usize, host_path: &str, guest_path: String) -> Result<
let tag = format!("boco{index}"); // short tag to fit on cmdline
let socket = dir.path().join(format!("{tag}.sock"));
// TODO: Make rootless mode optional
// Run virtiofsd under `podman unshare` so it can serve files owned
// by arbitrary guest UIDs without root, mapping them into the host
// user's subuid range. `--sandbox chroot` is required because the
// outer `podman unshare` already entered a user namespace (nested
// user namespaces are not allowed); chroot still prevents symlink
// escapes from the shared dir.
let mut command = Command::new("podman");
// In `unshare` mode, run virtiofsd under `podman unshare` so it can
// serve files owned by arbitrary guest UIDs without root, mapping
// them into the host user's subuid range. `--sandbox chroot` is
// required because the outer `podman unshare` already entered a user
// namespace (nested user namespaces are not allowed); chroot still
// prevents symlink escapes from the shared dir.
//
// In `direct` mode, run virtiofsd as the invoking user with 1:1 UID
// mapping. This cannot use the chroot sandbox (unprivileged
// chroot is not permitted), so it falls back to `--sandbox none`.
let mut command = match mode {
MountMode::Unshare => {
let mut command = Command::new("podman");
command.args(["unshare", "--", virtiofsd_str, "--socket-path"]);
command
}
MountMode::Direct => {
let mut command = Command::new(virtiofsd_str);
command.arg("--socket-path");
command
}
};
command
.args(["unshare", "--", virtiofsd_str, "--socket-path"])
.arg(&socket)
.args([
"--shared-dir",
@@ -146,7 +187,10 @@ pub fn spawn_share(index: usize, host_path: &str, guest_path: String) -> Result<
"--tag",
&tag,
"--sandbox",
"chroot",
match mode {
MountMode::Unshare => "chroot",
MountMode::Direct => "none",
},
"--cache",
"auto",
])