mirror of
https://github.com/nestriness/nestri.git
synced 2026-09-19 17:25:19 +03:00
feat: nescapture capture improvements and drive mounts (#337)
Co-authored-by: DatCaptainHorse <DatCaptainHorse@users.noreply.github.com> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
committed by
GitHub
parent
ebc0242b49
commit
6811c93d51
@@ -144,7 +144,7 @@ const EARLY: &[Early] = &[
|
||||
fstype: "tmpfs",
|
||||
flags: NOSUID_NODEV,
|
||||
data: "mode=755,size=4m",
|
||||
cost: "no share can be mounted, because its target cannot be created on a read-only root",
|
||||
cost: "no share can be mounted, because its target cannot be created on a read-only root",
|
||||
},
|
||||
// The relay's own directory, and it is deliberately **not** in the tree the
|
||||
// session's shares live in.
|
||||
|
||||
@@ -182,6 +182,11 @@ async fn reaper(waiters: Waiters) {
|
||||
|
||||
/// A signal from outside the channel. In a guest this is the hypervisor's
|
||||
/// shutdown request.
|
||||
///
|
||||
/// **SIGTERM is the one that matters here, not SIGINT.** A box has no terminal
|
||||
/// and nothing sends it Ctrl-C; what arrives is the hypervisor's ACPI power
|
||||
/// button, and a `ctrl_c()` that only watches SIGINT ignores it -- so the guest
|
||||
/// never shuts down cleanly and the box is killed on a timeout instead.
|
||||
async fn asked_to_stop() -> std::io::Result<()> {
|
||||
let mut term = signal(SignalKind::terminate())?;
|
||||
let mut int = signal(SignalKind::interrupt())?;
|
||||
|
||||
@@ -379,6 +379,17 @@ impl Stack {
|
||||
command.args(args);
|
||||
command.env_clear();
|
||||
command.envs(WRITABLE.iter().copied());
|
||||
// Forwarded, not cleared away with everything else: a service's log
|
||||
// level is otherwise unreachable. `env_clear` drops `RUST_LOG`, every
|
||||
// service resolves its filter with `EnvFilter::try_from_default_env`,
|
||||
// and that call has no variable to read — so each one falls back to
|
||||
// `info` whatever an operator sets, wherever they set it. There was no
|
||||
// way to raise a level inside the box at all, and the only ways around
|
||||
// it were to log at a level the line does not deserve or to rebuild the
|
||||
// image for each change.
|
||||
if let Ok(filter) = std::env::var("RUST_LOG") {
|
||||
command.env("RUST_LOG", filter);
|
||||
}
|
||||
// The service's own entry last, so a service that states one of these
|
||||
// for itself wins over the defaults above.
|
||||
command.envs(service.env.iter().copied());
|
||||
|
||||
@@ -266,6 +266,31 @@ where
|
||||
}
|
||||
booted = true;
|
||||
|
||||
// The drives, then the shares, then the services.
|
||||
//
|
||||
// **Drives first, and one `Mounted` between them.** A drive is
|
||||
// a filesystem this end mounts itself, so a share whose target
|
||||
// lives under one has to find it already there. The host is
|
||||
// told once, after both, because `Mounted` answers "is the
|
||||
// content where the descriptor said" and there is one answer to
|
||||
// that -- sending it twice made the host read the second as a
|
||||
// reply to something it had not asked.
|
||||
if let Err(failure) = workload.mount_drives(&descriptor.drives) {
|
||||
// Said before it is returned. `Refused` ends the session
|
||||
// either way; without the message the host sees a box that
|
||||
// stopped and has to guess between a drive, a share and a
|
||||
// service -- which is the whole reason these are reported
|
||||
// separately.
|
||||
send(
|
||||
&mut writer,
|
||||
&GuestToHost::MountFailed {
|
||||
reason: failure.reason.clone(),
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
return Ok(Outcome::Refused(failure));
|
||||
}
|
||||
|
||||
// The shares, then the services, and each reported separately.
|
||||
// Which of the two failed decides what is worth looking at, so
|
||||
// the two are never one message.
|
||||
@@ -317,6 +342,7 @@ where
|
||||
refuse(&mut writer, id, &reason).await?;
|
||||
continue;
|
||||
}
|
||||
|
||||
running = start(&mut writer, workload, untrusted, id, exec, on_exit).await?;
|
||||
}
|
||||
HostToGuest::Stop { id } => match &running {
|
||||
@@ -490,6 +516,7 @@ mod tests {
|
||||
at: "/mnt/user".into(),
|
||||
ro: false,
|
||||
}],
|
||||
drives: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ use std::future::Future;
|
||||
use std::io;
|
||||
use std::pin::Pin;
|
||||
|
||||
use nesprotocol::lifecycle::{Exec, Exit, Mount};
|
||||
use nesprotocol::lifecycle::{Drive, Exec, Exit, Mount};
|
||||
|
||||
use std::os::unix::process::CommandExt;
|
||||
|
||||
@@ -41,6 +41,9 @@ pub trait Workload {
|
||||
/// Make the shares the descriptor names, where it says to put them.
|
||||
fn mount(&mut self, mounts: &[Mount]) -> Result<(), Failure>;
|
||||
|
||||
/// Mount drives
|
||||
fn mount_drives(&mut self, drives: &[Drive]) -> Result<(), Failure>;
|
||||
|
||||
/// Start the command the descriptor names.
|
||||
///
|
||||
/// Returning the exit as a future, rather than a `wait` method, is what
|
||||
@@ -129,6 +132,13 @@ impl Workload for Process {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn mount_drives(&mut self, drives: &[Drive]) -> Result<(), Failure> {
|
||||
for drive in drives {
|
||||
mount_drive(drive)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn start(&mut self, exec: &Exec) -> Result<Exited, Failure> {
|
||||
let Some((program, args)) = exec.argv.split_first() else {
|
||||
return Err(Failure::new("the command is empty"));
|
||||
@@ -330,6 +340,36 @@ fn mount_share(share: &Mount) -> Result<(), Failure> {
|
||||
/// filesystem this mounts. A descriptor cannot name another.
|
||||
const FSTYPE: &std::ffi::CStr = c"virtiofs";
|
||||
|
||||
/// Mounts block device instead of virtiofs share
|
||||
fn mount_drive(drive: &Drive) -> Result<(), Failure> {
|
||||
// Checked before anything is created: a descriptor this component cannot
|
||||
// act on should leave no directory behind to confuse whoever reads the
|
||||
// failure.
|
||||
let (source, target, flags) = options_drive(drive)?;
|
||||
|
||||
// The mount point may not exist yet: a share can land anywhere the
|
||||
// descriptor names, including a directory no image created.
|
||||
std::fs::create_dir_all(&drive.at).map_err(|error| failed_drive(drive, error))?;
|
||||
|
||||
// SAFETY: mount takes two paths, a filesystem name and a flag word, all
|
||||
// of which outlive the call, and no options string.
|
||||
let mounted = unsafe {
|
||||
libc::mount(
|
||||
source.as_ptr(),
|
||||
target.as_ptr(),
|
||||
FSTYPE_DRIVE.as_ptr(),
|
||||
flags,
|
||||
std::ptr::null(),
|
||||
)
|
||||
};
|
||||
if mounted != 0 {
|
||||
return Err(failed_drive(drive, io::Error::last_os_error()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
const FSTYPE_DRIVE: &std::ffi::CStr = c"ext4";
|
||||
|
||||
/// What the mount call is given, split out because this is the part worth
|
||||
/// asserting: mounting itself needs privileges a test does not have.
|
||||
fn options(share: &Mount) -> Result<(CString, CString, libc::c_ulong), Failure> {
|
||||
@@ -360,6 +400,50 @@ fn options(share: &Mount) -> Result<(CString, CString, libc::c_ulong), Failure>
|
||||
Ok((source, target, flags))
|
||||
}
|
||||
|
||||
/// What the drive mount call is given.
|
||||
///
|
||||
/// # No filesystem-specific options, and that is a decision
|
||||
///
|
||||
/// `commit=` and `barrier=` were here once and the mount failed outright:
|
||||
/// *"can't mount with commit=, fs mounted w/o journal"*, `EINVAL`, and a box
|
||||
/// that refused its own descriptor before the session started. Both options
|
||||
/// only mean anything to a journal, and a build volume is made without one --
|
||||
/// what it holds is one game, re-downloadable, mounted by a clone that is
|
||||
/// destroyed with the box. Anything added here has to be an option that is
|
||||
/// still true of a journal-less ext4.
|
||||
///
|
||||
/// `noatime` stays: a game reading its own install has no use for access
|
||||
/// times, and writing them turns every read of a clone into a write. It is not
|
||||
/// paired with `nodiratime`, which it already implies.
|
||||
///
|
||||
/// # nosuid and nodev, for the same reason every share has them
|
||||
///
|
||||
/// What this mounts is the least trusted thing in the box: files a CDN handed
|
||||
/// us, checked for the bytes the manifest named and for nothing about what
|
||||
/// those bytes are. A setuid binary or a device node inside a depot is not
|
||||
/// something a workload should be able to use, and no descriptor has a way to
|
||||
/// ask for one.
|
||||
///
|
||||
/// **Not `noexec`.** The game's own executable is on this volume and the whole
|
||||
/// point is to run it.
|
||||
fn options_drive(drive: &Drive) -> Result<(CString, CString, libc::c_ulong), Failure> {
|
||||
let flags = libc::MS_NOSUID | libc::MS_NODEV | libc::MS_NOATIME;
|
||||
|
||||
let source = CString::new(drive.dev.as_str()).map_err(|_| {
|
||||
Failure::new(format!(
|
||||
"the drive device contains a nul byte: {:?}",
|
||||
drive.dev
|
||||
))
|
||||
})?;
|
||||
let target = CString::new(drive.at.as_str()).map_err(|_| {
|
||||
Failure::new(format!(
|
||||
"the drive mount point contains a nul byte: {:?}",
|
||||
drive.at
|
||||
))
|
||||
})?;
|
||||
Ok((source, target, flags))
|
||||
}
|
||||
|
||||
/// A failure names the path, which is what makes it actionable: a permission
|
||||
/// error and the directory it happened on can be acted on, where "the share
|
||||
/// did not mount" cannot.
|
||||
@@ -367,6 +451,10 @@ fn failed(share: &Mount, error: io::Error) -> Failure {
|
||||
Failure::new(format!("{}: {error}", share.at))
|
||||
}
|
||||
|
||||
fn failed_drive(drive: &Drive, error: io::Error) -> Failure {
|
||||
Failure::new(format!("{}: {error}", drive.at))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -490,6 +578,30 @@ mod tests {
|
||||
assert_eq!(flags & libc::MS_RDONLY, 0);
|
||||
}
|
||||
|
||||
/// The drive carries the same guard every share carries.
|
||||
///
|
||||
/// It is the mount that most needs it: a share is a directory this host
|
||||
/// prepared, and a drive is a filesystem built out of whatever a CDN sent.
|
||||
#[test]
|
||||
fn a_drive_is_mounted_without_devices_or_setuid_but_can_still_execute() {
|
||||
let drive = Drive {
|
||||
dev: "/dev/vdb".into(),
|
||||
at: "/nestri/install".into(),
|
||||
};
|
||||
let (source, target, flags) = options_drive(&drive).unwrap();
|
||||
assert_eq!(
|
||||
source.to_str().unwrap(),
|
||||
"/dev/vdb",
|
||||
"the device is the source"
|
||||
);
|
||||
assert_eq!(target.to_str().unwrap(), "/nestri/install");
|
||||
assert_eq!(flags & libc::MS_NOSUID, libc::MS_NOSUID);
|
||||
assert_eq!(flags & libc::MS_NODEV, libc::MS_NODEV);
|
||||
assert_eq!(flags & libc::MS_NOATIME, libc::MS_NOATIME);
|
||||
// The game's executable lives here.
|
||||
assert_eq!(flags & libc::MS_NOEXEC, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_read_only_share_is_mounted_read_only() {
|
||||
let (_, _, flags) = options(&share(true)).unwrap();
|
||||
@@ -540,6 +652,7 @@ pub mod double {
|
||||
/// only thing under test.
|
||||
pub struct Double {
|
||||
pub mounted: Vec<Vec<Mount>>,
|
||||
pub drives: Vec<Vec<Drive>>,
|
||||
pub started: Vec<Exec>,
|
||||
pub stops: usize,
|
||||
pub mount_failure: Option<Failure>,
|
||||
@@ -563,6 +676,7 @@ pub mod double {
|
||||
fn new(exit: Exit, holds_until_stopped: bool) -> Self {
|
||||
Self {
|
||||
mounted: Vec::new(),
|
||||
drives: Vec::new(),
|
||||
started: Vec::new(),
|
||||
stops: 0,
|
||||
mount_failure: None,
|
||||
@@ -583,6 +697,14 @@ pub mod double {
|
||||
}
|
||||
}
|
||||
|
||||
fn mount_drives(&mut self, drives: &[Drive]) -> Result<(), Failure> {
|
||||
self.drives.push(drives.to_vec());
|
||||
match &self.mount_failure {
|
||||
Some(failure) => Err(failure.clone()),
|
||||
None => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
fn start(&mut self, exec: &Exec) -> Result<Exited, Failure> {
|
||||
self.started.push(exec.clone());
|
||||
if let Some(failure) = &self.start_failure {
|
||||
|
||||
Reference in New Issue
Block a user