//! `ente-incarnate` — rutina extraída del Init para encarnar Cards en //! procesos aislados (clone(2) + namespaces + cgroup + rlimits + cpu affinity). //! //! El núcleo histórico vivía en `ente-soma` con globals dependientes de PID 1. //! Este crate elimina esos globals: se construye un [`Incarnator`] por //! supervisor (Init, shuma, etc.), cada uno con su propio bus socket y su //! propia política de capacidades. //! //! ## Limitaciones que NO desaparecen al extraer //! //! 1. `mount/pid/net/uts/ipc/cgroup` namespaces requieren `CAP_SYS_ADMIN` //! o estar combinados con `CLONE_NEWUSER` en el mismo `clone(2)`. //! 2. `user` namespace puede estar bloqueado por //! `kernel.unprivileged_userns_clone=0` o por LSM (apparmor/selinux). //! 3. cgroups v2 requieren delegación (sistemas modernos: systemd //! `Delegate=yes`). Sin delegación, escribir en `/sys/fs/cgroup` falla. //! 4. El primer proceso de un PID namespace es PID 1 *de ese ns*; si muere //! el kernel mata el namespace entero. //! //! [`CapabilitySet::detect`] reporta lo que está disponible runtime; //! [`Incarnator::dry_run`] valida un [`Card`] antes de ejecutar. #![doc(html_no_source)] pub mod caps; pub mod cgroup; pub mod child; pub mod env; pub mod error; pub mod namespaced; pub mod plain; pub mod pre_exec; pub use brahman_card::Card; pub use caps::{CapabilitySet, CgroupStatus, NsKind, UserNsStatus}; pub use env::{EnvSpec, ENV_BUS_SOCK, ENV_ENTE_ID}; pub use error::{Degradation, IncarnateError}; pub use pre_exec::{ChildPreExec, ChildSetup}; use std::os::fd::RawFd; /// Redirección declarativa de stdio del hijo. Cada `Some(fd)` se `dup2`-ea /// como stdin/stdout/stderr en el hijo. /// /// **Contrato de ownership**: el caller transfiere ownership de los FDs al /// `Incarnator` (igual que pasaría a `Command::stdio(Stdio::from_raw_fd)`). /// `Incarnator` se encarga de cerrarlos en el padre tras `incarnate` (path /// namespaced) o de dejar que `std::process::Command` los absorba (path /// plain). **No los cierres en el caller** — habría doble-close. /// /// Útil para conectar pipes entre procesos del pipeline de shuma sin /// romper la regla async-signal-safe del callback de clone(2). #[derive(Debug, Clone, Copy, Default)] pub struct ChildStdio { pub stdin_fd: Option, pub stdout_fd: Option, pub stderr_fd: Option, } impl ChildStdio { pub fn is_some(&self) -> bool { self.stdin_fd.is_some() || self.stdout_fd.is_some() || self.stderr_fd.is_some() } } use nix::unistd::Pid; use std::path::PathBuf; #[derive(Debug, Clone, Default)] pub struct IncarnatorConfig { /// Path del Unix socket del bus interno (se inyecta como `ENTE_BUS_SOCK`). /// `None` = no inyectar. pub bus_sock: Option, /// Inyectar `NOTIFY_SOCKET` (legacy sd_notify). Default `None`. /// `ente-zero` lo pasa = `Some("/run/systemd/notify")`. pub notify_socket: Option, /// Vars adicionales que el caller fuerza en cada hijo. pub extra_env: Vec<(String, String)>, /// Si `true`, falta de capacidades aborta `incarnate()` con error. /// Si `false`, se reportan como `Degradation` y la encarnación continúa /// con menos aislamiento (semántica histórica del Init). pub strict_caps: bool, } pub struct Incarnator { cfg: IncarnatorConfig, caps: CapabilitySet, } #[derive(Debug, Clone)] pub struct IncarnateOutcome { pub pid: Pid, pub degradations: Vec, } #[derive(Debug, Default, Clone)] pub struct ValidationReport { pub will_work: bool, pub blocking: Vec, pub warnings: Vec, } impl Incarnator { pub fn new(cfg: IncarnatorConfig) -> Self { Self { caps: CapabilitySet::detect(), cfg, } } /// Constructor para testing/inyección de capacidades pre-calculadas. pub fn with_caps(cfg: IncarnatorConfig, caps: CapabilitySet) -> Self { Self { cfg, caps } } pub fn capabilities(&self) -> &CapabilitySet { &self.caps } pub fn config(&self) -> &IncarnatorConfig { &self.cfg } /// Valida una Card sin ejecutar nada. Útil para que el caller (shuma, /// admin, tests) sepa de antemano si va a poder encarnar tal cual o si /// va a tener que aflojar el SomaSpec. pub fn dry_run(&self, card: &Card) -> ValidationReport { let mut r = ValidationReport { will_work: true, ..Default::default() }; let ns = &card.soma.namespaces; // Si user_ns está pedido, evaluar su disponibilidad. if ns.user { match self.caps.user_ns { UserNsStatus::DisabledBySysctl => { r.blocking.push( "user namespace requested but kernel.unprivileged_userns_clone=0".into(), ); r.will_work = false; } UserNsStatus::RestrictedByLsm => { r.blocking.push( "user namespace restricted by LSM (apparmor/selinux)".into(), ); r.will_work = false; } _ => {} } } // El resto de namespaces necesitan CAP_SYS_ADMIN o user ns. let needs_priv = [ (ns.mount, NsKind::Mount), (ns.pid, NsKind::Pid), (ns.net, NsKind::Net), (ns.uts, NsKind::Uts), (ns.ipc, NsKind::Ipc), (ns.cgroup, NsKind::Cgroup), ]; for (wanted, kind) in needs_priv { if wanted && !self.caps.can_create_ns(kind) { r.blocking.push(format!( "{} namespace requires CAP_SYS_ADMIN or user ns (neither available)", kind.name() )); r.will_work = false; } } // Cgroup: si el card pide path, chequear que tengamos delegación. if !card.soma.cgroup.path.is_empty() && !self.caps.cgroup_delegated { r.warnings.push(format!( "cgroup `{}` requested but our cgroup is not writable (delegation missing)", card.soma.cgroup.path )); } // Payload ejecutable. use brahman_card::Payload; if !matches!(card.payload, Payload::Native { .. } | Payload::Legacy { .. }) { r.blocking .push("payload is not Native/Legacy (use ente-wasm for Wasm)".into()); r.will_work = false; } r } /// Encarna la Card. Si `strict_caps`, valida primero y aborta ante /// blocking. Si no, ejecuta y deja que las degradaciones se acumulen. pub fn incarnate(&self, card: &Card) -> Result { self.incarnate_with(card, ChildStdio::default()) } /// Variante con redirección de stdio declarativa. Útil para conectar /// pipes entre procesos (caso: pipeline aislado). pub fn incarnate_with( &self, card: &Card, stdio: ChildStdio, ) -> Result { self.incarnate_full(card, stdio, ChildSetup::default()) } /// Variante full: stdio + setup pre-execve. pub fn incarnate_full( &self, card: &Card, stdio: ChildStdio, setup: ChildSetup, ) -> Result { if self.cfg.strict_caps { let v = self.dry_run(card); if !v.will_work { // Mapeamos el primer blocking a IncarnateError tipado. if let Some(first) = v.blocking.first() { if first.contains("unprivileged_userns_clone") { return Err(IncarnateError::UserNsDisabledBySysctl); } if first.contains("LSM") { return Err(IncarnateError::UserNsRestrictedByLsm); } if let Some(ns) = which_ns_blocking(first) { return Err(IncarnateError::NamespaceCapMissing { ns }); } if first.contains("payload") { return Err(IncarnateError::NonExecutablePayload); } } } } let env_spec = EnvSpec { bus_sock: self.cfg.bus_sock.clone(), notify_socket: self.cfg.notify_socket.clone(), extra: self.cfg.extra_env.clone(), }; let mut degradations = Vec::new(); let pid = if namespaced::needs_namespacing(&card.soma.namespaces) { namespaced::incarnate_namespaced(card, &env_spec, &stdio, &setup, &mut degradations)? } else { plain::incarnate_plain(card, &env_spec, &stdio, &setup)? }; Ok(IncarnateOutcome { pid, degradations }) } } fn which_ns_blocking(msg: &str) -> Option<&'static str> { for n in ["mount", "pid", "net", "uts", "ipc", "user", "cgroup"] { if msg.starts_with(n) { return Some(match n { "mount" => "mount", "pid" => "pid", "net" => "net", "uts" => "uts", "ipc" => "ipc", "user" => "user", "cgroup" => "cgroup", _ => unreachable!(), }); } } None } #[cfg(test)] mod tests { use super::*; use brahman_card::{Card, NamespaceSet, Payload}; fn make_card(payload: Payload, ns: NamespaceSet) -> Card { let mut c = Card::new("test"); c.payload = payload; c.soma.namespaces = ns; c } #[test] fn dry_run_native_no_ns_works() { let inc = Incarnator::new(IncarnatorConfig::default()); let card = make_card( Payload::Native { exec: "/bin/true".into(), argv: vec![], envp: vec![], }, NamespaceSet::default(), ); let r = inc.dry_run(&card); assert!(r.will_work, "{:?}", r); } #[test] fn dry_run_wasm_payload_blocks() { let inc = Incarnator::new(IncarnatorConfig::default()); let card = make_card( Payload::Wasm { module_sha256: [0u8; 32], entry: "main".into(), }, NamespaceSet::default(), ); let r = inc.dry_run(&card); assert!(!r.will_work); assert!(r.blocking.iter().any(|m| m.contains("payload"))); } /// Smoke: redirección stdout via ChildStdio en path plain. /// Lanza /bin/echo con stdout conectado a un pipe que leemos. #[test] fn incarnate_with_stdout_redirection_captures_output() { use nix::fcntl::OFlag; use nix::unistd::{pipe2, read}; use std::os::fd::{AsRawFd, IntoRawFd}; let inc = Incarnator::new(IncarnatorConfig::default()); let card = make_card( Payload::Native { exec: "/bin/echo".into(), argv: vec!["shuma-stdio".into()], envp: vec![], }, NamespaceSet::default(), ); let (r, w) = pipe2(OFlag::empty()).expect("pipe"); let w_raw = w.into_raw_fd(); let r_raw = r.as_raw_fd(); let stdio = ChildStdio { stdin_fd: None, stdout_fd: Some(w_raw), stderr_fd: None, }; let out = inc.incarnate_with(&card, stdio).expect("incarnate"); // Cerramos nuestro extremo de write (el hijo tiene su dup2). // Plain path: Command toma ownership y cierra al spawn. // Namespaced path: el padre todavía tiene una copia... pero en plain // no aplica. Para este test usamos plain (NamespaceSet vacío). // Cosechamos para no zombi. let _ = nix::sys::wait::waitpid(out.pid, None); // Leemos la salida. let mut buf = [0u8; 64]; let n = read(r_raw, &mut buf).expect("read"); assert!(n > 0); let s = std::str::from_utf8(&buf[..n]).unwrap(); assert!(s.contains("shuma-stdio"), "got: {s:?}"); // r se cierra al drop del OwnedFd. } /// child_pre_exec aplica chdir + NoNewPrivs en path plain. #[test] fn child_pre_exec_chdir_changes_pwd() { use crate::{ChildPreExec, ChildSetup}; use nix::fcntl::OFlag; use nix::unistd::{pipe2, read}; use std::ffi::CString; use std::os::fd::{AsRawFd, IntoRawFd}; let inc = Incarnator::new(IncarnatorConfig::default()); // Comando: /bin/pwd. Si chdir funciona, output = /tmp. let card = make_card( Payload::Native { exec: "/bin/pwd".into(), argv: vec![], envp: vec![], }, NamespaceSet::default(), ); let (r, w) = pipe2(OFlag::empty()).expect("pipe"); let w_raw = w.into_raw_fd(); let r_raw = r.as_raw_fd(); let stdio = ChildStdio { stdin_fd: None, stdout_fd: Some(w_raw), stderr_fd: None, }; let setup = ChildSetup::new() .with(ChildPreExec::Chdir(CString::new("/tmp").unwrap())) .with(ChildPreExec::NoNewPrivs); let out = inc.incarnate_full(&card, stdio, setup).expect("incarnate"); let _ = nix::sys::wait::waitpid(out.pid, None); let mut buf = [0u8; 64]; let n = read(r_raw, &mut buf).expect("read"); let s = std::str::from_utf8(&buf[..n]).unwrap(); assert!(s.starts_with("/tmp"), "pwd output was: {s:?}"); } /// Smoke: encarnar /bin/true sin ns. No requiere root. #[test] fn incarnate_plain_true_succeeds() { let inc = Incarnator::new(IncarnatorConfig::default()); let card = make_card( Payload::Native { exec: "/bin/true".into(), argv: vec![], envp: vec![], }, NamespaceSet::default(), ); let out = inc.incarnate(&card).expect("plain incarnation"); assert!(out.pid.as_raw() > 0); // Cosechamos para no dejar zombi. let _ = nix::sys::wait::waitpid(out.pid, None); } }