use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use std::time::Duration;
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
pub struct HealthCheckOptions {
#[serde(default = "default_health_interval")]
pub interval: Duration,
#[serde(default = "default_health_timeout")]
pub timeout: Duration,
#[serde(default = "default_health_retries")]
pub retries: u32,
#[serde(default = "default_health_start_period")]
pub start_period: Duration,
}
fn default_health_interval() -> Duration {
Duration::from_secs(30)
}
fn default_health_timeout() -> Duration {
Duration::from_secs(10)
}
fn default_health_retries() -> u32 {
3
}
fn default_health_start_period() -> Duration {
Duration::from_secs(60)
}
impl Default for HealthCheckOptions {
fn default() -> Self {
Self {
interval: default_health_interval(),
timeout: default_health_timeout(),
retries: default_health_retries(),
start_period: default_health_start_period(),
}
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct SecurityOptions {
pub jailer_enabled: bool,
pub seccomp_enabled: bool,
pub uid: Option<u32>,
pub gid: Option<u32>,
pub new_pid_ns: bool,
pub new_net_ns: bool,
pub chroot_base: PathBuf,
pub chroot_enabled: bool,
pub close_fds: bool,
pub sanitize_env: bool,
pub env_allowlist: Vec<String>,
pub resource_limits: ResourceLimits,
pub sandbox_profile: Option<PathBuf>,
pub network_enabled: bool,
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct ResourceLimits {
#[serde(default)]
pub max_open_files: Option<u64>,
#[serde(default)]
pub max_file_size: Option<u64>,
#[serde(default)]
pub max_processes: Option<u64>,
#[serde(default)]
pub max_memory: Option<u64>,
#[serde(default)]
pub max_cpu_time: Option<u64>,
}
fn default_chroot_base() -> PathBuf {
PathBuf::from("/srv/boxlite")
}
fn default_network_enabled() -> bool {
true
}
impl Default for SecurityOptions {
fn default() -> Self {
Self {
jailer_enabled: true,
seccomp_enabled: cfg!(target_os = "linux"),
uid: Some(65534), gid: Some(65534), new_pid_ns: cfg!(target_os = "linux"),
new_net_ns: false, chroot_base: default_chroot_base(),
chroot_enabled: cfg!(target_os = "linux"),
close_fds: true,
sanitize_env: true,
env_allowlist: vec!["RUST_LOG".to_string()],
resource_limits: ResourceLimits {
max_open_files: Some(1024),
max_file_size: Some(1024 * 1024 * 1024), max_processes: Some(1024),
max_memory: None, max_cpu_time: None, },
sandbox_profile: None,
network_enabled: default_network_enabled(),
}
}
}
impl SecurityOptions {
pub fn enabled() -> Self {
Self::default()
}
pub fn disabled() -> Self {
Self {
jailer_enabled: false,
seccomp_enabled: false,
uid: None,
gid: None,
new_pid_ns: false,
new_net_ns: false,
chroot_base: default_chroot_base(),
chroot_enabled: false,
close_fds: false,
sanitize_env: false,
env_allowlist: Vec::new(),
resource_limits: ResourceLimits::default(),
sandbox_profile: None,
network_enabled: default_network_enabled(),
}
}
pub fn from_preset(name: &str) -> boxlite_shared::errors::BoxliteResult<Self> {
match name.trim().to_ascii_lowercase().as_str() {
"enable" | "enabled" | "on" => Ok(Self::enabled()),
"disable" | "disabled" | "off" => Ok(Self::disabled()),
other => Err(boxlite_shared::errors::BoxliteError::InvalidArgument(
format!("unknown security setting {other:?}; expected one of enable|disable"),
)),
}
}
pub fn is_full_isolation_available() -> bool {
cfg!(target_os = "linux")
}
pub fn warn_inert_fields(&self) {
#[cfg(not(target_os = "linux"))]
{
let mut ignored = Vec::new();
if self.seccomp_enabled {
ignored.push("seccomp_enabled");
}
if self.new_pid_ns {
ignored.push("new_pid_ns");
}
if self.new_net_ns {
ignored.push("new_net_ns");
}
if self.chroot_enabled {
ignored.push("chroot_enabled");
}
if !ignored.is_empty() {
tracing::warn!(
?ignored,
"SecurityOptions: Linux-only isolation requested but ignored on this non-Linux platform"
);
}
}
#[cfg(not(target_os = "macos"))]
{
if self.sandbox_profile.is_some() {
tracing::warn!(
"SecurityOptions: sandbox_profile is macOS-only and ignored on this platform"
);
}
}
}
pub fn builder() -> SecurityOptionsBuilder {
SecurityOptionsBuilder::new()
}
}
#[derive(Debug, Clone)]
pub struct SecurityOptionsBuilder {
inner: SecurityOptions,
}
impl Default for SecurityOptionsBuilder {
fn default() -> Self {
Self::new()
}
}
impl SecurityOptionsBuilder {
pub fn new() -> Self {
Self {
inner: SecurityOptions::default(),
}
}
pub fn enabled() -> Self {
Self {
inner: SecurityOptions::enabled(),
}
}
pub fn disabled() -> Self {
Self {
inner: SecurityOptions::disabled(),
}
}
pub fn jailer_enabled(&mut self, enabled: bool) -> &mut Self {
self.inner.jailer_enabled = enabled;
self
}
pub fn seccomp_enabled(&mut self, enabled: bool) -> &mut Self {
self.inner.seccomp_enabled = enabled;
self
}
pub fn uid(&mut self, uid: u32) -> &mut Self {
self.inner.uid = Some(uid);
self
}
pub fn gid(&mut self, gid: u32) -> &mut Self {
self.inner.gid = Some(gid);
self
}
pub fn new_pid_ns(&mut self, enabled: bool) -> &mut Self {
self.inner.new_pid_ns = enabled;
self
}
pub fn new_net_ns(&mut self, enabled: bool) -> &mut Self {
self.inner.new_net_ns = enabled;
self
}
pub fn chroot_base(&mut self, path: impl Into<PathBuf>) -> &mut Self {
self.inner.chroot_base = path.into();
self
}
pub fn chroot_enabled(&mut self, enabled: bool) -> &mut Self {
self.inner.chroot_enabled = enabled;
self
}
pub fn close_fds(&mut self, enabled: bool) -> &mut Self {
self.inner.close_fds = enabled;
self
}
pub fn sanitize_env(&mut self, enabled: bool) -> &mut Self {
self.inner.sanitize_env = enabled;
self
}
pub fn env_allowlist(&mut self, vars: Vec<String>) -> &mut Self {
self.inner.env_allowlist = vars;
self
}
pub fn allow_env(&mut self, var: impl Into<String>) -> &mut Self {
self.inner.env_allowlist.push(var.into());
self
}
pub fn resource_limits(&mut self, limits: ResourceLimits) -> &mut Self {
self.inner.resource_limits = limits;
self
}
pub fn max_open_files(&mut self, limit: u64) -> &mut Self {
self.inner.resource_limits.max_open_files = Some(limit);
self
}
pub fn max_file_size_bytes(&mut self, bytes: u64) -> &mut Self {
self.inner.resource_limits.max_file_size = Some(bytes);
self
}
pub fn max_processes(&mut self, limit: u64) -> &mut Self {
self.inner.resource_limits.max_processes = Some(limit);
self
}
pub fn max_memory_bytes(&mut self, bytes: u64) -> &mut Self {
self.inner.resource_limits.max_memory = Some(bytes);
self
}
pub fn max_cpu_time_seconds(&mut self, seconds: u64) -> &mut Self {
self.inner.resource_limits.max_cpu_time = Some(seconds);
self
}
pub fn sandbox_profile(&mut self, path: impl Into<PathBuf>) -> &mut Self {
self.inner.sandbox_profile = Some(path.into());
self
}
pub fn network_enabled(&mut self, enabled: bool) -> &mut Self {
self.inner.network_enabled = enabled;
self
}
pub fn build(&self) -> SecurityOptions {
self.inner.clone()
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct ContainerCapabilities {
pub add: Vec<String>,
pub drop: Vec<String>,
}
impl ContainerCapabilities {
pub fn is_empty(&self) -> bool {
self.add.is_empty() && self.drop.is_empty()
}
pub(crate) fn is_privileged_capability_shape(&self) -> bool {
self.drop.is_empty()
&& self.add.len() == 1
&& canonical_capability_name(&self.add[0]) == "ALL"
}
pub(crate) fn validate(&self) -> boxlite_shared::errors::BoxliteResult<()> {
validate_capability_names("advanced.capabilities.add", &self.add)?;
validate_capability_names("advanced.capabilities.drop", &self.drop)
}
pub(crate) fn check_compatibility(
&self,
actual: &Self,
box_name: &str,
) -> boxlite_shared::errors::BoxliteResult<()> {
let canonicalize = |capabilities: &[String]| {
capabilities
.iter()
.map(|capability| canonical_capability_name(capability))
.collect::<std::collections::BTreeSet<_>>()
};
if canonicalize(&self.add) == canonicalize(&actual.add)
&& canonicalize(&self.drop) == canonicalize(&actual.drop)
{
return Ok(());
}
Err(boxlite_shared::errors::BoxliteError::InvalidArgument(
format!(
"box '{box_name}' already exists with a different capability policy; reuse it \
with the same advanced.capabilities, or create a box under a new name"
),
))
}
}
fn canonical_capability_name(capability: &str) -> String {
let normalized = capability.to_ascii_uppercase();
normalized
.strip_prefix("CAP_")
.unwrap_or(&normalized)
.to_string()
}
fn validate_capability_names(
option: &str,
capabilities: &[String],
) -> boxlite_shared::errors::BoxliteResult<()> {
for capability in capabilities {
let name = canonical_capability_name(capability);
if name == "ALL" {
continue;
}
if name.is_empty() {
return Err(boxlite_shared::errors::BoxliteError::InvalidArgument(
format!("empty Linux capability in {option}"),
));
}
let mut bytes = name.bytes();
let starts_with_letter = bytes.next().is_some_and(|byte| byte.is_ascii_uppercase());
let has_valid_tail =
bytes.all(|byte| byte.is_ascii_uppercase() || byte.is_ascii_digit() || byte == b'_');
if !starts_with_letter || !has_valid_tail {
return Err(boxlite_shared::errors::BoxliteError::InvalidArgument(
format!("malformed Linux capability in {option}: {capability}"),
));
}
}
Ok(())
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct NetworkRateLimit {
#[serde(skip_serializing_if = "Option::is_none")]
pub tx_kbps: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub rx_kbps: Option<u64>,
}
impl NetworkRateLimit {
pub(crate) const MAX_KBPS: u64 = 368_934_881_474_191;
pub(crate) fn validate(&self) -> boxlite_shared::errors::BoxliteResult<()> {
for (field, kbps) in [("tx_kbps", self.tx_kbps), ("rx_kbps", self.rx_kbps)] {
if let Some(kbps) = kbps
&& kbps > Self::MAX_KBPS
{
return Err(boxlite_shared::errors::BoxliteError::InvalidArgument(
format!(
"advanced.network_rate_limit.{field} must be at most {} kbps \
(got {kbps})",
Self::MAX_KBPS
),
));
}
}
Ok(())
}
pub fn is_unlimited(&self) -> bool {
matches!(self.tx_kbps, None | Some(0)) && matches!(self.rx_kbps, None | Some(0))
}
}
#[derive(Debug, Default, Serialize, Deserialize)]
pub struct AdvancedBoxOptions {
#[serde(default, skip_serializing_if = "Option::is_none")]
capabilities: Option<ContainerCapabilities>,
#[serde(default)]
pub security: SecurityOptions,
#[serde(default)]
pub isolate_mounts: bool,
#[serde(default)]
pub health_check: Option<HealthCheckOptions>,
#[serde(default, skip_serializing_if = "NetworkRateLimit::is_unlimited")]
pub network_rate_limit: NetworkRateLimit,
#[doc(hidden)]
#[serde(default)]
pub kernel: Option<crate::experimental::custom_kernel::KernelOptions>,
#[doc(hidden)]
#[serde(default)]
pub nested_virtualization: bool,
#[serde(default)]
pub privileged: bool,
#[serde(skip)]
resolved: std::sync::atomic::AtomicBool,
}
impl Clone for AdvancedBoxOptions {
fn clone(&self) -> Self {
Self {
capabilities: self.capabilities.clone(),
security: self.security.clone(),
isolate_mounts: self.isolate_mounts,
health_check: self.health_check.clone(),
network_rate_limit: self.network_rate_limit,
kernel: self.kernel.clone(),
nested_virtualization: self.nested_virtualization,
privileged: self.privileged,
resolved: std::sync::atomic::AtomicBool::new(false),
}
}
}
impl AdvancedBoxOptions {
pub fn capabilities(&self) -> Option<&ContainerCapabilities> {
self.capabilities.as_ref()
}
pub fn set_capabilities(
&mut self,
capabilities: Option<ContainerCapabilities>,
) -> boxlite_shared::errors::BoxliteResult<()> {
if self.resolved.load(std::sync::atomic::Ordering::Relaxed) {
return Err(boxlite_shared::errors::BoxliteError::InvalidArgument(
"capabilities cannot be changed after these options have been resolved".to_string(),
));
}
self.capabilities = capabilities;
Ok(())
}
pub(crate) fn validate_privileged_capability_conflict(
&self,
) -> boxlite_shared::errors::BoxliteResult<()> {
if !self.privileged {
return Ok(());
}
match &self.capabilities {
None => Ok(()),
Some(caps) if caps.is_privileged_capability_shape() => Ok(()),
Some(_) => Err(boxlite_shared::errors::BoxliteError::InvalidArgument(
"privileged mode cannot be combined with an explicit capabilities override, \
including an explicitly empty one — leave capabilities unspecified instead"
.to_string(),
)),
}
}
pub fn set_privileged(&mut self, enabled: bool) {
self.privileged = enabled;
}
pub(crate) fn effective_capabilities(&self) -> ContainerCapabilities {
if self.privileged {
ContainerCapabilities {
add: vec!["ALL".to_string()],
drop: Vec::new(),
}
} else {
self.capabilities.clone().unwrap_or_default()
}
}
pub(crate) fn resolve_container_security(
&self,
) -> boxlite_shared::errors::BoxliteResult<ResolvedContainerSecurityConfig> {
self.validate_privileged_capability_conflict()?;
self.resolved
.store(true, std::sync::atomic::Ordering::Relaxed);
let capabilities = self.effective_capabilities();
let readonly_paths = if self.privileged {
Vec::new()
} else {
default_readonly_paths()
};
Ok(ResolvedContainerSecurityConfig {
capabilities,
linux: ResolvedLinuxSecurity { readonly_paths },
mount: ResolvedMountSecurity {
options: mount_options(self.privileged),
},
})
}
}
fn default_readonly_paths() -> Vec<String> {
oci_spec::runtime::get_default_readonly_paths()
}
fn mount_options(privileged: bool) -> Vec<String> {
let mut options: Vec<String> = ["rbind", "nosuid", "noexec", "nodev"]
.into_iter()
.map(String::from)
.collect();
if !privileged {
options.push("rro".to_string());
}
options
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub(crate) struct ResolvedContainerSecurityConfig {
pub(crate) capabilities: ContainerCapabilities,
pub(crate) linux: ResolvedLinuxSecurity,
pub(crate) mount: ResolvedMountSecurity,
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub(crate) struct ResolvedLinuxSecurity {
pub(crate) readonly_paths: Vec<String>,
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub(crate) struct ResolvedMountSecurity {
pub(crate) options: Vec<String>,
}
#[cfg(test)]
mod resolved_security_tests {
use super::*;
#[test]
fn unprivileged_resolves_hardened_path_defaults() {
let resolved = AdvancedBoxOptions::default()
.resolve_container_security()
.expect("default (unprivileged) security should resolve");
assert_eq!(
resolved.linux.readonly_paths,
[
"/proc/bus",
"/proc/fs",
"/proc/irq",
"/proc/sys",
"/proc/sysrq-trigger",
]
.map(String::from)
);
assert!(resolved.mount.options.contains(&"rro".to_string()));
}
#[test]
fn set_privileged_toggles_the_plain_field() {
let mut options = AdvancedBoxOptions::default();
options.set_privileged(true);
assert!(options.privileged);
options.set_privileged(false);
assert!(!options.privileged);
}
#[test]
fn privileged_resolves_cleared_readonly_paths_and_writable_sys() {
let mut options = AdvancedBoxOptions::default();
options.set_privileged(true);
let resolved = options
.resolve_container_security()
.expect("privileged security should resolve");
assert!(resolved.linux.readonly_paths.is_empty());
assert!(!resolved.mount.options.contains(&"rro".to_string()));
assert_eq!(resolved.capabilities.add, ["ALL"]);
}
#[test]
fn privileged_resolution_does_not_mutate_capabilities() {
let options = AdvancedBoxOptions {
privileged: true,
..Default::default()
};
options
.resolve_container_security()
.expect("privileged security should resolve");
assert!(options.capabilities.is_none());
}
#[test]
fn capability_override_without_privileged_keeps_hardened_paths() {
let options = AdvancedBoxOptions {
capabilities: Some(ContainerCapabilities {
add: vec!["SYS_ADMIN".to_string()],
..Default::default()
}),
..Default::default()
};
let resolved = options
.resolve_container_security()
.expect("capability-only options should resolve");
assert!(!resolved.linux.readonly_paths.is_empty());
assert!(resolved.mount.options.contains(&"rro".to_string()));
assert_eq!(resolved.capabilities.add, ["SYS_ADMIN"]);
}
#[test]
fn privileged_rejects_conflicting_capability_override() {
let options = AdvancedBoxOptions {
privileged: true,
capabilities: Some(ContainerCapabilities {
add: vec!["SYS_ADMIN".to_string()],
..Default::default()
}),
..Default::default()
};
let error = options
.resolve_container_security()
.expect_err("privileged mode plus an explicit override should be rejected");
assert!(error.to_string().contains("cannot be combined"));
}
#[test]
fn privileged_rejects_conflicting_override_regardless_of_setter_order() {
let mut options = AdvancedBoxOptions::default();
options
.set_capabilities(Some(ContainerCapabilities {
add: vec!["SYS_ADMIN".to_string()],
..Default::default()
}))
.unwrap();
options.set_privileged(true);
let error = options.resolve_container_security().expect_err(
"capabilities-then-privileged should be rejected same as the reverse order",
);
assert!(error.to_string().contains("cannot be combined"));
}
}