mirror of
https://github.com/nestriness/nestri.git
synced 2026-09-27 21:12:25 +03:00
feat: media bitrate control, HDR (#346)
Fixes: #335 Still a work-in-progress. --------- Co-authored-by: DatCaptainHorse <DatCaptainHorse@users.noreply.github.com> Co-authored-by: Claude Opus 5 <noreply@anthropic.com> Co-authored-by: Wanjohi <elviswanjohi47@gmail.com>
This commit is contained in:
co-authored by
DatCaptainHorse
Claude Opus 5
Wanjohi
parent
1c721962f4
commit
0811f57f1a
@@ -14,6 +14,7 @@
|
||||
|
||||
pub mod filesystems;
|
||||
pub mod payload;
|
||||
pub mod platform;
|
||||
pub mod reap;
|
||||
pub mod services;
|
||||
pub mod session;
|
||||
|
||||
@@ -45,6 +45,7 @@ fn main() -> anyhow::Result<()> {
|
||||
// nothing else in this guest is an init system, so until this runs there
|
||||
// is no `/proc` to score this process in and nowhere to put a socket.
|
||||
nesinit::filesystems::establish();
|
||||
nesinit::platform::describe();
|
||||
|
||||
// Everything a distribution's init scripts used to do, and nothing else is
|
||||
// going to: a hostname, the box's address, the directories a session's
|
||||
@@ -80,6 +81,7 @@ fn main() -> anyhow::Result<()> {
|
||||
Ok(outcome) => tracing::info!(?outcome, "the session ended"),
|
||||
Err(error) => tracing::error!(%error, "the session failed"),
|
||||
}
|
||||
nesinit::platform::report_steal();
|
||||
|
||||
// Before anything below waits on a pid: the reaper runs on this runtime's
|
||||
// threads, and two things calling `wait` is what the registry exists to
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
// What the kernel under this box actually chose, said once, at debug.
|
||||
//
|
||||
// Several of the settings that decide a box's latency are not decided by the
|
||||
// image. The clocksource is picked at boot and can be demoted by a watchdog,
|
||||
// the idle driver loads only when the host or the command line asks for it,
|
||||
// and the preemption model is a boot parameter. None of them is visible from
|
||||
// outside, and a box has no shell to ask with, so this reads them from sysfs
|
||||
// and procfs and logs them. Enable with `RUST_LOG=nesinit=debug` on the kernel
|
||||
// command line.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
/// Clocksources a vDSO can read without entering the kernel.
|
||||
///
|
||||
/// Anything else makes every `clock_gettime` a syscall, and in a guest an
|
||||
/// emulated one: `hpet` is an MMIO read the host has to trap. A Windows game
|
||||
/// polls its performance counter constantly, so that is a cost paid thousands
|
||||
/// of times a frame, and nothing reports it.
|
||||
const VDSO_CLOCKSOURCES: &[&str] = &["tsc", "kvm-clock"];
|
||||
|
||||
/// Log the kernel's timing and idle choices. Needs `/proc` and `/sys`.
|
||||
pub fn describe() {
|
||||
let clocksource = read("/sys/devices/system/clocksource/clocksource0/current_clocksource");
|
||||
let cmdline = read("/proc/cmdline").unwrap_or_default();
|
||||
|
||||
tracing::debug!(
|
||||
clocksource = clocksource.as_deref().unwrap_or("unknown"),
|
||||
available = read("/sys/devices/system/clocksource/clocksource0/available_clocksource")
|
||||
.as_deref()
|
||||
.unwrap_or("unknown"),
|
||||
idle_driver = read("/sys/devices/system/cpu/cpuidle/current_driver")
|
||||
.as_deref()
|
||||
.unwrap_or("none"),
|
||||
idle_governor = read("/sys/devices/system/cpu/cpuidle/current_governor_ro")
|
||||
.as_deref()
|
||||
.unwrap_or("none"),
|
||||
// Present only while the haltpoll governor is built in, and only
|
||||
// meaningful while it is the governor in use.
|
||||
halt_poll_ns = read("/sys/module/haltpoll/parameters/guest_halt_poll_ns")
|
||||
.as_deref()
|
||||
.unwrap_or("n/a"),
|
||||
// The build's default when absent. The mode actually in effect is only
|
||||
// readable through debugfs, which this kernel does not have.
|
||||
preempt = kernel_parameter(&cmdline, "preempt").unwrap_or("default"),
|
||||
cpus = std::thread::available_parallelism().map_or(0, usize::from),
|
||||
kernel = read("/proc/sys/kernel/version")
|
||||
.as_deref()
|
||||
.unwrap_or("unknown"),
|
||||
"platform"
|
||||
);
|
||||
|
||||
if let Some(source) = clocksource.as_deref()
|
||||
&& !VDSO_CLOCKSOURCES.contains(&source)
|
||||
{
|
||||
tracing::warn!(
|
||||
clocksource = source,
|
||||
"every clock read in this box is a syscall; the kernel did not trust a faster clock"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Log how much of this box's CPU time the host took back. Call once, at the
|
||||
/// end of a session: the counters are cumulative since boot.
|
||||
pub fn report_steal() {
|
||||
let Some(stat) = read("/proc/stat") else {
|
||||
return;
|
||||
};
|
||||
let Some((steal, total)) = steal_of(&stat) else {
|
||||
return;
|
||||
};
|
||||
// Parts per thousand, so the log carries an integer and no float
|
||||
// formatting decides how small a number reads as zero.
|
||||
let permille = (steal * 1000).checked_div(total).unwrap_or(0);
|
||||
tracing::debug!(
|
||||
steal_ticks = steal,
|
||||
total_ticks = total,
|
||||
permille,
|
||||
"cpu time stolen by the host"
|
||||
);
|
||||
}
|
||||
|
||||
/// Steal and total ticks from the aggregate `cpu` line of `/proc/stat`.
|
||||
///
|
||||
/// Columns are user, nice, system, idle, iowait, irq, softirq, steal, and then
|
||||
/// guest time, which the kernel already counts inside user and nice. Summing
|
||||
/// past steal would count it twice.
|
||||
fn steal_of(stat: &str) -> Option<(u64, u64)> {
|
||||
let line = stat.lines().find(|l| l.starts_with("cpu "))?;
|
||||
let fields: Vec<u64> = line
|
||||
.split_whitespace()
|
||||
.skip(1)
|
||||
.take(8)
|
||||
.map(str::parse)
|
||||
.collect::<Result<_, _>>()
|
||||
.ok()?;
|
||||
let steal = *fields.get(7)?;
|
||||
Some((steal, fields.iter().sum()))
|
||||
}
|
||||
|
||||
/// The value of one bare `name=value` kernel parameter.
|
||||
fn kernel_parameter<'a>(cmdline: &'a str, name: &str) -> Option<&'a str> {
|
||||
cmdline
|
||||
.split_whitespace()
|
||||
.find_map(|word| word.strip_prefix(name)?.strip_prefix('='))
|
||||
.filter(|value| !value.is_empty())
|
||||
}
|
||||
|
||||
fn read(path: impl AsRef<Path>) -> Option<String> {
|
||||
std::fs::read_to_string(path)
|
||||
.ok()
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_parameter_is_found_by_its_whole_name() {
|
||||
let cmdline = "cpuidle_haltpoll.force=1 console=hvc0 preempt=full ro";
|
||||
assert_eq!(kernel_parameter(cmdline, "preempt"), Some("full"));
|
||||
assert_eq!(
|
||||
kernel_parameter(cmdline, "cpuidle_haltpoll.force"),
|
||||
Some("1")
|
||||
);
|
||||
}
|
||||
|
||||
/// `preempt` must not match `preempt_foo=`, which is a different parameter.
|
||||
#[test]
|
||||
fn a_longer_name_sharing_the_prefix_is_not_a_match() {
|
||||
assert_eq!(kernel_parameter("preempt_foo=x", "preempt"), None);
|
||||
assert_eq!(kernel_parameter("console=hvc0", "preempt"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn steal_is_the_eighth_column_and_guest_time_is_not_counted_twice() {
|
||||
// user nice system idle iowait irq softirq steal guest guest_nice
|
||||
let stat = "cpu 100 0 50 800 10 5 5 30 999 999\ncpu0 1 2 3 4 5 6 7 8 9 10\n";
|
||||
assert_eq!(steal_of(stat), Some((30, 1000)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_stat_without_a_cpu_line_reports_nothing() {
|
||||
assert_eq!(steal_of("intr 1 2 3\n"), None);
|
||||
assert_eq!(steal_of("cpu 1 2 3\n"), None);
|
||||
}
|
||||
}
|
||||
@@ -41,6 +41,7 @@ use tokio::sync::mpsc::{Receiver, Sender};
|
||||
|
||||
use crate::reap::{Waiters, Watched};
|
||||
use crate::workload::Failure;
|
||||
use nesprotocol::lifecycle::VideoLimits;
|
||||
|
||||
/// A service that died, and how.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
@@ -60,7 +61,13 @@ pub trait Services {
|
||||
/// Called once, after the shares are mounted and before anything may be
|
||||
/// launched. An empty stack is legitimate: a box with no services still
|
||||
/// boots, and a caller can still launch something that needs none.
|
||||
fn bring_up(&mut self) -> Result<Vec<String>, Failure>;
|
||||
///
|
||||
/// `video` comes from the descriptor and reaches the services that read it.
|
||||
/// It has to arrive here rather than later because a service configured
|
||||
/// after it is already running has a window in which it is not configured,
|
||||
/// and for a bitrate ceiling that window is a session streaming at whatever
|
||||
/// default it started with.
|
||||
fn bring_up(&mut self, video: VideoLimits) -> Result<Vec<String>, Failure>;
|
||||
|
||||
/// Deaths, as they happen.
|
||||
///
|
||||
@@ -160,6 +167,17 @@ pub const SERVICE_UID: u32 = 1000;
|
||||
/// here: the directory belongs to the service user and is not writable by the
|
||||
/// workload, which is the property [`crate::ticket::Untrusted`] depends on.
|
||||
pub const AUDIO_DIR: &str = "/run/pipewire";
|
||||
|
||||
/// Where the PulseAudio protocol is served, in [`AUDIO_DIR`] for the reason
|
||||
/// audio's own socket is.
|
||||
///
|
||||
/// pipewire-pulse is told this path by its configuration file in the image,
|
||||
/// which is not something this program can pass it, so the two are compared by
|
||||
/// a test rather than trusted to agree.
|
||||
pub const PULSE_SOCKET: &str = "/run/pipewire/pulse-native";
|
||||
|
||||
/// [`PULSE_SOCKET`] as a PulseAudio client is told it.
|
||||
pub const PULSE_SERVER: &str = "unix:/run/pipewire/pulse-native";
|
||||
pub const SERVICE_GID: u32 = 1000;
|
||||
|
||||
/// Where a service's runtime sockets live.
|
||||
@@ -273,6 +291,26 @@ pub const STACK: &[Service] = &[
|
||||
umask: None,
|
||||
ready: None,
|
||||
},
|
||||
Service {
|
||||
name: "pipewire-pulse",
|
||||
argv: &["/usr/bin/pipewire-pulse"],
|
||||
env: &[
|
||||
("XDG_RUNTIME_DIR", RUNTIME_DIR),
|
||||
("PIPEWIRE_RUNTIME_DIR", AUDIO_DIR),
|
||||
("DBUS_SESSION_BUS_ADDRESS", "unix:path=/run/user/1000/bus"),
|
||||
],
|
||||
user: Some((SERVICE_UID, SERVICE_GID)),
|
||||
// Optional for the reason the sender is: everything that speaks
|
||||
// PipeWire itself is unaffected, and a session with sound missing is
|
||||
// degraded rather than unusable.
|
||||
cost: "anything that only speaks PulseAudio plays silently, and Wine is one",
|
||||
required: false,
|
||||
// The workload is its client, and is not this user.
|
||||
umask: Some(0),
|
||||
// Nothing in this table connects to it, but the workload does, and the
|
||||
// workload is started after the table.
|
||||
ready: Some(PULSE_SOCKET),
|
||||
},
|
||||
Service {
|
||||
name: "neswire",
|
||||
argv: &["/usr/bin/neswire"],
|
||||
@@ -308,6 +346,13 @@ pub struct Stack {
|
||||
running: Vec<(&'static str, Watched)>,
|
||||
deaths: Receiver<Died>,
|
||||
reported: Sender<Died>,
|
||||
/// What the host said this box may spend on video, from the descriptor.
|
||||
///
|
||||
/// Held here because `spawn` is where it reaches a service, and `spawn`
|
||||
/// takes a `&'static Service` whose `env` is a fixed table -- a value that
|
||||
/// arrives at runtime has no route through it otherwise. The same problem
|
||||
/// `RUST_LOG` has, solved the same way.
|
||||
video: VideoLimits,
|
||||
}
|
||||
|
||||
impl Stack {
|
||||
@@ -328,6 +373,7 @@ impl Stack {
|
||||
running: Vec::new(),
|
||||
deaths,
|
||||
reported,
|
||||
video: VideoLimits::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -390,6 +436,15 @@ impl Stack {
|
||||
if let Ok(filter) = std::env::var("RUST_LOG") {
|
||||
command.env("RUST_LOG", filter);
|
||||
}
|
||||
// The descriptor's video limits, for the services that read them. Same
|
||||
// shape of problem as `RUST_LOG` above -- `env_clear` drops everything
|
||||
// and the service table is a fixed list of literals, so a value that
|
||||
// only exists at runtime has no other route in. `neshub` reads this
|
||||
// through the clap `env =` attribute it already uses for every other
|
||||
// setting.
|
||||
if let Some(kbps) = self.video.bitrate_kbps {
|
||||
command.env("NESTRI_MAX_BITRATE", kbps.to_string());
|
||||
}
|
||||
// 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());
|
||||
@@ -451,7 +506,8 @@ impl Stack {
|
||||
}
|
||||
|
||||
impl Services for Stack {
|
||||
fn bring_up(&mut self) -> Result<Vec<String>, Failure> {
|
||||
fn bring_up(&mut self, video: VideoLimits) -> Result<Vec<String>, Failure> {
|
||||
self.video = video;
|
||||
let mut up = Vec::new();
|
||||
// Lifted out so the loop does not hold a borrow of `self` across the
|
||||
// start it is asking for.
|
||||
@@ -604,6 +660,9 @@ pub mod double {
|
||||
/// only thing under test.
|
||||
pub struct Double {
|
||||
pub brought_up: usize,
|
||||
/// What the last `bring_up` was told, so a test can assert the limits
|
||||
/// reached the stack rather than assuming they did.
|
||||
pub video: VideoLimits,
|
||||
pub failure: Option<Failure>,
|
||||
pub names: Vec<String>,
|
||||
deaths: Receiver<Died>,
|
||||
@@ -625,6 +684,7 @@ pub mod double {
|
||||
names: vec!["dbus-system".into(), "neshub".into()],
|
||||
deaths,
|
||||
report,
|
||||
video: VideoLimits::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -637,8 +697,9 @@ pub mod double {
|
||||
}
|
||||
|
||||
impl Services for Double {
|
||||
fn bring_up(&mut self) -> Result<Vec<String>, Failure> {
|
||||
fn bring_up(&mut self, video: VideoLimits) -> Result<Vec<String>, Failure> {
|
||||
self.brought_up += 1;
|
||||
self.video = video;
|
||||
match &self.failure {
|
||||
Some(failure) => Err(failure.clone()),
|
||||
None => Ok(self.names.clone()),
|
||||
@@ -853,14 +914,42 @@ mod tests {
|
||||
/// owner -- and the workload is not the owner.
|
||||
#[test]
|
||||
fn the_audio_socket_is_reachable_by_a_user_who_does_not_own_it() {
|
||||
let pipewire = STACK
|
||||
for name in ["pipewire", "pipewire-pulse"] {
|
||||
let service = STACK
|
||||
.iter()
|
||||
.find(|s| s.name == name)
|
||||
.unwrap_or_else(|| panic!("{name} is in the table"));
|
||||
assert_eq!(
|
||||
service.umask,
|
||||
Some(0),
|
||||
"with any other umask the game finds {name}'s socket and cannot open it"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The PulseAudio socket's path is written three times: here, in the
|
||||
/// client address the workload is given, and in pipewire-pulse's own
|
||||
/// configuration in the image. If any of them moves on its own, the game
|
||||
/// finds no server and plays silently, and nothing fails.
|
||||
#[test]
|
||||
fn pulse_is_served_where_the_workload_is_told_to_look() {
|
||||
assert!(
|
||||
PULSE_SOCKET.starts_with(AUDIO_DIR),
|
||||
"the workload can only reach sockets in {AUDIO_DIR}"
|
||||
);
|
||||
assert_eq!(PULSE_SERVER, format!("unix:{PULSE_SOCKET}"));
|
||||
|
||||
let pulse = STACK
|
||||
.iter()
|
||||
.find(|s| s.name == "pipewire")
|
||||
.expect("audio is in the table");
|
||||
assert_eq!(
|
||||
pipewire.umask,
|
||||
Some(0),
|
||||
"with any other umask the game finds the socket and cannot open it"
|
||||
.find(|s| s.name == "pipewire-pulse")
|
||||
.expect("pulse is in the table");
|
||||
assert_eq!(pulse.ready, Some(PULSE_SOCKET));
|
||||
|
||||
let config =
|
||||
include_str!("../../../build/etc/pipewire/pipewire-pulse.conf.d/50-nestri.conf");
|
||||
assert!(
|
||||
config.contains(&format!("\"{PULSE_SERVER}\"")),
|
||||
"pipewire-pulse is configured to listen somewhere other than {PULSE_SOCKET}"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -266,19 +266,19 @@ where
|
||||
}
|
||||
booted = true;
|
||||
|
||||
// The drives, then the shares, then the services.
|
||||
// The overlays, 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
|
||||
// **Overlays first, and one `Mounted` between them.** An
|
||||
// overlay 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) {
|
||||
if let Err(failure) = workload.mount_overlays(&descriptor.overlays) {
|
||||
// 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
|
||||
// stopped and has to guess between an overlay, a share and a
|
||||
// service -- which is the whole reason these are reported
|
||||
// separately.
|
||||
send(
|
||||
@@ -311,7 +311,7 @@ where
|
||||
// A box whose own services will not come up cannot be launched
|
||||
// into, so this is refused rather than reported and carried on
|
||||
// from — unlike a launch, which is the caller's to correct.
|
||||
match services.bring_up() {
|
||||
match services.bring_up(descriptor.video) {
|
||||
Ok(up) => {
|
||||
tracing::info!(services = up.len(), "the box is ready to be launched into");
|
||||
send(&mut writer, &GuestToHost::Initialized { services: up }).await?
|
||||
@@ -516,7 +516,8 @@ mod tests {
|
||||
at: "/mnt/user".into(),
|
||||
ro: false,
|
||||
}],
|
||||
drives: Vec::new(),
|
||||
overlays: Vec::new(),
|
||||
video: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -715,6 +716,53 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn the_descriptors_video_limits_reach_the_services() {
|
||||
// The ceiling is useless if it stops at the descriptor. `neshub` is the
|
||||
// only thing that can enforce it and it is a service, so the number has
|
||||
// to survive the whole way from the boot document to the spawn.
|
||||
let (guest, host) = tokio::io::duplex(4096);
|
||||
let mut caller = Caller::new(host);
|
||||
let session = spawn(guest, Given::new(Double::exits_when_stopped(Exit::code(0))));
|
||||
|
||||
let mut given = descriptor();
|
||||
given.video.bitrate_kbps = Some(8_000);
|
||||
|
||||
caller.expect_ready().await;
|
||||
caller
|
||||
.say(&HostToGuest::Boot {
|
||||
descriptor: Box::new(given),
|
||||
})
|
||||
.await;
|
||||
caller.expect_booted().await;
|
||||
caller.say(&HostToGuest::Shutdown).await;
|
||||
|
||||
let (_, _, services) = session.await.unwrap();
|
||||
assert_eq!(services.video.bitrate_kbps, Some(8_000));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_box_told_nothing_about_video_says_so_rather_than_inventing_a_limit() {
|
||||
// "Unsaid" must not arrive as a number. A stack that cannot tell the two
|
||||
// apart cannot log that it was never told, and a ceiling nobody set is
|
||||
// exactly how every session came to offer 10 Mbps.
|
||||
let (guest, host) = tokio::io::duplex(4096);
|
||||
let mut caller = Caller::new(host);
|
||||
let session = spawn(guest, Given::new(Double::exits_when_stopped(Exit::code(0))));
|
||||
|
||||
caller.expect_ready().await;
|
||||
caller
|
||||
.say(&HostToGuest::Boot {
|
||||
descriptor: Box::new(descriptor()),
|
||||
})
|
||||
.await;
|
||||
caller.expect_booted().await;
|
||||
caller.say(&HostToGuest::Shutdown).await;
|
||||
|
||||
let (_, _, services) = session.await.unwrap();
|
||||
assert_eq!(services.video.bitrate_kbps, None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_launch_runs_what_it_names_and_is_reported_by_its_id() {
|
||||
let (guest, host) = tokio::io::duplex(4096);
|
||||
|
||||
+271
-81
@@ -11,7 +11,7 @@ use std::future::Future;
|
||||
use std::io;
|
||||
use std::pin::Pin;
|
||||
|
||||
use nesprotocol::lifecycle::{Drive, Exec, Exit, Mount};
|
||||
use nesprotocol::lifecycle::{Exec, Exit, Mount, Overlay};
|
||||
|
||||
use std::os::unix::process::CommandExt;
|
||||
|
||||
@@ -41,8 +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>;
|
||||
/// Stack each overlay the descriptor names: its build image, its writable
|
||||
/// layer, and the two together where it says.
|
||||
fn mount_overlays(&mut self, overlays: &[Overlay]) -> Result<(), Failure>;
|
||||
|
||||
/// Start the command the descriptor names.
|
||||
///
|
||||
@@ -132,9 +133,9 @@ impl Workload for Process {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn mount_drives(&mut self, drives: &[Drive]) -> Result<(), Failure> {
|
||||
for drive in drives {
|
||||
mount_drive(drive)?;
|
||||
fn mount_overlays(&mut self, overlays: &[Overlay]) -> Result<(), Failure> {
|
||||
for overlay in overlays {
|
||||
mount_overlay(overlay)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -302,6 +303,9 @@ const GRAPHICS: &[(&str, &str)] = &[
|
||||
// reach and both are told where. Without this a game renders and plays
|
||||
// silently, having looked under its own uid and found nothing.
|
||||
("PIPEWIRE_RUNTIME_DIR", crate::services::AUDIO_DIR),
|
||||
// The same, for a client that speaks PulseAudio instead. It does not read
|
||||
// the variable above, and its default is under its own runtime directory.
|
||||
("PULSE_SERVER", crate::services::PULSE_SERVER),
|
||||
];
|
||||
|
||||
/// Mount one share where the descriptor says to put it.
|
||||
@@ -340,35 +344,104 @@ 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)?;
|
||||
/// Stack one overlay: the build image, the box's writable layer, and the two
|
||||
/// together at `at`.
|
||||
///
|
||||
/// Each step's failure names the step, because "the install did not mount"
|
||||
/// has three different causes and each one is fixed somewhere else: a build
|
||||
/// image the kernel cannot read, an upper layer that was never formatted, and
|
||||
/// an overlay the kernel refused.
|
||||
fn mount_overlay(overlay: &Overlay) -> Result<(), Failure> {
|
||||
// Everything that can be refused without touching the filesystem is
|
||||
// refused first, so a descriptor this cannot act on leaves nothing behind.
|
||||
let plan = OverlayPlan::new(overlay)?;
|
||||
|
||||
// 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))?;
|
||||
mount_one(
|
||||
&plan.lower,
|
||||
&plan.lower_at,
|
||||
c"erofs",
|
||||
plan.lower_flags,
|
||||
None,
|
||||
)
|
||||
.map_err(|error| plan.failed("the build image", &plan.lower_at, error))?;
|
||||
mount_one(&plan.upper, &plan.rw_at, c"ext4", plan.upper_flags, None)
|
||||
.map_err(|error| plan.failed("the writable layer", &plan.rw_at, error))?;
|
||||
|
||||
// SAFETY: mount takes two paths, a filesystem name and a flag word, all
|
||||
// of which outlive the call, and no options string.
|
||||
for dir in [&plan.upper_dir, &plan.work_dir] {
|
||||
std::fs::create_dir_all(as_path(dir))
|
||||
.map_err(|error| plan.failed("the writable layer", dir, error))?;
|
||||
}
|
||||
// **The upper directory takes the build's root ownership.** overlayfs
|
||||
// shows a merged directory with the attributes of its upper half when it
|
||||
// has one, and this one always does -- so an upper directory this init
|
||||
// created, `root:root 0755`, would make the install's top directory
|
||||
// unwritable to the workload, whatever the build image says. Copied from
|
||||
// the lower root rather than named here: which uid the workload runs as is
|
||||
// the host's decision, and the host already made it when it packed the
|
||||
// image.
|
||||
let (uid, gid, mode) = ownership(&plan.lower_at)
|
||||
.map_err(|error| plan.failed("the build image", &plan.lower_at, error))?;
|
||||
set_ownership(&plan.upper_dir, uid, gid, mode)
|
||||
.map_err(|error| plan.failed("the writable layer", &plan.upper_dir, error))?;
|
||||
|
||||
mount_one(
|
||||
c"overlay",
|
||||
&plan.at,
|
||||
c"overlay",
|
||||
plan.overlay_flags,
|
||||
Some(&plan.overlay_data),
|
||||
)
|
||||
.map_err(|error| plan.failed("the overlay", &plan.at, error))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Mount one filesystem, creating its mount point first.
|
||||
fn mount_one(
|
||||
source: &std::ffi::CStr,
|
||||
target: &std::ffi::CStr,
|
||||
fstype: &std::ffi::CStr,
|
||||
flags: libc::c_ulong,
|
||||
data: Option<&std::ffi::CStr>,
|
||||
) -> io::Result<()> {
|
||||
// The mount point may not exist yet: the descriptor can name anywhere,
|
||||
// including a directory no image created.
|
||||
std::fs::create_dir_all(as_path(target))?;
|
||||
// SAFETY: every pointer is to a nul-terminated string that outlives the
|
||||
// call, and a null data pointer is what mount(2) takes for "no options".
|
||||
let mounted = unsafe {
|
||||
libc::mount(
|
||||
source.as_ptr(),
|
||||
target.as_ptr(),
|
||||
FSTYPE_DRIVE.as_ptr(),
|
||||
fstype.as_ptr(),
|
||||
flags,
|
||||
std::ptr::null(),
|
||||
data.map_or(std::ptr::null(), |d| d.as_ptr().cast()),
|
||||
)
|
||||
};
|
||||
if mounted != 0 {
|
||||
return Err(failed_drive(drive, io::Error::last_os_error()));
|
||||
return Err(io::Error::last_os_error());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
const FSTYPE_DRIVE: &std::ffi::CStr = c"ext4";
|
||||
/// The same bytes, as a path: lossless, where a round trip through `str` is
|
||||
/// not.
|
||||
fn as_path(path: &std::ffi::CStr) -> &std::path::Path {
|
||||
use std::os::unix::ffi::OsStrExt;
|
||||
std::path::Path::new(std::ffi::OsStr::from_bytes(path.to_bytes()))
|
||||
}
|
||||
|
||||
fn ownership(path: &std::ffi::CStr) -> io::Result<(u32, u32, u32)> {
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
let meta = std::fs::metadata(as_path(path))?;
|
||||
Ok((meta.uid(), meta.gid(), meta.mode() & 0o7777))
|
||||
}
|
||||
|
||||
fn set_ownership(path: &std::ffi::CStr, uid: u32, gid: u32, mode: u32) -> io::Result<()> {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let path = as_path(path);
|
||||
std::os::unix::fs::chown(path, Some(uid), Some(gid))?;
|
||||
std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode))
|
||||
}
|
||||
|
||||
/// What the mount call is given, split out because this is the part worth
|
||||
/// asserting: mounting itself needs privileges a test does not have.
|
||||
@@ -400,48 +473,114 @@ fn options(share: &Mount) -> Result<(CString, CString, libc::c_ulong), Failure>
|
||||
Ok((source, target, flags))
|
||||
}
|
||||
|
||||
/// What the drive mount call is given.
|
||||
/// Everything an overlay's three mounts are given, worked out before any of
|
||||
/// them is attempted.
|
||||
///
|
||||
/// # No filesystem-specific options, and that is a decision
|
||||
/// Split out because this is the part worth asserting: mounting needs
|
||||
/// privileges a test does not have.
|
||||
///
|
||||
/// `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.
|
||||
/// # Where the layers go
|
||||
///
|
||||
/// `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.
|
||||
/// Beside the overlay, in a hidden directory named after it:
|
||||
/// `/nestri/install` stacks `/nestri/.install/lower` under
|
||||
/// `/nestri/.install/rw/upper`. Beside rather than under, because anything
|
||||
/// mounted under `at` is covered the moment the overlay is mounted over it.
|
||||
///
|
||||
/// # nosuid and nodev, for the same reason every share has them
|
||||
/// # Flags
|
||||
///
|
||||
/// 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.
|
||||
/// `nosuid` and `nodev` on every layer and on the result, for the reason every
|
||||
/// share has them: what is stacked here is files a CDN handed us, checked for
|
||||
/// the bytes the manifest named and for nothing about what those bytes are.
|
||||
/// **Not `noexec`** anywhere: the game's executable is in the build.
|
||||
///
|
||||
/// **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;
|
||||
/// `noatime` on the writable layer and the overlay, so a game reading its own
|
||||
/// install does not turn every read into a write. The build image is mounted
|
||||
/// read-only and has no access times to write.
|
||||
///
|
||||
/// No filesystem-specific options on the upper layer: `commit=` and
|
||||
/// `barrier=` were once passed to a journal-less ext4 and the mount failed
|
||||
/// outright with `EINVAL`.
|
||||
#[derive(Debug)]
|
||||
struct OverlayPlan {
|
||||
lower: CString,
|
||||
upper: CString,
|
||||
at: CString,
|
||||
lower_at: CString,
|
||||
rw_at: CString,
|
||||
upper_dir: CString,
|
||||
work_dir: CString,
|
||||
lower_flags: libc::c_ulong,
|
||||
upper_flags: libc::c_ulong,
|
||||
overlay_flags: libc::c_ulong,
|
||||
overlay_data: CString,
|
||||
}
|
||||
|
||||
let source = CString::new(drive.dev.as_str()).map_err(|_| {
|
||||
impl OverlayPlan {
|
||||
fn new(overlay: &Overlay) -> Result<Self, Failure> {
|
||||
let at = std::path::Path::new(&overlay.at);
|
||||
let (Some(parent), Some(name)) = (at.parent(), at.file_name()) else {
|
||||
return Err(Failure::new(format!(
|
||||
"the overlay mount point has no parent to put its layers beside: {:?}",
|
||||
overlay.at
|
||||
)));
|
||||
};
|
||||
let layers = parent.join(format!(".{}", name.to_string_lossy()));
|
||||
let lower_at = layers.join("lower");
|
||||
let rw_at = layers.join("rw");
|
||||
let upper_dir = rw_at.join("upper");
|
||||
let work_dir = rw_at.join("work");
|
||||
|
||||
// overlayfs splits its options on commas and its layer lists on
|
||||
// colons, and has no escape for either that this should rely on. A
|
||||
// path carrying one would mount a different directory than the one
|
||||
// named, so it is refused.
|
||||
for path in [&lower_at, &upper_dir, &work_dir] {
|
||||
let text = path.to_string_lossy();
|
||||
if text.contains([',', ':']) {
|
||||
return Err(Failure::new(format!(
|
||||
"the overlay mount point cannot carry a comma or a colon: {:?}",
|
||||
overlay.at
|
||||
)));
|
||||
}
|
||||
}
|
||||
let data = format!(
|
||||
"lowerdir={},upperdir={},workdir={}",
|
||||
lower_at.display(),
|
||||
upper_dir.display(),
|
||||
work_dir.display()
|
||||
);
|
||||
|
||||
let common = libc::MS_NOSUID | libc::MS_NODEV;
|
||||
Ok(Self {
|
||||
lower: c_string(&overlay.lower, "the build image device")?,
|
||||
upper: c_string(&overlay.upper, "the writable layer device")?,
|
||||
at: c_string(&overlay.at, "the overlay mount point")?,
|
||||
lower_at: c_string(&lower_at.to_string_lossy(), "the overlay mount point")?,
|
||||
rw_at: c_string(&rw_at.to_string_lossy(), "the overlay mount point")?,
|
||||
upper_dir: c_string(&upper_dir.to_string_lossy(), "the overlay mount point")?,
|
||||
work_dir: c_string(&work_dir.to_string_lossy(), "the overlay mount point")?,
|
||||
lower_flags: common | libc::MS_RDONLY,
|
||||
upper_flags: common | libc::MS_NOATIME,
|
||||
overlay_flags: common | libc::MS_NOATIME,
|
||||
overlay_data: c_string(&data, "the overlay mount point")?,
|
||||
})
|
||||
}
|
||||
|
||||
/// Which step failed, on which path, in the operating system's words.
|
||||
fn failed(&self, step: &str, path: &std::ffi::CStr, error: io::Error) -> Failure {
|
||||
Failure::new(format!(
|
||||
"the drive device contains a nul byte: {:?}",
|
||||
drive.dev
|
||||
"{}: {step}: {}: {error}",
|
||||
self.at.to_string_lossy(),
|
||||
path.to_string_lossy()
|
||||
))
|
||||
})?;
|
||||
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 nul byte inside a path is a descriptor that cannot be carried out under
|
||||
/// any flags. Refused by name rather than silently emptied: an empty path turns
|
||||
/// up later as a mount failure about something else entirely.
|
||||
fn c_string(text: &str, what: &str) -> Result<CString, Failure> {
|
||||
CString::new(text).map_err(|_| Failure::new(format!("{what} contains a nul byte: {text:?}")))
|
||||
}
|
||||
|
||||
/// A failure names the path, which is what makes it actionable: a permission
|
||||
@@ -451,10 +590,6 @@ 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::*;
|
||||
@@ -578,28 +713,83 @@ 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.
|
||||
fn overlay(at: &str) -> Overlay {
|
||||
Overlay {
|
||||
lower: "/dev/vdb".into(),
|
||||
upper: "/dev/vdc".into(),
|
||||
at: at.into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The layers sit beside the overlay, never under it: anything mounted
|
||||
/// under `at` is hidden the moment the overlay covers it.
|
||||
#[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();
|
||||
fn an_overlays_layers_sit_beside_it_and_the_options_name_them() {
|
||||
let plan = OverlayPlan::new(&overlay("/nestri/install")).unwrap();
|
||||
assert_eq!(plan.lower.to_str().unwrap(), "/dev/vdb");
|
||||
assert_eq!(plan.upper.to_str().unwrap(), "/dev/vdc");
|
||||
assert_eq!(plan.lower_at.to_str().unwrap(), "/nestri/.install/lower");
|
||||
assert_eq!(plan.rw_at.to_str().unwrap(), "/nestri/.install/rw");
|
||||
assert_eq!(
|
||||
source.to_str().unwrap(),
|
||||
"/dev/vdb",
|
||||
"the device is the source"
|
||||
plan.overlay_data.to_str().unwrap(),
|
||||
"lowerdir=/nestri/.install/lower,\
|
||||
upperdir=/nestri/.install/rw/upper,\
|
||||
workdir=/nestri/.install/rw/work"
|
||||
);
|
||||
}
|
||||
|
||||
/// Every layer carries the guard every share carries, and none of them
|
||||
/// stops the game's own executable from running.
|
||||
///
|
||||
/// These are the mounts that most need it: a share is a directory this
|
||||
/// host prepared, and a build is a filesystem made out of whatever a CDN
|
||||
/// sent.
|
||||
#[test]
|
||||
fn every_layer_is_mounted_without_devices_or_setuid_but_can_still_execute() {
|
||||
let plan = OverlayPlan::new(&overlay("/nestri/install")).unwrap();
|
||||
for flags in [plan.lower_flags, plan.upper_flags, plan.overlay_flags] {
|
||||
assert_eq!(flags & libc::MS_NOSUID, libc::MS_NOSUID);
|
||||
assert_eq!(flags & libc::MS_NODEV, libc::MS_NODEV);
|
||||
assert_eq!(flags & libc::MS_NOEXEC, 0);
|
||||
}
|
||||
assert_eq!(plan.lower_flags & libc::MS_RDONLY, libc::MS_RDONLY);
|
||||
assert_eq!(plan.upper_flags & libc::MS_RDONLY, 0);
|
||||
assert_eq!(plan.overlay_flags & libc::MS_RDONLY, 0);
|
||||
assert_eq!(plan.overlay_flags & libc::MS_NOATIME, libc::MS_NOATIME);
|
||||
}
|
||||
|
||||
/// overlayfs splits its options on commas and colons, so a path carrying
|
||||
/// one would stack a different directory than the one named.
|
||||
#[test]
|
||||
fn an_overlay_the_kernel_would_misread_is_refused_before_anything_mounts() {
|
||||
for at in [
|
||||
"/nestri/in,stall",
|
||||
"/nestri/in:stall",
|
||||
"/",
|
||||
"/nestri/ins\0tall",
|
||||
] {
|
||||
assert!(
|
||||
OverlayPlan::new(&overlay(at)).is_err(),
|
||||
"{at:?} was accepted"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_failed_overlay_step_names_the_overlay_the_step_and_the_path() {
|
||||
let plan = OverlayPlan::new(&overlay("/nestri/install")).unwrap();
|
||||
let failure = plan.failed(
|
||||
"the build image",
|
||||
&plan.lower_at,
|
||||
io::Error::from_raw_os_error(libc::ENODEV),
|
||||
);
|
||||
assert!(
|
||||
failure
|
||||
.reason
|
||||
.starts_with("/nestri/install: the build image: /nestri/.install/lower: "),
|
||||
"{}",
|
||||
failure.reason
|
||||
);
|
||||
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]
|
||||
@@ -652,7 +842,7 @@ pub mod double {
|
||||
/// only thing under test.
|
||||
pub struct Double {
|
||||
pub mounted: Vec<Vec<Mount>>,
|
||||
pub drives: Vec<Vec<Drive>>,
|
||||
pub overlays: Vec<Vec<Overlay>>,
|
||||
pub started: Vec<Exec>,
|
||||
pub stops: usize,
|
||||
pub mount_failure: Option<Failure>,
|
||||
@@ -676,7 +866,7 @@ pub mod double {
|
||||
fn new(exit: Exit, holds_until_stopped: bool) -> Self {
|
||||
Self {
|
||||
mounted: Vec::new(),
|
||||
drives: Vec::new(),
|
||||
overlays: Vec::new(),
|
||||
started: Vec::new(),
|
||||
stops: 0,
|
||||
mount_failure: None,
|
||||
@@ -697,8 +887,8 @@ pub mod double {
|
||||
}
|
||||
}
|
||||
|
||||
fn mount_drives(&mut self, drives: &[Drive]) -> Result<(), Failure> {
|
||||
self.drives.push(drives.to_vec());
|
||||
fn mount_overlays(&mut self, overlays: &[Overlay]) -> Result<(), Failure> {
|
||||
self.overlays.push(overlays.to_vec());
|
||||
match &self.mount_failure {
|
||||
Some(failure) => Err(failure.clone()),
|
||||
None => Ok(()),
|
||||
|
||||
@@ -50,7 +50,9 @@ fn alive(pid: i32) -> bool {
|
||||
async fn a_stack_that_goes_away_takes_its_services_with_it() {
|
||||
let waiters = Waiters::new();
|
||||
let mut stack = Stack::from_table(waiters, SLEEPERS);
|
||||
let up = stack.bring_up().expect("two sleeps did not start");
|
||||
let up = stack
|
||||
.bring_up(Default::default())
|
||||
.expect("two sleeps did not start");
|
||||
assert_eq!(up.len(), 2);
|
||||
|
||||
let pids = stack.pids();
|
||||
|
||||
Reference in New Issue
Block a user