Establish bounded descriptor budgets for sandbox domains

This commit is contained in:
ms2k 2026-07-24 02:18:30 +09:00
commit 503f090121
8 changed files with 191 additions and 12 deletions

View file

@ -64,5 +64,10 @@ harness = false
name = "cold-start" name = "cold-start"
path = "tests/cold_start.rs" path = "tests/cold_start.rs"
[[test]]
name = "nofile"
path = "tests/nofile.rs"
harness = false
[lints] [lints]
workspace = true workspace = true

View file

@ -17,8 +17,9 @@ use lazaret_core::identity::{
}; };
use lazaret_core::records::{Domain, Session, SessionPhase}; use lazaret_core::records::{Domain, Session, SessionPhase};
use lazaret_linux::filesystem::try_exclusive_flock; use lazaret_linux::filesystem::try_exclusive_flock;
use lazaret_runtime_supervisor::sandbox::default_sandbox_layout; use lazaret_runtime_supervisor::sandbox::{DOMAIN_OPEN_FILE_LIMIT, default_sandbox_layout};
use rustix::fs::{Mode, OFlags, openat}; use rustix::fs::{Mode, OFlags, openat};
use rustix::process::{Resource, Rlimit, getrlimit, setrlimit};
use crate::bootstrap::{CoordinatorLifecycle, serve_control_connection_with_escalation}; use crate::bootstrap::{CoordinatorLifecycle, serve_control_connection_with_escalation};
use crate::cold_start::{BootstrapOwnerGuard, ColdStart, ColdStartError}; use crate::cold_start::{BootstrapOwnerGuard, ColdStart, ColdStartError};
@ -73,6 +74,7 @@ pub fn dispatch_private_client_bootstrap(
fn run_private_client_bootstrap(arguments: &[OsString]) -> Result<(), PrivateClientBootstrapError> { fn run_private_client_bootstrap(arguments: &[OsString]) -> Result<(), PrivateClientBootstrapError> {
let parsed = parse_arguments(arguments).inspect_err(|_| signal(b'E'))?; let parsed = parse_arguments(arguments).inspect_err(|_| signal(b'E'))?;
ensure_domain_nofile_limit().inspect_err(|_| signal(b'E'))?;
let owner = BootstrapOwnerGuard::from_stdin() let owner = BootstrapOwnerGuard::from_stdin()
.map_err(failure) .map_err(failure)
.inspect_err(|_| signal(b'E'))?; .inspect_err(|_| signal(b'E'))?;
@ -158,6 +160,51 @@ fn run_private_client_bootstrap(arguments: &[OsString]) -> Result<(), PrivateCli
.map_err(failure) .map_err(failure)
} }
/// Establishes the descriptor budget inherited by one Domain runtime.
///
/// # Errors
/// Returns an error when the hard limit is insufficient or the soft limit cannot be raised.
pub fn ensure_domain_nofile_limit() -> Result<(), PrivateClientBootstrapError> {
let inherited = getrlimit(Resource::Nofile);
let Some(required) = required_nofile_limit(inherited)? else {
return Ok(());
};
setrlimit(Resource::Nofile, required).map_err(|error| {
failure(format!(
"raising Host Coordinator RLIMIT_NOFILE from {} to {} failed: {error}",
display_limit(inherited.current),
DOMAIN_OPEN_FILE_LIMIT
))
})
}
fn required_nofile_limit(inherited: Rlimit) -> Result<Option<Rlimit>, PrivateClientBootstrapError> {
if inherited
.maximum
.is_some_and(|maximum| maximum < DOMAIN_OPEN_FILE_LIMIT)
{
return Err(failure(format!(
"Host Coordinator requires RLIMIT_NOFILE {DOMAIN_OPEN_FILE_LIMIT}, but inherited soft/hard limits are {}/{}",
display_limit(inherited.current),
display_limit(inherited.maximum)
)));
}
if inherited
.current
.is_none_or(|current| current >= DOMAIN_OPEN_FILE_LIMIT)
{
return Ok(None);
}
Ok(Some(Rlimit {
current: Some(DOMAIN_OPEN_FILE_LIMIT),
maximum: inherited.maximum,
}))
}
fn display_limit(limit: Option<u64>) -> String {
limit.map_or_else(|| "unlimited".to_owned(), |limit| limit.to_string())
}
fn serve_published( fn serve_published(
lifecycle: &Mutex<CoordinatorLifecycle<'_, '_>>, lifecycle: &Mutex<CoordinatorLifecycle<'_, '_>>,
waiters: &RefCell<BootstrapWaiterFace>, waiters: &RefCell<BootstrapWaiterFace>,
@ -495,6 +542,57 @@ mod tests {
use super::*; use super::*;
#[test]
fn host_coordinator_plans_a_bounded_raise_from_a_low_soft_nofile_limit() {
assert_eq!(
required_nofile_limit(Rlimit {
current: Some(1_024),
maximum: Some(DOMAIN_OPEN_FILE_LIMIT),
})
.unwrap(),
Some(Rlimit {
current: Some(DOMAIN_OPEN_FILE_LIMIT),
maximum: Some(DOMAIN_OPEN_FILE_LIMIT),
})
);
}
#[test]
fn host_coordinator_refuses_an_insufficient_hard_nofile_limit() {
let error = required_nofile_limit(Rlimit {
current: Some(1_024),
maximum: Some(DOMAIN_OPEN_FILE_LIMIT - 1),
})
.unwrap_err();
assert_eq!(
error.to_string(),
format!(
"Host Coordinator requires RLIMIT_NOFILE {DOMAIN_OPEN_FILE_LIMIT}, but inherited soft/hard limits are 1024/{}",
DOMAIN_OPEN_FILE_LIMIT - 1
)
);
}
#[test]
fn host_coordinator_preserves_sufficient_and_unlimited_nofile_limits() {
for limit in [
Rlimit {
current: Some(DOMAIN_OPEN_FILE_LIMIT),
maximum: Some(DOMAIN_OPEN_FILE_LIMIT),
},
Rlimit {
current: Some(DOMAIN_OPEN_FILE_LIMIT + 1),
maximum: None,
},
Rlimit {
current: None,
maximum: None,
},
] {
assert_eq!(required_nofile_limit(limit).unwrap(), None);
}
}
#[test] #[test]
fn private_bootstrap_arguments_are_exact_and_public() { fn private_bootstrap_arguments_are_exact_and_public() {
let arguments = [ let arguments = [

View file

@ -0,0 +1,39 @@
use std::process::ExitCode;
use lazaret_host_coordinator::client_bootstrap::ensure_domain_nofile_limit;
use lazaret_runtime_supervisor::sandbox::DOMAIN_OPEN_FILE_LIMIT;
use rustix::process::{Resource, Rlimit, getrlimit, setrlimit};
fn main() -> ExitCode {
match verify_low_inherited_soft_limit() {
Ok(()) => ExitCode::SUCCESS,
Err(error) => {
eprintln!("NOFILE fixture failed: {error}");
ExitCode::FAILURE
}
}
}
fn verify_low_inherited_soft_limit() -> Result<(), Box<dyn std::error::Error>> {
let inherited = getrlimit(Resource::Nofile);
if let Some(maximum) = inherited.maximum
&& maximum < DOMAIN_OPEN_FILE_LIMIT
{
return Err(
format!("test host hard limit {maximum} is below {DOMAIN_OPEN_FILE_LIMIT}").into(),
);
}
setrlimit(
Resource::Nofile,
Rlimit {
current: Some(1_024),
maximum: inherited.maximum,
},
)?;
ensure_domain_nofile_limit()?;
let observed = getrlimit(Resource::Nofile);
if observed.current != Some(DOMAIN_OPEN_FILE_LIMIT) || observed.maximum != inherited.maximum {
return Err(format!("unexpected raised limit: {observed:?}").into());
}
Ok(())
}

View file

@ -19,8 +19,8 @@ pub use lazaret_sandbox_contract::{
}; };
use oci_spec::runtime::{ use oci_spec::runtime::{
Capabilities, Capability, LinuxBuilder, LinuxCapabilitiesBuilder, LinuxNamespaceBuilder, Capabilities, Capability, LinuxBuilder, LinuxCapabilitiesBuilder, LinuxNamespaceBuilder,
LinuxNamespaceType, Mount, MountBuilder, ProcessBuilder, RootBuilder, Spec, SpecBuilder, LinuxNamespaceType, Mount, MountBuilder, PosixRlimitBuilder, PosixRlimitType, ProcessBuilder,
UserBuilder, RootBuilder, Spec, SpecBuilder, UserBuilder,
}; };
use rustix::fs::{AtFlags, FileType, Mode, OFlags, ResolveFlags, StatxFlags, openat2, statx}; use rustix::fs::{AtFlags, FileType, Mode, OFlags, ResolveFlags, StatxFlags, openat2, statx};
use sha2::{Digest, Sha256}; use sha2::{Digest, Sha256};
@ -40,6 +40,7 @@ use validation::{
}; };
pub const NETWORK_NAMESPACE_PATH: &str = "/proc/self/ns/net"; pub const NETWORK_NAMESPACE_PATH: &str = "/proc/self/ns/net";
pub const DOMAIN_OPEN_FILE_LIMIT: u64 = 524_288;
const OCI_VERSION: &str = "1.2.1"; const OCI_VERSION: &str = "1.2.1";
const MAX_TERMINFO_BYTES: usize = 1_048_576; const MAX_TERMINFO_BYTES: usize = 1_048_576;

View file

@ -1,12 +1,12 @@
use super::{ use super::{
AtFlags, Capabilities, Capability, ContainerId, File, FileIdentity, FileType, HandoffPathType, AtFlags, Capabilities, Capability, ContainerId, File, FileIdentity, FileType, HandoffPathType,
LazyLock, LinuxBuilder, LinuxCapabilitiesBuilder, LinuxNamespaceBuilder, LinuxNamespaceType, LazyLock, LinuxBuilder, LinuxCapabilitiesBuilder, LinuxNamespaceBuilder, LinuxNamespaceType,
Mode, Mount, NETWORK_NAMESPACE_PATH, OFlags, OsString, OwnedFd, Path, PathBuf, ProcessBuilder, Mode, Mount, NETWORK_NAMESPACE_PATH, OFlags, OsString, OwnedFd, Path, PathBuf,
RUNTIME_FILE_DESTINATIONS, Read, ResolveFlags, ResolvedDomainConfiguration, RootProcess, PosixRlimitBuilder, PosixRlimitType, ProcessBuilder, RUNTIME_FILE_DESTINATIONS, Read,
RunscCommand, RuntimeHandoffSource, SandboxBuildError, SandboxBuildErrorKind, ResolveFlags, ResolvedDomainConfiguration, RootProcess, RunscCommand, RuntimeHandoffSource,
StagedHostTerminal, StatxFlags, UserBuilder, VolumeAccess, WORKSPACE_DESTINATION, bind_mount, SandboxBuildError, SandboxBuildErrorKind, StagedHostTerminal, StatxFlags, UserBuilder,
control_mounts, hex_lower, host_path, oci_error, openat2, staging_error, standard_mounts, VolumeAccess, WORKSPACE_DESTINATION, bind_mount, control_mounts, hex_lower, host_path,
statx, validate_absolute_normalized, oci_error, openat2, staging_error, standard_mounts, statx, validate_absolute_normalized,
}; };
pub(super) struct RuntimePaths { pub(super) struct RuntimePaths {
@ -353,6 +353,12 @@ pub(super) fn build_process(
.umask(0o022_u32) .umask(0o022_u32)
.build() .build()
.map_err(oci_error)?; .map_err(oci_error)?;
let nofile = PosixRlimitBuilder::default()
.typ(PosixRlimitType::RlimitNofile)
.soft(super::DOMAIN_OPEN_FILE_LIMIT)
.hard(super::DOMAIN_OPEN_FILE_LIMIT)
.build()
.map_err(oci_error)?;
ProcessBuilder::default() ProcessBuilder::default()
.terminal(false) .terminal(false)
.user(user) .user(user)
@ -360,6 +366,7 @@ pub(super) fn build_process(
.env(Vec::<String>::new()) .env(Vec::<String>::new())
.cwd(PathBuf::from(WORKSPACE_DESTINATION)) .cwd(PathBuf::from(WORKSPACE_DESTINATION))
.capabilities(capabilities) .capabilities(capabilities)
.rlimits(vec![nofile])
.no_new_privileges(true) .no_new_privileges(true)
.build() .build()
.map_err(oci_error) .map_err(oci_error)

View file

@ -9,7 +9,7 @@ use lazaret_core::configuration::{
}; };
use lazaret_core::identity::{ContainerId, DeviceId, InodeId, NamespaceIdentity}; use lazaret_core::identity::{ContainerId, DeviceId, InodeId, NamespaceIdentity};
use lazaret_runtime_supervisor::sandbox::{ use lazaret_runtime_supervisor::sandbox::{
CONTROL_ROOT_DESTINATION, CREDENTIALS_DESTINATION, DEV_DESTINATION, CONTROL_ROOT_DESTINATION, CREDENTIALS_DESTINATION, DEV_DESTINATION, DOMAIN_OPEN_FILE_LIMIT,
ESCALATION_CLIENT_DESTINATION, HOSTNAME_DESTINATION, HOSTS_DESTINATION, HostTerminal, ESCALATION_CLIENT_DESTINATION, HOSTNAME_DESTINATION, HOSTS_DESTINATION, HostTerminal,
MAILBOX_REQUESTS_DESTINATION, MAILBOX_RESULTS_DESTINATION, NETWORK_NAMESPACE_PATH, MAILBOX_REQUESTS_DESTINATION, MAILBOX_RESULTS_DESTINATION, NETWORK_NAMESPACE_PATH,
PROC_DESTINATION, RESOLV_CONF_DESTINATION, RootProcess, SANDBOX_INIT_DESTINATION, PROC_DESTINATION, RESOLV_CONF_DESTINATION, RootProcess, SANDBOX_INIT_DESTINATION,
@ -283,6 +283,14 @@ fn deterministic_snapshot_has_only_private_namespaces_and_fixed_runtime_shape()
assert_eq!(specification["process"]["terminal"], false); assert_eq!(specification["process"]["terminal"], false);
assert_eq!(specification["process"]["noNewPrivileges"], true); assert_eq!(specification["process"]["noNewPrivileges"], true);
assert_eq!(specification["process"]["cwd"], WORKSPACE_DESTINATION); assert_eq!(specification["process"]["cwd"], WORKSPACE_DESTINATION);
assert_eq!(
specification["process"]["rlimits"],
serde_json::json!([{
"type": "RLIMIT_NOFILE",
"hard": DOMAIN_OPEN_FILE_LIMIT,
"soft": DOMAIN_OPEN_FILE_LIMIT
}])
);
assert_eq!( assert_eq!(
specification["process"]["args"], specification["process"]["args"],
serde_json::json!([ serde_json::json!([

View file

@ -429,7 +429,7 @@ class VerificationTests(unittest.TestCase):
"not binary(capability-drop-helper) and not binary(acquisition) " "not binary(capability-drop-helper) and not binary(acquisition) "
"and not binary(state-anchor-lifecycle) and not binary(recovery-process) " "and not binary(state-anchor-lifecycle) and not binary(recovery-process) "
"and not binary(runtime-composition) and not binary(escalation) " "and not binary(runtime-composition) and not binary(escalation) "
"and not binary(prepared-worker) " "and not binary(prepared-worker) and not binary(nofile) "
"and not binary(network-runtime)" "and not binary(network-runtime)"
), ),
], ],
@ -493,6 +493,16 @@ class VerificationTests(unittest.TestCase):
"prepared-worker", "prepared-worker",
"--locked", "--locked",
], ],
[
"cargo",
"test",
"--offline",
"-p",
"lazaret-host-coordinator",
"--test",
"nofile",
"--locked",
],
[ [
"cargo", "cargo",
"test", "test",

View file

@ -35,7 +35,7 @@ NEXTEST_COMMAND = (
"not binary(capability-drop-helper) and not binary(acquisition) " "not binary(capability-drop-helper) and not binary(acquisition) "
"and not binary(state-anchor-lifecycle) and not binary(recovery-process) " "and not binary(state-anchor-lifecycle) and not binary(recovery-process) "
"and not binary(runtime-composition) and not binary(escalation) " "and not binary(runtime-composition) and not binary(escalation) "
"and not binary(prepared-worker) " "and not binary(prepared-worker) and not binary(nofile) "
"and not binary(network-runtime)" "and not binary(network-runtime)"
), ),
) )
@ -108,6 +108,16 @@ PREPARED_WORKER_TEST_COMMAND = (
"prepared-worker", "prepared-worker",
"--locked", "--locked",
) )
NOFILE_TEST_COMMAND = (
"cargo",
"test",
"--offline",
"-p",
"lazaret-host-coordinator",
"--test",
"nofile",
"--locked",
)
RUNTIME_COMPOSITION_TEST_COMMAND = ( RUNTIME_COMPOSITION_TEST_COMMAND = (
"cargo", "cargo",
"test", "test",
@ -721,6 +731,7 @@ def run_rust_tests() -> None:
run(list(CAPABILITY_TEST_COMMAND)) run(list(CAPABILITY_TEST_COMMAND))
run(list(ESCALATION_TEST_COMMAND)) run(list(ESCALATION_TEST_COMMAND))
run(list(PREPARED_WORKER_TEST_COMMAND)) run(list(PREPARED_WORKER_TEST_COMMAND))
run(list(NOFILE_TEST_COMMAND))
run_physical(list(RUNTIME_COMPOSITION_TEST_COMMAND)) run_physical(list(RUNTIME_COMPOSITION_TEST_COMMAND))
run_physical(list(NETWORK_RUNTIME_TEST_COMMAND)) run_physical(list(NETWORK_RUNTIME_TEST_COMMAND))
run(list(DOCTEST_COMMAND)) run(list(DOCTEST_COMMAND))