diff --git a/README.md b/README.md index ce42861..3265f20 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,7 @@ It is designed for systems that execute code you do not fully trust: - **Dual-Hypervisor Core**: Uses KVM-backed Firecracker on Linux, and native `Virtualization.framework` on macOS. - **Host-Reliant Disk Mounts**: The guest microVM has no shell, utilities, or libraries. Service code and language runtimes (Bun, Node, Deno, QuickJS) are compiled on the host and attached as read-only virtual block devices (`/dev/vdb` and `/dev/vdc`). - **VSOCK Multiplexing**: Low-latency communication handshakes stream stdout/stderr and exit codes directly back to the host via virtual sockets, bypassing network interfaces. +- **Resource Enforcement**: `memoryMb` and `cpuLimit` are applied to Firecracker machine config, while `timeoutMs` is enforced by a host-side watchdog that force-terminates timed-out VMs. - **Preflight & Metric Timelines**: Sub-millisecond logging of all VM lifecycle transitions (disk format, boot connect, execution, cleanup). ## Quick Start diff --git a/docs/architecture.md b/docs/architecture.md index 44bef38..e55951f 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -49,6 +49,7 @@ ignite-cli/ignite-http -> Host transmits JSON payload (environment, execution script) -> Guest Agent executes runtime command inside microVM sandbox -> Guest Agent streams stdout/stderr multiplexed frames over VSOCK + -> Host watchdog enforces `timeoutMs`; on timeout it force-terminates the Firecracker process -> Guest Agent captures exit code and triggers reboot(POWER_OFF) -> Host tears down hypervisor and deletes temporary UDS socket files ``` diff --git a/docs/threat-model.md b/docs/threat-model.md index a67c6b8..b7e50c3 100644 --- a/docs/threat-model.md +++ b/docs/threat-model.md @@ -11,7 +11,7 @@ Ignite aims to provide defense-in-depth for executing untrusted JS/TS code insid | Mitigate network exfiltration | VMs are started without virtual network interfaces. | | Mitigate host file tampering | App files (`/app`) and engine code (`/runtime`) are attached as read-only virtual block devices. | | Limit privilege escalation | No shell (`/bin/sh`), compiler, or system utilities exist in the guest rootfs. | -| Bound runaway processes | Memory, vCPUs, and time execution limits are enforced directly by the hypervisor process. | +| Bound runaway processes | Memory/vCPU limits are applied to Firecracker machine config, and a host watchdog force-terminates the VM when `timeoutMs` is exceeded. | | VSOCK Only handshake | Handshake and stdout/stderr pipes occur over a dedicated virtual socket (VSOCK) connection. | ## Trust Boundaries diff --git a/ignite-core/src/platform/firecracker.rs b/ignite-core/src/platform/firecracker.rs index 2c41ff4..393e0a1 100644 --- a/ignite-core/src/platform/firecracker.rs +++ b/ignite-core/src/platform/firecracker.rs @@ -23,6 +23,7 @@ const VSOCK_PORT_SUFFIX: &str = "_1052"; const API_READY_TIMEOUT_MS: u64 = 500; const API_READY_POLL_MS: u64 = 5; const GUEST_CID: u32 = 3; +const IO_POLL_TIMEOUT_MS: u64 = 50; impl Default for FirecrackerOrchestrator { fn default() -> Self { @@ -202,7 +203,7 @@ impl MicroVmOrchestrator for FirecrackerOrchestrator { on_stdout: Option>, on_stderr: Option>, ) -> Result { - let config = self.config.as_ref().ok_or_else(|| IgniteError::Config { + let config = self.config.clone().ok_or_else(|| IgniteError::Config { message: "VM not configured".to_string(), source: None, })?; @@ -222,6 +223,7 @@ impl MicroVmOrchestrator for FirecrackerOrchestrator { match listener.accept() { Ok((stream, _)) => { stream.set_nonblocking(false)?; + stream.set_read_timeout(Some(Duration::from_millis(IO_POLL_TIMEOUT_MS)))?; break stream; } Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => { @@ -244,10 +246,10 @@ impl MicroVmOrchestrator for FirecrackerOrchestrator { // 2. Transmit execution payload (length-prefixed) let payload = serde_json::json!({ - "env": config.env, - "runtime_args": config.runtime_args, - "entrypoint": config.entrypoint, - "input": config.input, + "env": &config.env, + "runtime_args": &config.runtime_args, + "entrypoint": &config.entrypoint, + "input": &config.input, }); let payload_bytes = serde_json::to_vec(&payload)?; @@ -262,19 +264,73 @@ impl MicroVmOrchestrator for FirecrackerOrchestrator { loop { let mut type_byte = [0u8; 1]; - if socket.read_exact(&mut type_byte).is_err() { - break; // VM socket disconnected + if let Err(e) = Self::read_exact_with_timeout( + &mut socket, + &mut type_byte, + start_time, + config.timeout_ms, + ) { + if e.kind() == std::io::ErrorKind::UnexpectedEof { + break; // VM socket disconnected + } + if e.kind() == std::io::ErrorKind::TimedOut { + self.cleanup_vm_processes(); + return Err(IgniteError::Execution { + message: format!( + "Execution exceeded timeoutMs ({}ms); VM force-terminated by host watchdog", + config.timeout_ms + ), + source: None, + }); + } + self.cleanup_vm_processes(); + return Err(e.into()); } let mut len_bytes = [0u8; 4]; - if socket.read_exact(&mut len_bytes).is_err() { - break; + if let Err(e) = Self::read_exact_with_timeout( + &mut socket, + &mut len_bytes, + start_time, + config.timeout_ms, + ) { + if e.kind() == std::io::ErrorKind::UnexpectedEof { + break; + } + if e.kind() == std::io::ErrorKind::TimedOut { + self.cleanup_vm_processes(); + return Err(IgniteError::Execution { + message: format!( + "Execution exceeded timeoutMs ({}ms); VM force-terminated by host watchdog", + config.timeout_ms + ), + source: None, + }); + } + self.cleanup_vm_processes(); + return Err(e.into()); } let length = u32::from_be_bytes(len_bytes) as usize; let mut data = vec![0u8; length]; - if socket.read_exact(&mut data).is_err() { - break; + if let Err(e) = + Self::read_exact_with_timeout(&mut socket, &mut data, start_time, config.timeout_ms) + { + if e.kind() == std::io::ErrorKind::UnexpectedEof { + break; + } + if e.kind() == std::io::ErrorKind::TimedOut { + self.cleanup_vm_processes(); + return Err(IgniteError::Execution { + message: format!( + "Execution exceeded timeoutMs ({}ms); VM force-terminated by host watchdog", + config.timeout_ms + ), + source: None, + }); + } + self.cleanup_vm_processes(); + return Err(e.into()); } let chunk = String::from_utf8_lossy(&data); @@ -338,4 +394,39 @@ impl FirecrackerOrchestrator { let _ = fs::remove_file(&vsock_listener_path); } } + + fn read_exact_with_timeout( + stream: &mut UnixStream, + buffer: &mut [u8], + start_time: Instant, + timeout_ms: u32, + ) -> std::io::Result<()> { + let mut read_total = 0usize; + while read_total < buffer.len() { + match stream.read(&mut buffer[read_total..]) { + Ok(0) => { + return Err(std::io::Error::new( + std::io::ErrorKind::UnexpectedEof, + "VSOCK stream closed", + )); + } + Ok(n) => { + read_total += n; + } + Err(e) + if e.kind() == std::io::ErrorKind::TimedOut + || e.kind() == std::io::ErrorKind::WouldBlock => + { + if start_time.elapsed().as_millis() > (timeout_ms as u128) { + return Err(std::io::Error::new( + std::io::ErrorKind::TimedOut, + "Execution timed out", + )); + } + } + Err(e) => return Err(e), + } + } + Ok(()) + } }