diff --git a/crates/agentd/bin/main.rs b/crates/agentd/bin/main.rs index ecd7b159..63ccab6a 100644 --- a/crates/agentd/bin/main.rs +++ b/crates/agentd/bin/main.rs @@ -18,9 +18,18 @@ fn main() { // Capture CLOCK_BOOTTIME immediately — this represents kernel boot duration. let boot_time_ns = microsandbox_agentd::clock::boottime_ns(); + // Read all MSB_* environment variables once at startup and parse. + let config = match microsandbox_agentd::AgentdConfig::from_env() { + Ok(c) => c, + Err(e) => { + eprintln!("agentd: config parse failed: {e}"); + std::process::exit(1); + } + }; + // Phase 1: Synchronous init (mount filesystems, prepare runtime directories). let init_start = microsandbox_agentd::clock::boottime_ns(); - if let Err(e) = microsandbox_agentd::init::init() { + if let Err(e) = microsandbox_agentd::init::init(&config) { eprintln!("agentd: init failed: {e}"); std::process::exit(1); } @@ -33,7 +42,7 @@ fn main() { .expect("agentd: failed to build tokio runtime"); rt.block_on(async { - match microsandbox_agentd::agent::run(boot_time_ns, init_time_ns).await { + match microsandbox_agentd::agent::run(boot_time_ns, init_time_ns, &config).await { Ok(()) => {} Err(microsandbox_agentd::AgentdError::Shutdown) => {} Err(e) => { diff --git a/crates/agentd/lib/agent.rs b/crates/agentd/lib/agent.rs index 2f9a0511..f67806ab 100644 --- a/crates/agentd/lib/agent.rs +++ b/crates/agentd/lib/agent.rs @@ -21,6 +21,7 @@ use microsandbox_protocol::{ }; use crate::{ + config::AgentdConfig, error::{AgentdError, AgentdResult}, fs::FsWriteSession, heartbeat::{heartbeat_dir_exists, write_heartbeat}, @@ -55,7 +56,7 @@ const MAX_INPUT_BUF_SIZE: usize = MAX_FRAME_SIZE as usize + 4; /// /// - `boot_time_ns`: `CLOCK_BOOTTIME` at `main()` start (kernel boot duration). /// - `init_time_ns`: nanoseconds spent in `init::init()`. -pub async fn run(boot_time_ns: u64, init_time_ns: u64) -> AgentdResult<()> { +pub async fn run(boot_time_ns: u64, init_time_ns: u64, config: &AgentdConfig) -> AgentdResult<()> { // Discover serial port. let port_path = find_serial_port(AGENT_PORT_NAME)?; @@ -140,6 +141,7 @@ pub async fn run(boot_time_ns: u64, init_time_ns: u64) -> AgentdResult<()> { &mut write_sessions, &session_tx, &mut serial_out_buf, + config, ).await?; } @@ -214,6 +216,7 @@ async fn handle_message( write_sessions: &mut HashMap, session_tx: &mpsc::UnboundedSender<(u32, SessionOutput)>, out_buf: &mut Vec, + config: &AgentdConfig, ) -> AgentdResult<()> { match msg.t { MessageType::ExecRequest => { @@ -221,7 +224,7 @@ async fn handle_message( .payload() .map_err(|e| AgentdError::ExecSession(format!("decode exec request: {e}")))?; prepend_scripts_to_path(&mut req); - match ExecSession::spawn(msg.id, &req, session_tx.clone()) { + match ExecSession::spawn(msg.id, &req, session_tx.clone(), config.user.as_deref()) { Ok(session) => { let reply = Message::with_payload( MessageType::ExecStarted, diff --git a/crates/agentd/lib/config.rs b/crates/agentd/lib/config.rs new file mode 100644 index 00000000..8bc060ca --- /dev/null +++ b/crates/agentd/lib/config.rs @@ -0,0 +1,779 @@ +//! Agentd configuration, read once from environment variables at startup. +//! +//! [`AgentdConfig::from_env`] reads all `MSB_*` environment variables and +//! parses them into their respective types in a single step. Downstream +//! functions receive the config by reference, avoiding repeated env var reads +//! and repeated parsing. + +use std::net::{Ipv4Addr, Ipv6Addr}; + +use microsandbox_protocol::{ + ENV_BLOCK_ROOT, ENV_DIR_MOUNTS, ENV_FILE_MOUNTS, ENV_HOSTNAME, ENV_NET, ENV_NET_IPV4, + ENV_NET_IPV6, ENV_TMPFS, ENV_USER, +}; + +use crate::error::{AgentdError, AgentdResult}; + +//-------------------------------------------------------------------------------------------------- +// Types +//-------------------------------------------------------------------------------------------------- + +/// Parsed configuration for agentd. +/// +/// All `MSB_*` environment variables are read and parsed into their respective +/// types during construction via [`AgentdConfig::from_env`]. +#[derive(Debug)] +pub struct AgentdConfig { + /// Parsed `MSB_BLOCK_ROOT` — block device for rootfs switch. + pub(crate) block_root: Option, + + /// Parsed `MSB_DIR_MOUNTS` — virtiofs directory mount specs. + pub(crate) dir_mounts: Option>, + + /// Parsed `MSB_FILE_MOUNTS` — virtiofs file mount specs. + pub(crate) file_mounts: Option>, + + /// Parsed `MSB_TMPFS` — tmpfs mount specs. + pub(crate) tmpfs: Option>, + + /// `MSB_HOSTNAME` — guest hostname. + pub(crate) hostname: Option, + + /// Parsed `MSB_NET` — network interface config. + pub(crate) net: Option, + + /// Parsed `MSB_NET_IPV4` — IPv4 config. + pub(crate) net_ipv4: Option, + + /// Parsed `MSB_NET_IPV6` — IPv6 config. + pub(crate) net_ipv6: Option, + + /// `MSB_USER` — default guest user for exec sessions. + pub(crate) user: Option, +} + +/// Parsed tmpfs mount specification. +#[derive(Debug)] +pub(crate) struct TmpfsSpec { + pub path: String, + pub size_mib: Option, + pub mode: Option, + pub noexec: bool, +} + +/// Parsed block-device root specification. +#[derive(Debug)] +pub(crate) struct BlockRootSpec { + pub device: String, + pub fstype: Option, +} + +/// Parsed virtiofs directory volume mount specification. +#[derive(Debug)] +pub(crate) struct DirMountSpec { + pub tag: String, + pub guest_path: String, + pub readonly: bool, +} + +/// Parsed virtiofs file volume mount specification. +#[derive(Debug)] +pub(crate) struct FileMountSpec { + pub tag: String, + pub filename: String, + pub guest_path: String, + pub readonly: bool, +} + +/// Parsed `MSB_NET` specification. +#[derive(Debug)] +pub(crate) struct NetSpec { + pub iface: String, + pub mac: [u8; 6], + pub mtu: u16, +} + +/// Parsed `MSB_NET_IPV4` specification. +#[derive(Debug)] +pub(crate) struct NetIpv4Spec { + pub address: Ipv4Addr, + pub prefix_len: u8, + pub gateway: Ipv4Addr, + pub dns: Option, +} + +/// Parsed `MSB_NET_IPV6` specification. +#[derive(Debug)] +pub(crate) struct NetIpv6Spec { + pub address: Ipv6Addr, + pub prefix_len: u8, + pub gateway: Ipv6Addr, + pub dns: Option, +} + +//-------------------------------------------------------------------------------------------------- +// Implementations +//-------------------------------------------------------------------------------------------------- + +impl AgentdConfig { + /// Reads all `MSB_*` environment variables and parses them into the config. + /// + /// Empty or whitespace-only values are treated as absent (`None`). + /// Returns an error if any present value fails to parse. + pub fn from_env() -> AgentdResult { + Ok(Self { + block_root: read_env(ENV_BLOCK_ROOT) + .map(|v| parse_block_root(&v)) + .transpose()?, + dir_mounts: read_env(ENV_DIR_MOUNTS) + .map(|v| parse_dir_mounts(&v)) + .transpose()?, + file_mounts: read_env(ENV_FILE_MOUNTS) + .map(|v| parse_file_mounts(&v)) + .transpose()?, + tmpfs: read_env(ENV_TMPFS) + .map(|v| parse_tmpfs_mounts(&v)) + .transpose()?, + hostname: read_env(ENV_HOSTNAME), + net: read_env(ENV_NET).map(|v| parse_net(&v)).transpose()?, + net_ipv4: read_env(ENV_NET_IPV4) + .map(|v| parse_net_ipv4(&v)) + .transpose()?, + net_ipv6: read_env(ENV_NET_IPV6) + .map(|v| parse_net_ipv6(&v)) + .transpose()?, + user: read_env(ENV_USER), + }) + } +} + +//-------------------------------------------------------------------------------------------------- +// Parse Functions: Block Root / Volume Mounts / Tmpfs +//-------------------------------------------------------------------------------------------------- + +/// Parses a block-device root specification: `device[,fstype=TYPE]` +fn parse_block_root(val: &str) -> AgentdResult { + let mut parts = val.split(','); + let device = parts.next().unwrap(); + if device.is_empty() { + return Err(AgentdError::Init( + "MSB_BLOCK_ROOT has empty device path".into(), + )); + } + + let mut fstype = None; + for opt in parts { + if let Some(val) = opt.strip_prefix("fstype=") { + if val.is_empty() { + return Err(AgentdError::Init( + "MSB_BLOCK_ROOT has empty fstype value".into(), + )); + } + fstype = Some(val.to_string()); + } else { + return Err(AgentdError::Init(format!( + "unknown MSB_BLOCK_ROOT option: {opt}" + ))); + } + } + + Ok(BlockRootSpec { + device: device.to_string(), + fstype, + }) +} + +/// Parses semicolon-separated directory mount entries. +fn parse_dir_mounts(val: &str) -> AgentdResult> { + val.split(';') + .filter(|e| !e.is_empty()) + .map(parse_dir_mount_entry) + .collect() +} + +/// Parses a single virtiofs directory volume mount entry: `tag:guest_path[:ro]` +fn parse_dir_mount_entry(entry: &str) -> AgentdResult { + let parts: Vec<&str> = entry.split(':').collect(); + if parts.len() < 2 { + return Err(AgentdError::Init(format!( + "MSB_DIR_MOUNTS entry must be tag:path[:ro], got: {entry}" + ))); + } + + let tag = parts[0]; + let guest_path = parts[1]; + let readonly = match parts.get(2) { + Some(&"ro") => true, + None => false, + Some(flag) => { + return Err(AgentdError::Init(format!( + "MSB_DIR_MOUNTS unknown flag '{flag}' (expected 'ro')" + ))); + } + }; + + if parts.len() > 3 { + return Err(AgentdError::Init(format!( + "MSB_DIR_MOUNTS entry has too many parts: {entry}" + ))); + } + + if tag.is_empty() { + return Err(AgentdError::Init( + "MSB_DIR_MOUNTS entry has empty tag".into(), + )); + } + if guest_path.is_empty() || !guest_path.starts_with('/') { + return Err(AgentdError::Init(format!( + "MSB_DIR_MOUNTS guest path must be absolute: {guest_path}" + ))); + } + + Ok(DirMountSpec { + tag: tag.to_string(), + guest_path: guest_path.to_string(), + readonly, + }) +} + +/// Parses semicolon-separated file mount entries. +fn parse_file_mounts(val: &str) -> AgentdResult> { + val.split(';') + .filter(|e| !e.is_empty()) + .map(parse_file_mount_entry) + .collect() +} + +/// Parses a single virtiofs file volume mount entry: `tag:filename:guest_path[:ro]` +fn parse_file_mount_entry(entry: &str) -> AgentdResult { + let parts: Vec<&str> = entry.split(':').collect(); + if parts.len() < 3 { + return Err(AgentdError::Init(format!( + "MSB_FILE_MOUNTS entry must be tag:filename:path[:ro], got: {entry}" + ))); + } + + let tag = parts[0]; + let filename = parts[1]; + let guest_path = parts[2]; + let readonly = match parts.get(3) { + Some(&"ro") => true, + None => false, + Some(flag) => { + return Err(AgentdError::Init(format!( + "MSB_FILE_MOUNTS unknown flag '{flag}' (expected 'ro')" + ))); + } + }; + + if parts.len() > 4 { + return Err(AgentdError::Init(format!( + "MSB_FILE_MOUNTS entry has too many parts: {entry}" + ))); + } + + if tag.is_empty() { + return Err(AgentdError::Init( + "MSB_FILE_MOUNTS entry has empty tag".into(), + )); + } + if filename.is_empty() { + return Err(AgentdError::Init( + "MSB_FILE_MOUNTS entry has empty filename".into(), + )); + } + if guest_path.is_empty() || !guest_path.starts_with('/') { + return Err(AgentdError::Init(format!( + "MSB_FILE_MOUNTS guest path must be absolute: {guest_path}" + ))); + } + + Ok(FileMountSpec { + tag: tag.to_string(), + filename: filename.to_string(), + guest_path: guest_path.to_string(), + readonly, + }) +} + +/// Parses semicolon-separated tmpfs mount entries. +fn parse_tmpfs_mounts(val: &str) -> AgentdResult> { + val.split(';') + .filter(|e| !e.is_empty()) + .map(parse_tmpfs_entry) + .collect() +} + +/// Parses a single tmpfs entry: `path[,size=N][,mode=N][,noexec]` +/// +/// Mode is parsed as octal (e.g. `mode=1777`). +fn parse_tmpfs_entry(entry: &str) -> AgentdResult { + let mut parts = entry.split(','); + let path = parts.next().unwrap(); // always at least one element + if path.is_empty() { + return Err(AgentdError::Init("tmpfs entry has empty path".into())); + } + + let mut size_mib = None; + let mut mode = None; + let mut noexec = false; + + for opt in parts { + if opt == "noexec" { + noexec = true; + } else if let Some(val) = opt.strip_prefix("size=") { + size_mib = Some( + val.parse::() + .map_err(|_| AgentdError::Init(format!("invalid tmpfs size: {val}")))?, + ); + } else if let Some(val) = opt.strip_prefix("mode=") { + mode = Some( + u32::from_str_radix(val, 8) + .map_err(|_| AgentdError::Init(format!("invalid octal tmpfs mode: {val}")))?, + ); + } else { + return Err(AgentdError::Init(format!("unknown tmpfs option: {opt}"))); + } + } + + Ok(TmpfsSpec { + path: path.to_string(), + size_mib, + mode, + noexec, + }) +} + +//-------------------------------------------------------------------------------------------------- +// Parse Functions: Network +//-------------------------------------------------------------------------------------------------- + +/// Parses `MSB_NET` value: `iface=NAME,mac=AA:BB:CC:DD:EE:FF,mtu=N` +fn parse_net(val: &str) -> AgentdResult { + let mut iface = None; + let mut mac = None; + let mut mtu = 1500u16; + + for part in val.split(',') { + if let Some(v) = part.strip_prefix("iface=") { + iface = Some(v.to_string()); + } else if let Some(v) = part.strip_prefix("mac=") { + mac = Some(parse_mac(v)?); + } else if let Some(v) = part.strip_prefix("mtu=") { + mtu = v + .parse() + .map_err(|_| AgentdError::Init(format!("invalid MTU: {v}")))?; + } else { + return Err(AgentdError::Init(format!("unknown MSB_NET option: {part}"))); + } + } + + let iface = iface.ok_or_else(|| AgentdError::Init("MSB_NET missing iface=".into()))?; + let mac = mac.ok_or_else(|| AgentdError::Init("MSB_NET missing mac=".into()))?; + + Ok(NetSpec { iface, mac, mtu }) +} + +/// Parses `MSB_NET_IPV4` value: `addr=A.B.C.D/N,gw=A.B.C.D[,dns=A.B.C.D]` +fn parse_net_ipv4(val: &str) -> AgentdResult { + let mut address = None; + let mut prefix_len = None; + let mut gateway = None; + let mut dns = None; + + for part in val.split(',') { + if let Some(v) = part.strip_prefix("addr=") { + let (addr, prefix) = parse_cidr_v4(v)?; + address = Some(addr); + prefix_len = Some(prefix); + } else if let Some(v) = part.strip_prefix("gw=") { + gateway = Some( + v.parse::() + .map_err(|_| AgentdError::Init(format!("invalid IPv4 gateway: {v}")))?, + ); + } else if let Some(v) = part.strip_prefix("dns=") { + dns = Some( + v.parse::() + .map_err(|_| AgentdError::Init(format!("invalid IPv4 DNS: {v}")))?, + ); + } else { + return Err(AgentdError::Init(format!( + "unknown MSB_NET_IPV4 option: {part}" + ))); + } + } + + let address = address.ok_or_else(|| AgentdError::Init("MSB_NET_IPV4 missing addr=".into()))?; + let prefix_len = + prefix_len.ok_or_else(|| AgentdError::Init("MSB_NET_IPV4 missing addr=".into()))?; + let gateway = gateway.ok_or_else(|| AgentdError::Init("MSB_NET_IPV4 missing gw=".into()))?; + + Ok(NetIpv4Spec { + address, + prefix_len, + gateway, + dns, + }) +} + +/// Parses `MSB_NET_IPV6` value: `addr=ADDR/N,gw=ADDR[,dns=ADDR]` +fn parse_net_ipv6(val: &str) -> AgentdResult { + let mut address = None; + let mut prefix_len = None; + let mut gateway = None; + let mut dns = None; + + for part in val.split(',') { + if let Some(v) = part.strip_prefix("addr=") { + let (addr, prefix) = parse_cidr_v6(v)?; + address = Some(addr); + prefix_len = Some(prefix); + } else if let Some(v) = part.strip_prefix("gw=") { + gateway = Some( + v.parse::() + .map_err(|_| AgentdError::Init(format!("invalid IPv6 gateway: {v}")))?, + ); + } else if let Some(v) = part.strip_prefix("dns=") { + dns = Some( + v.parse::() + .map_err(|_| AgentdError::Init(format!("invalid IPv6 DNS: {v}")))?, + ); + } else { + return Err(AgentdError::Init(format!( + "unknown MSB_NET_IPV6 option: {part}" + ))); + } + } + + let address = address.ok_or_else(|| AgentdError::Init("MSB_NET_IPV6 missing addr=".into()))?; + let prefix_len = + prefix_len.ok_or_else(|| AgentdError::Init("MSB_NET_IPV6 missing addr=".into()))?; + let gateway = gateway.ok_or_else(|| AgentdError::Init("MSB_NET_IPV6 missing gw=".into()))?; + + Ok(NetIpv6Spec { + address, + prefix_len, + gateway, + dns, + }) +} + +/// Parses a MAC address string like `02:5a:7b:13:01:02`. +fn parse_mac(s: &str) -> AgentdResult<[u8; 6]> { + let mut mac = [0u8; 6]; + let mut len = 0usize; + for (i, part) in s.split(':').enumerate() { + if i >= 6 { + return Err(AgentdError::Init(format!("invalid MAC address: {s}"))); + } + mac[i] = u8::from_str_radix(part, 16) + .map_err(|_| AgentdError::Init(format!("invalid MAC octet: {part}")))?; + len = i + 1; + } + if len != 6 { + return Err(AgentdError::Init(format!("invalid MAC address: {s}"))); + } + Ok(mac) +} + +/// Parses an IPv4 CIDR like `100.96.1.2/30`. +fn parse_cidr_v4(s: &str) -> AgentdResult<(Ipv4Addr, u8)> { + let (addr_str, prefix_str) = s + .split_once('/') + .ok_or_else(|| AgentdError::Init(format!("invalid IPv4 CIDR (missing /): {s}")))?; + let addr = addr_str + .parse::() + .map_err(|_| AgentdError::Init(format!("invalid IPv4 address: {addr_str}")))?; + let prefix = prefix_str + .parse::() + .map_err(|_| AgentdError::Init(format!("invalid IPv4 prefix length: {prefix_str}")))?; + if prefix > 32 { + return Err(AgentdError::Init(format!( + "IPv4 prefix length out of range (0-32): {prefix}" + ))); + } + Ok((addr, prefix)) +} + +/// Parses an IPv6 CIDR like `fd42:6d73:62:2a::2/64`. +fn parse_cidr_v6(s: &str) -> AgentdResult<(Ipv6Addr, u8)> { + let (addr_str, prefix_str) = s + .rsplit_once('/') + .ok_or_else(|| AgentdError::Init(format!("invalid IPv6 CIDR (missing /): {s}")))?; + let addr = addr_str + .parse::() + .map_err(|_| AgentdError::Init(format!("invalid IPv6 address: {addr_str}")))?; + let prefix = prefix_str + .parse::() + .map_err(|_| AgentdError::Init(format!("invalid IPv6 prefix length: {prefix_str}")))?; + if prefix > 128 { + return Err(AgentdError::Init(format!( + "IPv6 prefix length out of range (0-128): {prefix}" + ))); + } + Ok((addr, prefix)) +} + +//-------------------------------------------------------------------------------------------------- +// Helper Functions +//-------------------------------------------------------------------------------------------------- + +/// Reads a single environment variable, returning `None` for missing or empty values. +fn read_env(key: &str) -> Option { + std::env::var(key) + .ok() + .map(|v| v.trim().to_string()) + .filter(|v| !v.is_empty()) +} + +//-------------------------------------------------------------------------------------------------- +// Tests +//-------------------------------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + // ── Block Root ──────────────────────────────────────────────────── + + #[test] + fn test_parse_block_root_device_only() { + let spec = parse_block_root("/dev/vda").unwrap(); + assert_eq!(spec.device, "/dev/vda"); + assert_eq!(spec.fstype, None); + } + + #[test] + fn test_parse_block_root_with_fstype() { + let spec = parse_block_root("/dev/vda,fstype=ext4").unwrap(); + assert_eq!(spec.device, "/dev/vda"); + assert_eq!(spec.fstype.as_deref(), Some("ext4")); + } + + #[test] + fn test_parse_block_root_empty_device_errors() { + let err = parse_block_root(",fstype=ext4").unwrap_err(); + assert!(err.to_string().contains("empty device path")); + } + + #[test] + fn test_parse_block_root_unknown_option_errors() { + let err = parse_block_root("/dev/vda,bogus=42").unwrap_err(); + assert!(err.to_string().contains("unknown MSB_BLOCK_ROOT option")); + } + + #[test] + fn test_parse_block_root_empty_fstype_errors() { + let err = parse_block_root("/dev/vda,fstype=").unwrap_err(); + assert!(err.to_string().contains("empty fstype")); + } + + // ── File Mounts ──────────────────────────────────────────────────── + + #[test] + fn test_parse_file_mount_entry_basic() { + let spec = parse_file_mount_entry("fm_config:app.conf:/etc/app.conf").unwrap(); + assert_eq!(spec.tag, "fm_config"); + assert_eq!(spec.filename, "app.conf"); + assert_eq!(spec.guest_path, "/etc/app.conf"); + assert!(!spec.readonly); + } + + #[test] + fn test_parse_file_mount_entry_readonly() { + let spec = parse_file_mount_entry("fm_config:app.conf:/etc/app.conf:ro").unwrap(); + assert!(spec.readonly); + } + + #[test] + fn test_parse_file_mount_entry_too_few_parts() { + assert!(parse_file_mount_entry("fm_config:/etc/app.conf").is_err()); + } + + #[test] + fn test_parse_file_mount_entry_empty_filename() { + assert!(parse_file_mount_entry("fm_config::/etc/app.conf").is_err()); + } + + #[test] + fn test_parse_file_mount_entry_relative_path() { + assert!(parse_file_mount_entry("fm_config:app.conf:relative/path").is_err()); + } + + #[test] + fn test_parse_file_mount_entry_too_many_parts() { + assert!(parse_file_mount_entry("fm_config:app.conf:/etc/app.conf:ro:extra").is_err()); + } + + #[test] + fn test_parse_file_mount_entry_unknown_flag() { + assert!(parse_file_mount_entry("fm_config:app.conf:/etc/app.conf:rw").is_err()); + } + + #[test] + fn test_parse_file_mount_entry_empty_tag() { + assert!(parse_file_mount_entry(":app.conf:/etc/app.conf").is_err()); + } + + // ── Tmpfs ───────────────────────────────────────────────────────── + + #[test] + fn test_parse_path_only() { + let spec = parse_tmpfs_entry("/tmp").unwrap(); + assert_eq!(spec.path, "/tmp"); + assert_eq!(spec.size_mib, None); + assert_eq!(spec.mode, None); + assert!(!spec.noexec); + } + + #[test] + fn test_parse_with_size() { + let spec = parse_tmpfs_entry("/tmp,size=256").unwrap(); + assert_eq!(spec.path, "/tmp"); + assert_eq!(spec.size_mib, Some(256)); + } + + #[test] + fn test_parse_with_noexec() { + let spec = parse_tmpfs_entry("/tmp,noexec").unwrap(); + assert_eq!(spec.path, "/tmp"); + assert!(spec.noexec); + } + + #[test] + fn test_parse_with_octal_mode() { + let spec = parse_tmpfs_entry("/tmp,mode=1777").unwrap(); + assert_eq!(spec.mode, Some(0o1777)); + + let spec = parse_tmpfs_entry("/data,mode=755").unwrap(); + assert_eq!(spec.mode, Some(0o755)); + } + + #[test] + fn test_parse_multi_options() { + let spec = parse_tmpfs_entry("/tmp,size=256,mode=1777,noexec").unwrap(); + assert_eq!(spec.path, "/tmp"); + assert_eq!(spec.size_mib, Some(256)); + assert_eq!(spec.mode, Some(0o1777)); + assert!(spec.noexec); + } + + #[test] + fn test_parse_unknown_option_errors() { + let err = parse_tmpfs_entry("/tmp,bogus=42").unwrap_err(); + assert!(err.to_string().contains("unknown tmpfs option")); + } + + #[test] + fn test_parse_invalid_size_errors() { + let err = parse_tmpfs_entry("/tmp,size=abc").unwrap_err(); + assert!(err.to_string().contains("invalid tmpfs size")); + } + + #[test] + fn test_parse_invalid_mode_errors() { + let err = parse_tmpfs_entry("/tmp,mode=zzz").unwrap_err(); + assert!(err.to_string().contains("invalid octal tmpfs mode")); + } + + #[test] + fn test_parse_empty_path_errors() { + let err = parse_tmpfs_entry(",size=256").unwrap_err(); + assert!(err.to_string().contains("empty path")); + } + + // ── Network ─────────────────────────────────────────────────────── + + #[test] + fn test_parse_net_full() { + let spec = parse_net("iface=eth0,mac=02:5a:7b:13:01:02,mtu=1500").unwrap(); + assert_eq!(spec.iface, "eth0"); + assert_eq!(spec.mac, [0x02, 0x5a, 0x7b, 0x13, 0x01, 0x02]); + assert_eq!(spec.mtu, 1500); + } + + #[test] + fn test_parse_net_default_mtu() { + let spec = parse_net("iface=eth0,mac=02:00:00:00:00:01").unwrap(); + assert_eq!(spec.mtu, 1500); + } + + #[test] + fn test_parse_net_missing_iface() { + assert!(parse_net("mac=02:00:00:00:00:01").is_err()); + } + + #[test] + fn test_parse_net_missing_mac() { + assert!(parse_net("iface=eth0").is_err()); + } + + #[test] + fn test_parse_net_unknown_option() { + assert!(parse_net("iface=eth0,mac=02:00:00:00:00:01,bogus=42").is_err()); + } + + #[test] + fn test_parse_net_ipv4() { + let spec = parse_net_ipv4("addr=100.96.1.2/30,gw=100.96.1.1,dns=100.96.1.1").unwrap(); + assert_eq!(spec.address, Ipv4Addr::new(100, 96, 1, 2)); + assert_eq!(spec.prefix_len, 30); + assert_eq!(spec.gateway, Ipv4Addr::new(100, 96, 1, 1)); + assert_eq!(spec.dns, Some(Ipv4Addr::new(100, 96, 1, 1))); + } + + #[test] + fn test_parse_net_ipv4_no_dns() { + let spec = parse_net_ipv4("addr=10.0.0.2/24,gw=10.0.0.1").unwrap(); + assert_eq!(spec.dns, None); + } + + #[test] + fn test_parse_net_ipv4_missing_addr() { + assert!(parse_net_ipv4("gw=10.0.0.1").is_err()); + } + + #[test] + fn test_parse_net_ipv6() { + let spec = parse_net_ipv6( + "addr=fd42:6d73:62:2a::2/64,gw=fd42:6d73:62:2a::1,dns=fd42:6d73:62:2a::1", + ) + .unwrap(); + assert_eq!( + spec.address, + "fd42:6d73:62:2a::2".parse::().unwrap() + ); + assert_eq!(spec.prefix_len, 64); + assert_eq!( + spec.gateway, + "fd42:6d73:62:2a::1".parse::().unwrap() + ); + assert!(spec.dns.is_some()); + } + + #[test] + fn test_parse_mac_valid() { + let mac = parse_mac("02:5a:7b:13:01:02").unwrap(); + assert_eq!(mac, [0x02, 0x5a, 0x7b, 0x13, 0x01, 0x02]); + } + + #[test] + fn test_parse_mac_invalid() { + assert!(parse_mac("02:5a:7b").is_err()); + assert!(parse_mac("zz:00:00:00:00:00").is_err()); + } + + #[test] + fn test_parse_cidr_v4() { + let (addr, prefix) = parse_cidr_v4("100.96.1.2/30").unwrap(); + assert_eq!(addr, Ipv4Addr::new(100, 96, 1, 2)); + assert_eq!(prefix, 30); + } + + #[test] + fn test_parse_cidr_v6() { + let (addr, prefix) = parse_cidr_v6("fd42:6d73:62:2a::2/64").unwrap(); + assert_eq!(addr, "fd42:6d73:62:2a::2".parse::().unwrap()); + assert_eq!(prefix, 64); + } +} diff --git a/crates/agentd/lib/init.rs b/crates/agentd/lib/init.rs index b5206785..f327923d 100644 --- a/crates/agentd/lib/init.rs +++ b/crates/agentd/lib/init.rs @@ -1,43 +1,7 @@ //! PID 1 init: mount filesystems, apply tmpfs mounts, prepare runtime directories. -use crate::error::{AgentdError, AgentdResult}; - -//-------------------------------------------------------------------------------------------------- -// Types -//-------------------------------------------------------------------------------------------------- - -/// Parsed tmpfs mount specification. -#[derive(Debug)] -struct TmpfsSpec<'a> { - path: &'a str, - size_mib: Option, - mode: Option, - noexec: bool, -} - -/// Parsed block-device root specification. -#[derive(Debug)] -struct BlockRootSpec<'a> { - device: &'a str, - fstype: Option<&'a str>, -} - -/// Parsed virtiofs directory volume mount specification. -#[derive(Debug)] -struct DirMountSpec<'a> { - tag: &'a str, - guest_path: &'a str, - readonly: bool, -} - -/// Parsed virtiofs file volume mount specification. -#[derive(Debug)] -struct FileMountSpec<'a> { - tag: &'a str, - filename: &'a str, - guest_path: &'a str, - readonly: bool, -} +use crate::config::AgentdConfig; +use crate::error::AgentdResult; //-------------------------------------------------------------------------------------------------- // Functions @@ -45,192 +9,37 @@ struct FileMountSpec<'a> { /// Performs synchronous PID 1 initialization. /// -/// Mounts essential filesystems, applies directory mounts from -/// `MSB_DIR_MOUNTS`, file mounts from `MSB_FILE_MOUNTS`, and tmpfs mounts -/// from `MSB_TMPFS`. Configures networking from `MSB_NET*` env vars and -/// prepares runtime directories. -pub fn init() -> AgentdResult<()> { +/// Mounts essential filesystems, applies directory mounts, file mounts, and +/// tmpfs mounts from the parsed config. Configures networking and prepares +/// runtime directories. +pub fn init(config: &AgentdConfig) -> AgentdResult<()> { linux::mount_filesystems()?; linux::mount_runtime()?; - linux::mount_block_root()?; - linux::apply_dir_mounts()?; - linux::apply_file_mounts()?; - crate::network::apply_hostname()?; - linux::apply_tmpfs_mounts()?; + if let Some(spec) = &config.block_root { + linux::mount_block_root(spec)?; + } + if let Some(specs) = &config.dir_mounts { + linux::apply_dir_mounts(specs)?; + } + if let Some(specs) = &config.file_mounts { + linux::apply_file_mounts(specs)?; + } + crate::network::apply_hostname(config.hostname.as_deref())?; + if let Some(specs) = &config.tmpfs { + linux::apply_tmpfs_mounts(specs)?; + } linux::ensure_standard_tmp_permissions()?; - crate::network::apply_network_config()?; + crate::network::apply_network_config( + config.net.as_ref(), + config.net_ipv4.as_ref(), + config.net_ipv6.as_ref(), + )?; crate::tls::install_ca_cert()?; linux::ensure_scripts_path_in_profile()?; linux::create_run_dir()?; Ok(()) } -/// Parses a single tmpfs entry: `path[,size=N][,mode=N][,noexec]` -/// -/// Mode is parsed as octal (e.g. `mode=1777`). -fn parse_tmpfs_entry(entry: &str) -> AgentdResult> { - let mut parts = entry.split(','); - let path = parts.next().unwrap(); // always at least one element - if path.is_empty() { - return Err(AgentdError::Init("tmpfs entry has empty path".into())); - } - - let mut size_mib = None; - let mut mode = None; - let mut noexec = false; - - for opt in parts { - if opt == "noexec" { - noexec = true; - } else if let Some(val) = opt.strip_prefix("size=") { - size_mib = Some( - val.parse::() - .map_err(|_| AgentdError::Init(format!("invalid tmpfs size: {val}")))?, - ); - } else if let Some(val) = opt.strip_prefix("mode=") { - mode = Some( - u32::from_str_radix(val, 8) - .map_err(|_| AgentdError::Init(format!("invalid octal tmpfs mode: {val}")))?, - ); - } else { - return Err(AgentdError::Init(format!("unknown tmpfs option: {opt}"))); - } - } - - Ok(TmpfsSpec { - path, - size_mib, - mode, - noexec, - }) -} - -/// Parses a block-device root specification: `device[,fstype=TYPE]` -fn parse_block_root(val: &str) -> AgentdResult> { - let mut parts = val.split(','); - let device = parts.next().unwrap(); - if device.is_empty() { - return Err(AgentdError::Init( - "MSB_BLOCK_ROOT has empty device path".into(), - )); - } - - let mut fstype = None; - for opt in parts { - if let Some(val) = opt.strip_prefix("fstype=") { - if val.is_empty() { - return Err(AgentdError::Init( - "MSB_BLOCK_ROOT has empty fstype value".into(), - )); - } - fstype = Some(val); - } else { - return Err(AgentdError::Init(format!( - "unknown MSB_BLOCK_ROOT option: {opt}" - ))); - } - } - - Ok(BlockRootSpec { device, fstype }) -} - -/// Parses a single virtiofs directory volume mount entry: `tag:guest_path[:ro]` -fn parse_dir_mount_entry(entry: &str) -> AgentdResult> { - let parts: Vec<&str> = entry.split(':').collect(); - if parts.len() < 2 { - return Err(AgentdError::Init(format!( - "MSB_DIR_MOUNTS entry must be tag:path[:ro], got: {entry}" - ))); - } - - let tag = parts[0]; - let guest_path = parts[1]; - let readonly = match parts.get(2) { - Some(&"ro") => true, - None => false, - Some(flag) => { - return Err(AgentdError::Init(format!( - "MSB_DIR_MOUNTS unknown flag '{flag}' (expected 'ro')" - ))); - } - }; - - if parts.len() > 3 { - return Err(AgentdError::Init(format!( - "MSB_DIR_MOUNTS entry has too many parts: {entry}" - ))); - } - - if tag.is_empty() { - return Err(AgentdError::Init( - "MSB_DIR_MOUNTS entry has empty tag".into(), - )); - } - if guest_path.is_empty() || !guest_path.starts_with('/') { - return Err(AgentdError::Init(format!( - "MSB_DIR_MOUNTS guest path must be absolute: {guest_path}" - ))); - } - - Ok(DirMountSpec { - tag, - guest_path, - readonly, - }) -} - -/// Parses a single virtiofs file volume mount entry: `tag:filename:guest_path[:ro]` -fn parse_file_mount_entry(entry: &str) -> AgentdResult> { - let parts: Vec<&str> = entry.split(':').collect(); - if parts.len() < 3 { - return Err(AgentdError::Init(format!( - "MSB_FILE_MOUNTS entry must be tag:filename:path[:ro], got: {entry}" - ))); - } - - let tag = parts[0]; - let filename = parts[1]; - let guest_path = parts[2]; - let readonly = match parts.get(3) { - Some(&"ro") => true, - None => false, - Some(flag) => { - return Err(AgentdError::Init(format!( - "MSB_FILE_MOUNTS unknown flag '{flag}' (expected 'ro')" - ))); - } - }; - - if parts.len() > 4 { - return Err(AgentdError::Init(format!( - "MSB_FILE_MOUNTS entry has too many parts: {entry}" - ))); - } - - if tag.is_empty() { - return Err(AgentdError::Init( - "MSB_FILE_MOUNTS entry has empty tag".into(), - )); - } - if filename.is_empty() { - return Err(AgentdError::Init( - "MSB_FILE_MOUNTS entry has empty filename".into(), - )); - } - if guest_path.is_empty() || !guest_path.starts_with('/') { - return Err(AgentdError::Init(format!( - "MSB_FILE_MOUNTS guest path must be absolute: {guest_path}" - ))); - } - - Ok(FileMountSpec { - tag, - filename, - guest_path, - readonly, - }) -} - fn ensure_scripts_profile_block(profile: &str) -> String { const START_MARKER: &str = "# >>> microsandbox scripts path >>>"; const END_MARKER: &str = "# <<< microsandbox scripts path <<<"; @@ -264,10 +73,9 @@ mod linux { unistd::{chdir, chroot, mkdir}, }; + use crate::config::{BlockRootSpec, DirMountSpec, FileMountSpec, TmpfsSpec}; use crate::error::{AgentdError, AgentdResult}; - use super::TmpfsSpec; - /// Mounts essential Linux filesystems. pub fn mount_filesystems() -> AgentdResult<()> { // /dev — devtmpfs @@ -357,25 +165,18 @@ mod linux { Ok(()) } - /// Mounts a block device as the new root filesystem, if `MSB_BLOCK_ROOT` is set. + /// Mounts a block device as the new root filesystem. /// /// Steps: mount block device at `/newroot`, bind-mount `/.msb` into it, /// pivot via `MS_MOVE` + `chroot`, then re-mount essential filesystems. - pub fn mount_block_root() -> AgentdResult<()> { - let val = match std::env::var(microsandbox_protocol::ENV_BLOCK_ROOT) { - Ok(v) if !v.is_empty() => v, - _ => return Ok(()), - }; - - let spec = super::parse_block_root(&val)?; - + pub fn mount_block_root(spec: &BlockRootSpec) -> AgentdResult<()> { // Create the temporary mount point. mkdir_ignore_exists("/newroot")?; // Mount the block device. - if let Some(fstype) = spec.fstype { + if let Some(fstype) = spec.fstype.as_deref() { mount( - Some(spec.device), + Some(spec.device.as_str()), "/newroot", Some(fstype), MsFlags::empty(), @@ -388,7 +189,7 @@ mod linux { )) })?; } else { - try_mount(spec.device, "/newroot")?; + try_mount(&spec.device, "/newroot")?; } // Bind-mount the runtime filesystem into the new root. @@ -455,35 +256,17 @@ mod linux { ))) } - /// Reads `MSB_DIR_MOUNTS` env var and mounts each virtiofs directory volume. - /// - /// For each entry, creates the guest mount point directory and mounts the - /// virtiofs share using the tag provided by the host. If the entry - /// specifies `:ro`, the mount is made read-only via `MS_RDONLY`. - /// - /// Missing env var is not an error (no directory volume mounts requested). - /// Parse failures and mount failures are hard errors. - pub fn apply_dir_mounts() -> AgentdResult<()> { - let val = match std::env::var(microsandbox_protocol::ENV_DIR_MOUNTS) { - Ok(v) if !v.is_empty() => v, - _ => return Ok(()), - }; - - for entry in val.split(';') { - if entry.is_empty() { - continue; - } - - let spec = super::parse_dir_mount_entry(entry)?; - mount_dir(&spec)?; + /// Mounts each virtiofs directory volume from the parsed specs. + pub fn apply_dir_mounts(specs: &[DirMountSpec]) -> AgentdResult<()> { + for spec in specs { + mount_dir(spec)?; } - Ok(()) } /// Mounts a single virtiofs directory share from a parsed spec. - fn mount_dir(spec: &super::DirMountSpec<'_>) -> AgentdResult<()> { - let path = spec.guest_path; + fn mount_dir(spec: &DirMountSpec) -> AgentdResult<()> { + let path = spec.guest_path.as_str(); // Create the mount point directory. std::fs::create_dir_all(path) @@ -494,7 +277,14 @@ mod linux { flags |= MsFlags::MS_RDONLY; } - mount(Some(spec.tag), path, Some("virtiofs"), flags, None::<&str>).map_err(|e| { + mount( + Some(spec.tag.as_str()), + path, + Some("virtiofs"), + flags, + None::<&str>, + ) + .map_err(|e| { AgentdError::Init(format!( "failed to mount virtiofs tag '{}' at {path}: {e}", spec.tag @@ -504,16 +294,8 @@ mod linux { Ok(()) } - /// Reads `MSB_FILE_MOUNTS` env var and bind-mounts each file. - /// - /// Missing env var is not an error (no file mounts requested). - /// Parse failures and mount failures are hard errors. - pub fn apply_file_mounts() -> AgentdResult<()> { - let val = match std::env::var(microsandbox_protocol::ENV_FILE_MOUNTS) { - Ok(v) if !v.is_empty() => v, - _ => return Ok(()), - }; - + /// Bind-mounts each file from virtiofs shares. + pub fn apply_file_mounts(specs: &[FileMountSpec]) -> AgentdResult<()> { // Create the staging root directory. std::fs::create_dir_all(microsandbox_protocol::FILE_MOUNTS_DIR).map_err(|e| { AgentdError::Init(format!( @@ -522,13 +304,8 @@ mod linux { )) })?; - for entry in val.split(';') { - if entry.is_empty() { - continue; - } - - let spec = super::parse_file_mount_entry(entry)?; - mount_file(&spec)?; + for spec in specs { + mount_file(spec)?; } // Best-effort cleanup of the staging root (succeeds only if all @@ -539,7 +316,7 @@ mod linux { } /// Mounts a single file from a virtiofs share via bind mount. - fn mount_file(spec: &super::FileMountSpec<'_>) -> AgentdResult<()> { + fn mount_file(spec: &FileMountSpec) -> AgentdResult<()> { let staging_path = format!("{}/{}", microsandbox_protocol::FILE_MOUNTS_DIR, spec.tag); // 1. Create the staging mount point directory. @@ -554,7 +331,7 @@ mod linux { } mount( - Some(spec.tag), + Some(spec.tag.as_str()), staging_path.as_str(), Some("virtiofs"), flags, @@ -568,7 +345,7 @@ mod linux { })?; // 3. Create parent directories for the guest path. - let guest = Path::new(spec.guest_path); + let guest = Path::new(&spec.guest_path); if let Some(parent) = guest.parent() { std::fs::create_dir_all(parent).map_err(|e| { AgentdError::Init(format!( @@ -583,7 +360,7 @@ mod linux { .create(true) .truncate(false) .write(true) - .open(spec.guest_path) + .open(&spec.guest_path) .map_err(|e| { AgentdError::Init(format!( "failed to create bind target {}: {e}", @@ -595,7 +372,7 @@ mod linux { let source_path = format!("{staging_path}/{}", spec.filename); mount( Some(source_path.as_str()), - spec.guest_path, + spec.guest_path.as_str(), None::<&str>, MsFlags::MS_BIND, None::<&str>, @@ -611,7 +388,7 @@ mod linux { if spec.readonly { mount( None::<&str>, - spec.guest_path, + spec.guest_path.as_str(), None::<&str>, MsFlags::MS_BIND | MsFlags::MS_REMOUNT | MsFlags::MS_RDONLY, None::<&str>, @@ -633,25 +410,11 @@ mod linux { Ok(()) } - /// Reads `MSB_TMPFS` env var and mounts each tmpfs entry. - /// - /// Missing env var is not an error (no tmpfs mounts requested). - /// Parse failures and mount failures are hard errors. - pub fn apply_tmpfs_mounts() -> AgentdResult<()> { - let val = match std::env::var(microsandbox_protocol::ENV_TMPFS) { - Ok(v) if !v.is_empty() => v, - _ => return Ok(()), - }; - - for entry in val.split(';') { - if entry.is_empty() { - continue; - } - - let spec = super::parse_tmpfs_entry(entry)?; - mount_tmpfs(&spec)?; + /// Mounts each tmpfs from the parsed specs. + pub fn apply_tmpfs_mounts(specs: &[TmpfsSpec]) -> AgentdResult<()> { + for spec in specs { + mount_tmpfs(spec)?; } - Ok(()) } @@ -663,8 +426,8 @@ mod linux { } /// Mounts a single tmpfs from a parsed spec. - fn mount_tmpfs(spec: &TmpfsSpec<'_>) -> AgentdResult<()> { - let path = spec.path; + fn mount_tmpfs(spec: &TmpfsSpec) -> AgentdResult<()> { + let path = spec.path.as_str(); // Determine the permission mode. let mode = spec @@ -797,148 +560,6 @@ mod linux { mod tests { use super::*; - #[test] - fn test_parse_path_only() { - let spec = parse_tmpfs_entry("/tmp").unwrap(); - assert_eq!(spec.path, "/tmp"); - assert_eq!(spec.size_mib, None); - assert_eq!(spec.mode, None); - assert!(!spec.noexec); - } - - #[test] - fn test_parse_with_size() { - let spec = parse_tmpfs_entry("/tmp,size=256").unwrap(); - assert_eq!(spec.path, "/tmp"); - assert_eq!(spec.size_mib, Some(256)); - } - - #[test] - fn test_parse_with_noexec() { - let spec = parse_tmpfs_entry("/tmp,noexec").unwrap(); - assert_eq!(spec.path, "/tmp"); - assert!(spec.noexec); - } - - #[test] - fn test_parse_with_octal_mode() { - let spec = parse_tmpfs_entry("/tmp,mode=1777").unwrap(); - assert_eq!(spec.mode, Some(0o1777)); - - let spec = parse_tmpfs_entry("/data,mode=755").unwrap(); - assert_eq!(spec.mode, Some(0o755)); - } - - #[test] - fn test_parse_multi_options() { - let spec = parse_tmpfs_entry("/tmp,size=256,mode=1777,noexec").unwrap(); - assert_eq!(spec.path, "/tmp"); - assert_eq!(spec.size_mib, Some(256)); - assert_eq!(spec.mode, Some(0o1777)); - assert!(spec.noexec); - } - - #[test] - fn test_parse_unknown_option_errors() { - let err = parse_tmpfs_entry("/tmp,bogus=42").unwrap_err(); - assert!(err.to_string().contains("unknown tmpfs option")); - } - - #[test] - fn test_parse_invalid_size_errors() { - let err = parse_tmpfs_entry("/tmp,size=abc").unwrap_err(); - assert!(err.to_string().contains("invalid tmpfs size")); - } - - #[test] - fn test_parse_invalid_mode_errors() { - let err = parse_tmpfs_entry("/tmp,mode=zzz").unwrap_err(); - assert!(err.to_string().contains("invalid octal tmpfs mode")); - } - - #[test] - fn test_parse_empty_path_errors() { - let err = parse_tmpfs_entry(",size=256").unwrap_err(); - assert!(err.to_string().contains("empty path")); - } - - #[test] - fn test_parse_block_root_device_only() { - let spec = parse_block_root("/dev/vda").unwrap(); - assert_eq!(spec.device, "/dev/vda"); - assert_eq!(spec.fstype, None); - } - - #[test] - fn test_parse_block_root_with_fstype() { - let spec = parse_block_root("/dev/vda,fstype=ext4").unwrap(); - assert_eq!(spec.device, "/dev/vda"); - assert_eq!(spec.fstype, Some("ext4")); - } - - #[test] - fn test_parse_block_root_empty_device_errors() { - let err = parse_block_root(",fstype=ext4").unwrap_err(); - assert!(err.to_string().contains("empty device path")); - } - - #[test] - fn test_parse_block_root_unknown_option_errors() { - let err = parse_block_root("/dev/vda,bogus=42").unwrap_err(); - assert!(err.to_string().contains("unknown MSB_BLOCK_ROOT option")); - } - - #[test] - fn test_parse_block_root_empty_fstype_errors() { - let err = parse_block_root("/dev/vda,fstype=").unwrap_err(); - assert!(err.to_string().contains("empty fstype")); - } - - #[test] - fn test_parse_file_mount_entry_basic() { - let spec = parse_file_mount_entry("fm_config:app.conf:/etc/app.conf").unwrap(); - assert_eq!(spec.tag, "fm_config"); - assert_eq!(spec.filename, "app.conf"); - assert_eq!(spec.guest_path, "/etc/app.conf"); - assert!(!spec.readonly); - } - - #[test] - fn test_parse_file_mount_entry_readonly() { - let spec = parse_file_mount_entry("fm_config:app.conf:/etc/app.conf:ro").unwrap(); - assert!(spec.readonly); - } - - #[test] - fn test_parse_file_mount_entry_too_few_parts() { - assert!(parse_file_mount_entry("fm_config:/etc/app.conf").is_err()); - } - - #[test] - fn test_parse_file_mount_entry_empty_filename() { - assert!(parse_file_mount_entry("fm_config::/etc/app.conf").is_err()); - } - - #[test] - fn test_parse_file_mount_entry_relative_path() { - assert!(parse_file_mount_entry("fm_config:app.conf:relative/path").is_err()); - } - - #[test] - fn test_parse_file_mount_entry_too_many_parts() { - assert!(parse_file_mount_entry("fm_config:app.conf:/etc/app.conf:ro:extra").is_err()); - } - - #[test] - fn test_parse_file_mount_entry_unknown_flag() { - assert!(parse_file_mount_entry("fm_config:app.conf:/etc/app.conf:rw").is_err()); - } - - #[test] - fn test_parse_file_mount_entry_empty_tag() { - assert!(parse_file_mount_entry(":app.conf:/etc/app.conf").is_err()); - } - #[test] fn test_ensure_scripts_profile_block_appends_block() { let updated = ensure_scripts_profile_block("export PATH=/usr/bin:/bin\n"); diff --git a/crates/agentd/lib/lib.rs b/crates/agentd/lib/lib.rs index 0082fa58..cecd176c 100644 --- a/crates/agentd/lib/lib.rs +++ b/crates/agentd/lib/lib.rs @@ -6,6 +6,7 @@ #![cfg(target_os = "linux")] #![warn(missing_docs)] +mod config; mod error; //-------------------------------------------------------------------------------------------------- @@ -22,4 +23,5 @@ pub mod serial; pub mod session; pub mod tls; +pub use config::AgentdConfig; pub use error::*; diff --git a/crates/agentd/lib/network.rs b/crates/agentd/lib/network.rs index b5672d69..14a7d64b 100644 --- a/crates/agentd/lib/network.rs +++ b/crates/agentd/lib/network.rs @@ -3,91 +3,43 @@ //! Configures the guest network interface using ioctls and netlink, following //! the parameters from host. -use std::net::{Ipv4Addr, Ipv6Addr}; - -use crate::error::{AgentdError, AgentdResult}; - -//-------------------------------------------------------------------------------------------------- -// Types -//-------------------------------------------------------------------------------------------------- - -/// Parsed `MSB_NET` specification. -#[derive(Debug)] -struct NetSpec<'a> { - iface: &'a str, - mac: [u8; 6], - mtu: u16, -} - -/// Parsed `MSB_NET_IPV4` specification. -#[derive(Debug)] -struct NetIpv4Spec { - address: Ipv4Addr, - prefix_len: u8, - gateway: Ipv4Addr, - dns: Option, -} - -/// Parsed `MSB_NET_IPV6` specification. -#[derive(Debug)] -struct NetIpv6Spec { - address: Ipv6Addr, - prefix_len: u8, - gateway: Ipv6Addr, - dns: Option, -} +use crate::config::{NetIpv4Spec, NetIpv6Spec, NetSpec}; +use crate::error::AgentdResult; //-------------------------------------------------------------------------------------------------- // Functions //-------------------------------------------------------------------------------------------------- -/// Sets the guest hostname from `MSB_HOSTNAME`. +/// Sets the guest hostname. /// /// Calls `sethostname()`, writes `/etc/hostname`, and provisions /// `/etc/hosts` with localhost aliases and the hostname entry. -pub fn apply_hostname() -> AgentdResult<()> { - let hostname = match std::env::var(microsandbox_protocol::ENV_HOSTNAME) { - Ok(v) if !v.is_empty() => Some(v), - _ => None, - }; - - linux::write_hosts_file(hostname.as_deref())?; +pub(crate) fn apply_hostname(hostname: Option<&str>) -> AgentdResult<()> { + linux::write_hosts_file(hostname)?; - if let Some(ref name) = hostname { + if let Some(name) = hostname { linux::set_hostname(name)?; } Ok(()) } -/// Applies network configuration from `MSB_NET*` environment variables. +/// Applies network configuration. /// /// Always provisions loopback, even when no external network interface is -/// requested. Missing `MSB_NET` is not an error (no networking requested). -/// Parse failures and configuration failures are hard errors. -pub fn apply_network_config() -> AgentdResult<()> { +/// requested. Missing `net` is not an error (no networking requested). +pub(crate) fn apply_network_config( + net: Option<&NetSpec>, + net_ipv4: Option<&NetIpv4Spec>, + net_ipv6: Option<&NetIpv6Spec>, +) -> AgentdResult<()> { linux::configure_loopback()?; - let val = match std::env::var(microsandbox_protocol::ENV_NET) { - Ok(v) if !v.is_empty() => v, - _ => return Ok(()), + let Some(net) = net else { + return Ok(()); }; - let net = parse_net(&val)?; - - // Parse optional IPv4 config. - let ipv4 = match std::env::var(microsandbox_protocol::ENV_NET_IPV4) { - Ok(v) if !v.is_empty() => Some(parse_net_ipv4(&v)?), - _ => None, - }; - - // Parse optional IPv6 config. - let ipv6 = match std::env::var(microsandbox_protocol::ENV_NET_IPV6) { - Ok(v) if !v.is_empty() => Some(parse_net_ipv6(&v)?), - _ => None, - }; - - linux::configure_interface(&net, ipv4.as_ref(), ipv6.as_ref()) + linux::configure_interface(net, net_ipv4, net_ipv6) } fn hosts_file_contents(hostname: Option<&str>) -> String { @@ -112,172 +64,6 @@ fn hosts_file_contents(hostname: Option<&str>) -> String { s } -/// Parses `MSB_NET` value: `iface=NAME,mac=AA:BB:CC:DD:EE:FF,mtu=N` -fn parse_net(val: &str) -> AgentdResult> { - let mut iface = None; - let mut mac = None; - let mut mtu = 1500u16; - - for part in val.split(',') { - if let Some(v) = part.strip_prefix("iface=") { - iface = Some(v); - } else if let Some(v) = part.strip_prefix("mac=") { - mac = Some(parse_mac(v)?); - } else if let Some(v) = part.strip_prefix("mtu=") { - mtu = v - .parse() - .map_err(|_| AgentdError::Init(format!("invalid MTU: {v}")))?; - } else { - return Err(AgentdError::Init(format!("unknown MSB_NET option: {part}"))); - } - } - - let iface = iface.ok_or_else(|| AgentdError::Init("MSB_NET missing iface=".into()))?; - let mac = mac.ok_or_else(|| AgentdError::Init("MSB_NET missing mac=".into()))?; - - Ok(NetSpec { iface, mac, mtu }) -} - -/// Parses `MSB_NET_IPV4` value: `addr=A.B.C.D/N,gw=A.B.C.D[,dns=A.B.C.D]` -fn parse_net_ipv4(val: &str) -> AgentdResult { - let mut address = None; - let mut prefix_len = None; - let mut gateway = None; - let mut dns = None; - - for part in val.split(',') { - if let Some(v) = part.strip_prefix("addr=") { - let (addr, prefix) = parse_cidr_v4(v)?; - address = Some(addr); - prefix_len = Some(prefix); - } else if let Some(v) = part.strip_prefix("gw=") { - gateway = Some( - v.parse::() - .map_err(|_| AgentdError::Init(format!("invalid IPv4 gateway: {v}")))?, - ); - } else if let Some(v) = part.strip_prefix("dns=") { - dns = Some( - v.parse::() - .map_err(|_| AgentdError::Init(format!("invalid IPv4 DNS: {v}")))?, - ); - } else { - return Err(AgentdError::Init(format!( - "unknown MSB_NET_IPV4 option: {part}" - ))); - } - } - - let address = address.ok_or_else(|| AgentdError::Init("MSB_NET_IPV4 missing addr=".into()))?; - let prefix_len = - prefix_len.ok_or_else(|| AgentdError::Init("MSB_NET_IPV4 missing addr=".into()))?; - let gateway = gateway.ok_or_else(|| AgentdError::Init("MSB_NET_IPV4 missing gw=".into()))?; - - Ok(NetIpv4Spec { - address, - prefix_len, - gateway, - dns, - }) -} - -/// Parses `MSB_NET_IPV6` value: `addr=ADDR/N,gw=ADDR[,dns=ADDR]` -fn parse_net_ipv6(val: &str) -> AgentdResult { - let mut address = None; - let mut prefix_len = None; - let mut gateway = None; - let mut dns = None; - - for part in val.split(',') { - if let Some(v) = part.strip_prefix("addr=") { - let (addr, prefix) = parse_cidr_v6(v)?; - address = Some(addr); - prefix_len = Some(prefix); - } else if let Some(v) = part.strip_prefix("gw=") { - gateway = Some( - v.parse::() - .map_err(|_| AgentdError::Init(format!("invalid IPv6 gateway: {v}")))?, - ); - } else if let Some(v) = part.strip_prefix("dns=") { - dns = Some( - v.parse::() - .map_err(|_| AgentdError::Init(format!("invalid IPv6 DNS: {v}")))?, - ); - } else { - return Err(AgentdError::Init(format!( - "unknown MSB_NET_IPV6 option: {part}" - ))); - } - } - - let address = address.ok_or_else(|| AgentdError::Init("MSB_NET_IPV6 missing addr=".into()))?; - let prefix_len = - prefix_len.ok_or_else(|| AgentdError::Init("MSB_NET_IPV6 missing addr=".into()))?; - let gateway = gateway.ok_or_else(|| AgentdError::Init("MSB_NET_IPV6 missing gw=".into()))?; - - Ok(NetIpv6Spec { - address, - prefix_len, - gateway, - dns, - }) -} - -/// Parses a MAC address string like `02:5a:7b:13:01:02`. -fn parse_mac(s: &str) -> AgentdResult<[u8; 6]> { - let mut mac = [0u8; 6]; - let mut len = 0usize; - for (i, part) in s.split(':').enumerate() { - if i >= 6 { - return Err(AgentdError::Init(format!("invalid MAC address: {s}"))); - } - mac[i] = u8::from_str_radix(part, 16) - .map_err(|_| AgentdError::Init(format!("invalid MAC octet: {part}")))?; - len = i + 1; - } - if len != 6 { - return Err(AgentdError::Init(format!("invalid MAC address: {s}"))); - } - Ok(mac) -} - -/// Parses an IPv4 CIDR like `100.96.1.2/30`. -fn parse_cidr_v4(s: &str) -> AgentdResult<(Ipv4Addr, u8)> { - let (addr_str, prefix_str) = s - .split_once('/') - .ok_or_else(|| AgentdError::Init(format!("invalid IPv4 CIDR (missing /): {s}")))?; - let addr = addr_str - .parse::() - .map_err(|_| AgentdError::Init(format!("invalid IPv4 address: {addr_str}")))?; - let prefix = prefix_str - .parse::() - .map_err(|_| AgentdError::Init(format!("invalid IPv4 prefix length: {prefix_str}")))?; - if prefix > 32 { - return Err(AgentdError::Init(format!( - "IPv4 prefix length out of range (0-32): {prefix}" - ))); - } - Ok((addr, prefix)) -} - -/// Parses an IPv6 CIDR like `fd42:6d73:62:2a::2/64`. -fn parse_cidr_v6(s: &str) -> AgentdResult<(Ipv6Addr, u8)> { - let (addr_str, prefix_str) = s - .rsplit_once('/') - .ok_or_else(|| AgentdError::Init(format!("invalid IPv6 CIDR (missing /): {s}")))?; - let addr = addr_str - .parse::() - .map_err(|_| AgentdError::Init(format!("invalid IPv6 address: {addr_str}")))?; - let prefix = prefix_str - .parse::() - .map_err(|_| AgentdError::Init(format!("invalid IPv6 prefix length: {prefix_str}")))?; - if prefix > 128 { - return Err(AgentdError::Init(format!( - "IPv6 prefix length out of range (0-128): {prefix}" - ))); - } - Ok((addr, prefix)) -} - //-------------------------------------------------------------------------------------------------- // Modules //-------------------------------------------------------------------------------------------------- @@ -285,10 +71,9 @@ fn parse_cidr_v6(s: &str) -> AgentdResult<(Ipv6Addr, u8)> { mod linux { use std::net::{Ipv4Addr, Ipv6Addr}; + use crate::config::{NetIpv4Spec, NetIpv6Spec, NetSpec}; use crate::error::{AgentdError, AgentdResult}; - use super::{NetIpv4Spec, NetIpv6Spec, NetSpec}; - //---------------------------------------------------------------------------------------------- // Types //---------------------------------------------------------------------------------------------- @@ -330,14 +115,14 @@ mod linux { /// 7. Add IPv6 default route via netlink `RTM_NEWROUTE` /// 8. Write `/etc/resolv.conf` pub fn configure_interface( - net: &NetSpec<'_>, + net: &NetSpec, ipv4: Option<&NetIpv4Spec>, ipv6: Option<&NetIpv6Spec>, ) -> AgentdResult<()> { - let ifindex = get_ifindex(net.iface)?; + let ifindex = get_ifindex(&net.iface)?; - set_mac_address(net.iface, &net.mac)?; - set_mtu(net.iface, net.mtu)?; + set_mac_address(&net.iface, &net.mac)?; + set_mtu(&net.iface, net.mtu)?; if let Some(v4) = ipv4 { add_address_v4(ifindex, v4.address, v4.prefix_len)?; @@ -346,7 +131,7 @@ mod linux { add_address_v6(ifindex, v6.address, v6.prefix_len)?; } - bring_interface_up(net.iface)?; + bring_interface_up(&net.iface)?; if let Some(v4) = ipv4 { add_default_route_v4(v4.gateway)?; @@ -793,99 +578,6 @@ mod linux { mod tests { use super::*; - #[test] - fn test_parse_net_full() { - let spec = parse_net("iface=eth0,mac=02:5a:7b:13:01:02,mtu=1500").unwrap(); - assert_eq!(spec.iface, "eth0"); - assert_eq!(spec.mac, [0x02, 0x5a, 0x7b, 0x13, 0x01, 0x02]); - assert_eq!(spec.mtu, 1500); - } - - #[test] - fn test_parse_net_default_mtu() { - let spec = parse_net("iface=eth0,mac=02:00:00:00:00:01").unwrap(); - assert_eq!(spec.mtu, 1500); - } - - #[test] - fn test_parse_net_missing_iface() { - assert!(parse_net("mac=02:00:00:00:00:01").is_err()); - } - - #[test] - fn test_parse_net_missing_mac() { - assert!(parse_net("iface=eth0").is_err()); - } - - #[test] - fn test_parse_net_unknown_option() { - assert!(parse_net("iface=eth0,mac=02:00:00:00:00:01,bogus=42").is_err()); - } - - #[test] - fn test_parse_net_ipv4() { - let spec = parse_net_ipv4("addr=100.96.1.2/30,gw=100.96.1.1,dns=100.96.1.1").unwrap(); - assert_eq!(spec.address, Ipv4Addr::new(100, 96, 1, 2)); - assert_eq!(spec.prefix_len, 30); - assert_eq!(spec.gateway, Ipv4Addr::new(100, 96, 1, 1)); - assert_eq!(spec.dns, Some(Ipv4Addr::new(100, 96, 1, 1))); - } - - #[test] - fn test_parse_net_ipv4_no_dns() { - let spec = parse_net_ipv4("addr=10.0.0.2/24,gw=10.0.0.1").unwrap(); - assert_eq!(spec.dns, None); - } - - #[test] - fn test_parse_net_ipv4_missing_addr() { - assert!(parse_net_ipv4("gw=10.0.0.1").is_err()); - } - - #[test] - fn test_parse_net_ipv6() { - let spec = parse_net_ipv6( - "addr=fd42:6d73:62:2a::2/64,gw=fd42:6d73:62:2a::1,dns=fd42:6d73:62:2a::1", - ) - .unwrap(); - assert_eq!( - spec.address, - "fd42:6d73:62:2a::2".parse::().unwrap() - ); - assert_eq!(spec.prefix_len, 64); - assert_eq!( - spec.gateway, - "fd42:6d73:62:2a::1".parse::().unwrap() - ); - assert!(spec.dns.is_some()); - } - - #[test] - fn test_parse_mac_valid() { - let mac = parse_mac("02:5a:7b:13:01:02").unwrap(); - assert_eq!(mac, [0x02, 0x5a, 0x7b, 0x13, 0x01, 0x02]); - } - - #[test] - fn test_parse_mac_invalid() { - assert!(parse_mac("02:5a:7b").is_err()); - assert!(parse_mac("zz:00:00:00:00:00").is_err()); - } - - #[test] - fn test_parse_cidr_v4() { - let (addr, prefix) = parse_cidr_v4("100.96.1.2/30").unwrap(); - assert_eq!(addr, Ipv4Addr::new(100, 96, 1, 2)); - assert_eq!(prefix, 30); - } - - #[test] - fn test_parse_cidr_v6() { - let (addr, prefix) = parse_cidr_v6("fd42:6d73:62:2a::2/64").unwrap(); - assert_eq!(addr, "fd42:6d73:62:2a::2".parse::().unwrap()); - assert_eq!(prefix, 64); - } - #[test] fn test_hosts_file_without_hostname() { assert_eq!( diff --git a/crates/agentd/lib/session.rs b/crates/agentd/lib/session.rs index 9f606902..ae4cead8 100644 --- a/crates/agentd/lib/session.rs +++ b/crates/agentd/lib/session.rs @@ -96,11 +96,12 @@ impl ExecSession { id: u32, req: &ExecRequest, tx: mpsc::UnboundedSender<(u32, SessionOutput)>, + default_user: Option<&str>, ) -> AgentdResult { if req.tty { - Self::spawn_pty(id, req, tx) + Self::spawn_pty(id, req, tx, default_user) } else { - Self::spawn_pipe(id, req, tx) + Self::spawn_pipe(id, req, tx, default_user) } } @@ -160,6 +161,7 @@ impl ExecSession { id: u32, req: &ExecRequest, tx: mpsc::UnboundedSender<(u32, SessionOutput)>, + default_user: Option<&str>, ) -> AgentdResult { let pty = openpty(None, None)?; let err_pipe = new_exec_error_pipe()?; @@ -216,7 +218,7 @@ impl ExecSession { .transpose() .map_err(|e| AgentdError::ExecSession(format!("invalid cwd: {e}")))?; - let resolved_user = resolve_requested_user(req)?; + let resolved_user = resolve_requested_user(req, default_user)?; let default_home = default_home_dir(req, resolved_user.as_ref()).map(CStr::to_owned); let home_key = default_home .as_ref() @@ -346,6 +348,7 @@ impl ExecSession { id: u32, req: &ExecRequest, tx: mpsc::UnboundedSender<(u32, SessionOutput)>, + default_user: Option<&str>, ) -> AgentdResult { let mut cmd = Command::new(&req.cmd); cmd.args(&req.args) @@ -363,7 +366,7 @@ impl ExecSession { cmd.current_dir(dir); } - let resolved_user = resolve_requested_user(req)?; + let resolved_user = resolve_requested_user(req, default_user)?; if let Some(home) = default_home_dir(req, resolved_user.as_ref()) { cmd.env("HOME", home.to_string_lossy().into_owned()); } @@ -509,21 +512,18 @@ fn wait_for_exec_failure_child(pid: i32) -> AgentdResult<()> { Ok(()) } -fn resolve_requested_user(req: &ExecRequest) -> AgentdResult> { +fn resolve_requested_user( + req: &ExecRequest, + default_user: Option<&str>, +) -> AgentdResult> { let requested = req .user .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) - .map(str::to_owned) - .or_else(|| { - std::env::var(microsandbox_protocol::ENV_USER) - .ok() - .map(|value| value.trim().to_string()) - .filter(|value| !value.is_empty()) - }); - - requested.as_deref().map(resolve_user_spec).transpose() + .or(default_user); + + requested.map(resolve_user_spec).transpose() } fn resolve_user_spec(spec: &str) -> AgentdResult { @@ -939,7 +939,7 @@ mod tests { rlimits: Vec::new(), }; - let session = ExecSession::spawn(7, &req, tx).expect("spawn pty session"); + let session = ExecSession::spawn(7, &req, tx, None).expect("spawn pty session"); let mut stdout = Vec::new(); let mut exit = None; @@ -989,11 +989,7 @@ mod tests { } #[test] - fn test_request_user_overrides_env_default() { - unsafe { - std::env::set_var(microsandbox_protocol::ENV_USER, "0:0"); - } - + fn test_request_user_overrides_config_default() { let req = ExecRequest { cmd: "/bin/true".to_string(), args: Vec::new(), @@ -1006,12 +1002,31 @@ mod tests { rlimits: Vec::new(), }; - let resolved = resolve_requested_user(&req).expect("resolve requested user"); + let resolved = resolve_requested_user(&req, Some("0:0")).expect("resolve requested user"); assert_eq!(resolved.unwrap().uid, 1); + } - unsafe { - std::env::remove_var(microsandbox_protocol::ENV_USER); - } + #[test] + fn test_config_default_user_used_when_request_has_none() { + let req = ExecRequest { + cmd: "/bin/true".to_string(), + args: Vec::new(), + env: Vec::new(), + cwd: None, + user: None, + tty: false, + rows: 24, + cols: 80, + rlimits: Vec::new(), + }; + + let uid = unsafe { libc::getuid() }; + let gid = unsafe { libc::getgid() }; + let resolved = resolve_requested_user(&req, Some(&format!("{uid}:{gid}"))) + .expect("resolve with config default"); + let resolved = resolved.expect("should resolve to a user"); + assert_eq!(resolved.uid, uid); + assert_eq!(resolved.gid, gid); } #[test] @@ -1078,7 +1093,7 @@ mod tests { rlimits: Vec::new(), }; - let err = ExecSession::spawn(9, &req, tx).expect_err("spawn should fail"); + let err = ExecSession::spawn(9, &req, tx, None).expect_err("spawn should fail"); let message = err.to_string(); assert!(message.contains("spawn pipe"));