//! Encarnación del Soma: traducción de SomaSpec a syscalls. //! //! Esta capa es la única parte de PID 1 que toca syscalls de namespacing — //! todo lo demás opera sobre tipos de alto nivel. La complejidad vive aquí //! por diseño: encapsulada, auditable, y con un único punto de entrada. //! //! ## Protocolo padre↔hijo en el path namespaced //! //! ```text //! parent child //! | | //! |--- clone() ------->| (child empieza dentro de los nuevos NS) //! | | //! | |---- read(sync_r, 1) ---- (bloquea) //! | | //! | write uid_map | //! | write gid_map | //! | cgroup move | //! | cpu affinity | //! | | //! |--- write(sync_w) ->| //! | |---- setrlimit //! | |---- mount(/, MS_PRIVATE | MS_REC) //! | |---- execve() //! ``` use ente_card::{CgroupSpec, EntityCard, NamespaceSet, Payload, ResourceLimits}; use nix::fcntl::OFlag; use nix::sched::CloneFlags; use nix::unistd::{pipe2, Pid}; use std::ffi::CString; use std::os::fd::{AsRawFd, IntoRawFd, RawFd}; use std::process::Command; use std::sync::OnceLock; use tracing::{info, warn}; /// Path del socket del bus interno. Se establece una sola vez al arrancar /// PID 1 (después de que el listener bind exitoso). Cada hijo encarnado /// recibe este path en `ENTE_BUS_SOCK`. static BUS_SOCK_PATH: OnceLock = OnceLock::new(); pub fn set_bus_sock(path: String) { let _ = BUS_SOCK_PATH.set(path); } fn build_env(card: &EntityCard, base_envp: &[(String, String)]) -> Vec<(String, String)> { // Heredamos parent env, sobreescribimos con el envp explícito de la Card, // y al final inyectamos las vars del fractal (no negociables). let mut env: Vec<(String, String)> = std::env::vars().collect(); for (k, v) in base_envp { env.retain(|(ek, _)| ek != k); env.push((k.clone(), v.clone())); } if let Some(p) = BUS_SOCK_PATH.get() { env.retain(|(k, _)| k != ente_bus::ENV_BUS_SOCK); env.push((ente_bus::ENV_BUS_SOCK.into(), p.clone())); } env.retain(|(k, _)| k != ente_bus::ENV_ENTE_ID); env.push((ente_bus::ENV_ENTE_ID.into(), card.id.to_string())); // Apps `Type=notify` (sd_notify) leen NOTIFY_SOCKET. Apuntamos al path // canónico de systemd; si ente-notify-compat no está corriendo, apps // sólo verán que sd_notify falla y siguen sin "ready" signal — no es fatal. env.retain(|(k, _)| k != "NOTIFY_SOCKET"); env.push(("NOTIFY_SOCKET".into(), "/run/systemd/notify".into())); env } pub fn incarnate(card: &EntityCard) -> anyhow::Result { if needs_namespacing(&card.soma.namespaces) { incarnate_namespaced(card) } else { incarnate_plain(card) } } fn needs_namespacing(ns: &NamespaceSet) -> bool { ns.mount || ns.pid || ns.net || ns.uts || ns.ipc || ns.user || ns.cgroup } /// Path simple: para Entes que no requieren aislamiento. Útil para Entes-shim /// que conviven con el host (e.g. compat-logind) y para dev mode. fn incarnate_plain(card: &EntityCard) -> anyhow::Result { let (exec, argv, base_envp) = match &card.payload { Payload::Native { exec, argv, envp } => (exec.clone(), argv.clone(), envp.clone()), Payload::Legacy { exec, argv, .. } => (exec.clone(), argv.clone(), Vec::new()), _ => anyhow::bail!("incarnate_plain: payload no ejecutable"), }; let env = build_env(card, &base_envp); let mut cmd = Command::new(&exec); cmd.args(&argv); cmd.env_clear(); for (k, v) in &env { cmd.env(k, v); } let child = cmd.spawn().map_err(|e| anyhow::anyhow!("spawn {exec}: {e}"))?; Ok(Pid::from_raw(child.id() as i32)) } /// Path namespaced: clone(2) + sync pipe + setup post-clone en padre + finalize en hijo. fn incarnate_namespaced(card: &EntityCard) -> anyhow::Result { let flags = build_clone_flags(&card.soma.namespaces); info!(label = %card.label, ?flags, "namespaced incarnation"); let (exec, argv, base_envp) = match &card.payload { Payload::Native { exec, argv, envp } => (exec.clone(), argv.clone(), envp.clone()), Payload::Legacy { exec, argv, .. } => (exec.clone(), argv.clone(), Vec::new()), _ => anyhow::bail!("incarnate_namespaced: payload no ejecutable"), }; // Pipe O_CLOEXEC: el read del lado hijo es lo que hace race-free el setup. // O_CLOEXEC garantiza que el fd se cierra automáticamente en execve, así // no contamina el binario final. let (sync_r, sync_w) = pipe2(OFlag::O_CLOEXEC)?; let sync_r_raw: RawFd = sync_r.into_raw_fd(); let sync_w_raw: RawFd = sync_w.into_raw_fd(); let exec_c = CString::new(exec.clone())?; let argv_c: Vec = std::iter::once(exec_c.clone()) .chain(argv.iter().filter_map(|s| CString::new(s.as_str()).ok())) .collect(); let argv_ptrs: Vec<*const libc::c_char> = argv_c.iter() .map(|c| c.as_ptr()) .chain(std::iter::once(std::ptr::null())) .collect(); // envp construido pre-clone: padre y hijo comparten el COW. Tras execve // el kernel reemplaza el address space, así que las CStrings sólo viven // hasta el syscall. let env_pairs = build_env(card, &base_envp); let envp_c: Vec = env_pairs.iter() .filter_map(|(k, v)| CString::new(format!("{k}={v}")).ok()) .collect(); let envp_ptrs: Vec<*const libc::c_char> = envp_c.iter() .map(|c| c.as_ptr()) .chain(std::iter::once(std::ptr::null())) .collect(); let rlimits = card.soma.rlimits.clone(); let mount_ns_enabled = card.soma.namespaces.mount; // SAFETY: la clausura corre en stack nuevo dentro de un proceso recién // clonado, COW del padre. Reglas inviolables: // - sólo syscalls async-signal-safe // - no `println!`/`tracing!`/cualquier I/O del runtime // - no allocator (vec/box/string) // - no Drop con efectos // - capturar sólo Copy o datos pre-construidos let cb = Box::new(move || -> isize { // 1) Cerrar el extremo de escritura: pertenece al padre. unsafe { libc::close(sync_w_raw); } // 2) Bloquear hasta que el padre termine el setup (uid_map, cgroup, etc). let mut byte = [0u8; 1]; let n = unsafe { libc::read(sync_r_raw, byte.as_mut_ptr() as *mut _, 1) }; if n != 1 { unsafe { libc::_exit(101); } } unsafe { libc::close(sync_r_raw); } // 3) Aplicar rlimits dentro del nuevo namespace. unsafe { apply_rlimits_unchecked(&rlimits); } // 4) Si tenemos mount ns, marcar / como privado recursivamente para // que mounts del Ente no se filtren al host (es la trampa más // típica al delegar mount ns). if mount_ns_enabled { unsafe { libc::mount( std::ptr::null(), b"/\0".as_ptr() as *const _, std::ptr::null(), libc::MS_PRIVATE | libc::MS_REC, std::ptr::null(), ); } } // 5) execve. Si retorna, falló. unsafe { libc::execve(exec_c.as_ptr(), argv_ptrs.as_ptr(), envp_ptrs.as_ptr()); libc::_exit(102); } }); let mut stack = vec![0u8; 1024 * 1024]; #[allow(deprecated)] let pid = unsafe { nix::sched::clone(cb, &mut stack, flags, Some(libc::SIGCHLD)) }.map_err(|e| { unsafe { libc::close(sync_r_raw); libc::close(sync_w_raw); } anyhow::anyhow!("clone failed: {e}") })?; // Padre: cerrar el extremo de lectura. unsafe { libc::close(sync_r_raw); } // Setup post-clone en padre. Errores aquí no son fatales — registramos y // continuamos. Si algo crítico falla, el hijo execve seguirá adelante con // configuración degradada y el supervisor decidirá qué hacer. if let Err(e) = configure_child(pid, card) { warn!(?e, ?pid, "configure_child errores no-fatales"); } // Despertar al hijo. let signal_byte = [b'x']; let written = unsafe { libc::write(sync_w_raw, signal_byte.as_ptr() as *const _, 1) }; unsafe { libc::close(sync_w_raw); } if written != 1 { warn!(?pid, "no se pudo señalizar al hijo (write devolvió {})", written); } if matches!(&card.payload, Payload::Legacy { fakes, .. } if !fakes.is_empty()) { // TODO: facades viven en un Ente-shim aparte que se inyecta vía // bind-mount sobre /run/systemd/notify, /run/dbus/system_bus_socket, // etc. Cuando exista, registrarlas aquí. warn!("legacy facades declaradas pero shim post-clone no implementado"); } Ok(pid) } /// Setup que requiere capacidades del padre: uid_map, gid_map, cgroup move. /// Estos archivos en /proc//* tienen reglas de propiedad que sólo el /// padre puede satisfacer mientras el hijo está suspendido en el sync pipe. fn configure_child(pid: Pid, card: &EntityCard) -> anyhow::Result<()> { if card.soma.namespaces.user { // Desde kernel 3.19 se debe escribir "deny" a setgroups antes de // poder escribir gid_map sin CAP_SETGID. Ignorar errores: en kernels // antiguos el archivo no existe y no es problema. let _ = std::fs::write(format!("/proc/{}/setgroups", pid.as_raw()), "deny"); let uid = nix::unistd::getuid().as_raw(); let gid = nix::unistd::getgid().as_raw(); std::fs::write( format!("/proc/{}/uid_map", pid.as_raw()), format!("0 {uid} 1"), ).map_err(|e| anyhow::anyhow!("write uid_map: {e}"))?; std::fs::write( format!("/proc/{}/gid_map", pid.as_raw()), format!("0 {gid} 1"), ).map_err(|e| anyhow::anyhow!("write gid_map: {e}"))?; } if !card.soma.cgroup.path.is_empty() { match ensure_cgroup(&card.soma.cgroup) { Ok(abs_path) => { let procs = format!("{abs_path}/cgroup.procs"); if let Err(e) = std::fs::write(&procs, format!("{}\n", pid.as_raw())) { warn!(?e, path = %procs, "cgroup move falló"); } } Err(e) => warn!(?e, path = %card.soma.cgroup.path, "ensure_cgroup falló"), } } if let Some(cpus) = &card.soma.cpu_affinity { if let Err(e) = set_cpu_affinity(pid, cpus) { warn!(?e, ?pid, "sched_setaffinity falló"); } } Ok(()) } fn set_cpu_affinity(pid: Pid, cpus: &[u32]) -> anyhow::Result<()> { let mut set: libc::cpu_set_t = unsafe { std::mem::zeroed() }; unsafe { libc::CPU_ZERO(&mut set); } for &c in cpus { unsafe { libc::CPU_SET(c as usize, &mut set); } } let r = unsafe { libc::sched_setaffinity(pid.as_raw(), std::mem::size_of::(), &set) }; if r != 0 { anyhow::bail!("sched_setaffinity: {}", std::io::Error::last_os_error()); } Ok(()) } /// SAFETY: invocada en el hijo post-clone, sólo libc, no Rust I/O. unsafe fn apply_rlimits_unchecked(rl: &ResourceLimits) { if let Some(mem) = rl.mem_bytes { let lim = libc::rlimit { rlim_cur: mem, rlim_max: mem }; libc::setrlimit(libc::RLIMIT_AS, &lim); } if let Some(np) = rl.nproc { let lim = libc::rlimit { rlim_cur: np as u64, rlim_max: np as u64 }; libc::setrlimit(libc::RLIMIT_NPROC, &lim); } if let Some(nf) = rl.nofile { let lim = libc::rlimit { rlim_cur: nf as u64, rlim_max: nf as u64 }; libc::setrlimit(libc::RLIMIT_NOFILE, &lim); } } /// Cgroup actual del proceso PID 1 (o ente-zero en dev). Lo usamos como /// prefijo para paths declarados relativos en CgroupSpec.path. En prod (PID 1 /// como child del kernel) será `/`. En dev bajo systemd-user será algo como /// `/user.slice/user-1001.slice/user@1001.service/...`. fn current_cgroup() -> Option { let s = std::fs::read_to_string("/proc/self/cgroup").ok()?; // Formato unified (cgroup v2): "0::/user.slice/..." s.lines() .find_map(|l| l.strip_prefix("0::")) .map(|s| s.trim().to_string()) } /// Resuelve un path declarado en CgroupSpec contra la jerarquía real. /// - path absoluto (empieza con `/`): respetar tal cual /// - path relativo: prefijar con cgroup actual de PID 1 fn resolve_cgroup_path(spec_path: &str) -> String { if spec_path.is_empty() { return String::new(); } if spec_path.starts_with('/') { return spec_path.to_string(); } let trimmed = spec_path.trim_start_matches('/'); if let Some(cg) = current_cgroup() { let base = if cg == "/" { String::new() } else { cg.trim_end_matches('/').to_string() }; format!("{base}/{trimmed}") } else { format!("/{trimmed}") } } /// Crea el cgroup declarado, aplica weights. Devuelve el path absoluto /// resultante bajo /sys/fs/cgroup. fn ensure_cgroup(spec: &CgroupSpec) -> anyhow::Result { let rel = resolve_cgroup_path(&spec.path); if rel.is_empty() { anyhow::bail!("cgroup path vacío"); } let abs = format!("/sys/fs/cgroup{}", rel); std::fs::create_dir_all(&abs) .map_err(|e| anyhow::anyhow!("mkdir {}: {e}", abs))?; if let Some(w) = spec.cpu_weight { let _ = std::fs::write(format!("{abs}/cpu.weight"), format!("{w}\n")); } if let Some(w) = spec.io_weight { // io.weight requiere el formato "default " en cgroup v2. let _ = std::fs::write(format!("{abs}/io.weight"), format!("default {w}\n")); } Ok(abs) } fn build_clone_flags(ns: &NamespaceSet) -> CloneFlags { let mut f = CloneFlags::empty(); if ns.mount { f |= CloneFlags::CLONE_NEWNS; } if ns.pid { f |= CloneFlags::CLONE_NEWPID; } if ns.net { f |= CloneFlags::CLONE_NEWNET; } if ns.uts { f |= CloneFlags::CLONE_NEWUTS; } if ns.ipc { f |= CloneFlags::CLONE_NEWIPC; } if ns.user { f |= CloneFlags::CLONE_NEWUSER; } if ns.cgroup { f |= CloneFlags::CLONE_NEWCGROUP; } f } // AsRawFd unused but keep the import alive — soma may grow more fd handling. #[allow(dead_code)] fn _keep_imports(_: &dyn AsRawFd) {}