⎇ f8a2547b4516bdb95a3198855202f4d0ca56ed2b
crates/init/arje-incarnate/src/lib.rs
Ñawpa willay
Pi imata llamkarqan
ch'uya
refactor(naming): A1 — ente→arje, vista→revista, pluma→fana
1//! `ente-incarnate` — rutina extraída del Init para encarnar Cards en
2//! procesos aislados (clone(2) + namespaces + cgroup + rlimits + cpu affinity).
3//!
4//! El núcleo histórico vivía en `ente-soma` con globals dependientes de PID 1.
5//! Este crate elimina esos globals: se construye un [`Incarnator`] por
6//! supervisor (Init, shuma, etc.), cada uno con su propio bus socket y su
7//! propia política de capacidades.
8//!
9//! ## Limitaciones que NO desaparecen al extraer
10//!
11//! 1. `mount/pid/net/uts/ipc/cgroup` namespaces requieren `CAP_SYS_ADMIN`
12//! o estar combinados con `CLONE_NEWUSER` en el mismo `clone(2)`.
13//! 2. `user` namespace puede estar bloqueado por
14//! `kernel.unprivileged_userns_clone=0` o por LSM (apparmor/selinux).
15//! 3. cgroups v2 requieren delegación (sistemas modernos: systemd
16//! `Delegate=yes`). Sin delegación, escribir en `/sys/fs/cgroup` falla.
17//! 4. El primer proceso de un PID namespace es PID 1 *de ese ns*; si muere
18//! el kernel mata el namespace entero.
19//!
20//! [`CapabilitySet::detect`] reporta lo que está disponible runtime;
21//! [`Incarnator::dry_run`] valida un [`Card`] antes de ejecutar.
22
23#![doc(html_no_source)]
24
25pub mod caps;
26pub mod cgroup;
27pub mod child;
28pub mod env;
29pub mod error;
30pub mod namespaced;
31pub mod plain;
32pub mod pre_exec;
33
34pub use brahman_card::Card;
35pub use caps::{CapabilitySet, CgroupStatus, NsKind, UserNsStatus};
36pub use env::{EnvSpec, ENV_BUS_SOCK, ENV_ENTE_ID};
37pub use error::{Degradation, IncarnateError};
38pub use pre_exec::{ChildPreExec, ChildSetup};
39
40use std::os::fd::RawFd;
41
42/// Redirección declarativa de stdio del hijo. Cada `Some(fd)` se `dup2`-ea
43/// como stdin/stdout/stderr en el hijo.
44///
45/// **Contrato de ownership**: el caller transfiere ownership de los FDs al
46/// `Incarnator` (igual que pasaría a `Command::stdio(Stdio::from_raw_fd)`).
47/// `Incarnator` se encarga de cerrarlos en el padre tras `incarnate` (path
48/// namespaced) o de dejar que `std::process::Command` los absorba (path
49/// plain). **No los cierres en el caller** — habría doble-close.
50///
51/// Útil para conectar pipes entre procesos del pipeline de shuma sin
52/// romper la regla async-signal-safe del callback de clone(2).
53#[derive(Debug, Clone, Copy, Default)]
54pub struct ChildStdio {
55 pub stdin_fd: Option<RawFd>,
56 pub stdout_fd: Option<RawFd>,
57 pub stderr_fd: Option<RawFd>,
58}
59
60impl ChildStdio {
61 pub fn is_some(&self) -> bool {
62 self.stdin_fd.is_some() || self.stdout_fd.is_some() || self.stderr_fd.is_some()
63 }
64}
65
66use nix::unistd::Pid;
67use std::path::PathBuf;
68
69#[derive(Debug, Clone, Default)]
70pub struct IncarnatorConfig {
71 /// Path del Unix socket del bus interno (se inyecta como `ENTE_BUS_SOCK`).
72 /// `None` = no inyectar.
73 pub bus_sock: Option<PathBuf>,
74
75 /// Inyectar `NOTIFY_SOCKET` (legacy sd_notify). Default `None`.
76 /// `ente-zero` lo pasa = `Some("/run/systemd/notify")`.
77 pub notify_socket: Option<PathBuf>,
78
79 /// Vars adicionales que el caller fuerza en cada hijo.
80 pub extra_env: Vec<(String, String)>,
81
82 /// Si `true`, falta de capacidades aborta `incarnate()` con error.
83 /// Si `false`, se reportan como `Degradation` y la encarnación continúa
84 /// con menos aislamiento (semántica histórica del Init).
85 pub strict_caps: bool,
86}
87
88pub struct Incarnator {
89 cfg: IncarnatorConfig,
90 caps: CapabilitySet,
91}
92
93#[derive(Debug, Clone)]
94pub struct IncarnateOutcome {
95 pub pid: Pid,
96 pub degradations: Vec<Degradation>,
97}
98
99#[derive(Debug, Default, Clone)]
100pub struct ValidationReport {
101 pub will_work: bool,
102 pub blocking: Vec<String>,
103 pub warnings: Vec<String>,
104}
105
106impl Incarnator {
107 pub fn new(cfg: IncarnatorConfig) -> Self {
108 Self {
109 caps: CapabilitySet::detect(),
110 cfg,
111 }
112 }
113
114 /// Constructor para testing/inyección de capacidades pre-calculadas.
115 pub fn with_caps(cfg: IncarnatorConfig, caps: CapabilitySet) -> Self {
116 Self { cfg, caps }
117 }
118
119 pub fn capabilities(&self) -> &CapabilitySet {
120 &self.caps
121 }
122
123 pub fn config(&self) -> &IncarnatorConfig {
124 &self.cfg
125 }
126
127 /// Valida una Card sin ejecutar nada. Útil para que el caller (shuma,
128 /// admin, tests) sepa de antemano si va a poder encarnar tal cual o si
129 /// va a tener que aflojar el SomaSpec.
130 pub fn dry_run(&self, card: &Card) -> ValidationReport {
131 let mut r = ValidationReport {
132 will_work: true,
133 ..Default::default()
134 };
135 let ns = &card.soma.namespaces;
136
137 // Si user_ns está pedido, evaluar su disponibilidad.
138 if ns.user {
139 match self.caps.user_ns {
140 UserNsStatus::DisabledBySysctl => {
141 r.blocking.push(
142 "user namespace requested but kernel.unprivileged_userns_clone=0".into(),
143 );
144 r.will_work = false;
145 }
146 UserNsStatus::RestrictedByLsm => {
147 r.blocking.push(
148 "user namespace restricted by LSM (apparmor/selinux)".into(),
149 );
150 r.will_work = false;
151 }
152 _ => {}
153 }
154 }
155
156 // El resto de namespaces necesitan CAP_SYS_ADMIN o user ns.
157 let needs_priv = [
158 (ns.mount, NsKind::Mount),
159 (ns.pid, NsKind::Pid),
160 (ns.net, NsKind::Net),
161 (ns.uts, NsKind::Uts),
162 (ns.ipc, NsKind::Ipc),
163 (ns.cgroup, NsKind::Cgroup),
164 ];
165 for (wanted, kind) in needs_priv {
166 if wanted && !self.caps.can_create_ns(kind) {
167 r.blocking.push(format!(
168 "{} namespace requires CAP_SYS_ADMIN or user ns (neither available)",
169 kind.name()
170 ));
171 r.will_work = false;
172 }
173 }
174
175 // Cgroup: si el card pide path, chequear que tengamos delegación.
176 if !card.soma.cgroup.path.is_empty() && !self.caps.cgroup_delegated {
177 r.warnings.push(format!(
178 "cgroup `{}` requested but our cgroup is not writable (delegation missing)",
179 card.soma.cgroup.path
180 ));
181 }
182
183 // Payload ejecutable.
184 use brahman_card::Payload;
185 if !matches!(card.payload, Payload::Native { .. } | Payload::Legacy { .. }) {
186 r.blocking
187 .push("payload is not Native/Legacy (use ente-wasm for Wasm)".into());
188 r.will_work = false;
189 }
190
191 r
192 }
193
194 /// Encarna la Card. Si `strict_caps`, valida primero y aborta ante
195 /// blocking. Si no, ejecuta y deja que las degradaciones se acumulen.
196 pub fn incarnate(&self, card: &Card) -> Result<IncarnateOutcome, IncarnateError> {
197 self.incarnate_with(card, ChildStdio::default())
198 }
199
200 /// Variante con redirección de stdio declarativa. Útil para conectar
201 /// pipes entre procesos (caso: pipeline aislado).
202 pub fn incarnate_with(
203 &self,
204 card: &Card,
205 stdio: ChildStdio,
206 ) -> Result<IncarnateOutcome, IncarnateError> {
207 self.incarnate_full(card, stdio, ChildSetup::default())
208 }
209
210 /// Variante full: stdio + setup pre-execve.
211 pub fn incarnate_full(
212 &self,
213 card: &Card,
214 stdio: ChildStdio,
215 setup: ChildSetup,
216 ) -> Result<IncarnateOutcome, IncarnateError> {
217 if self.cfg.strict_caps {
218 let v = self.dry_run(card);
219 if !v.will_work {
220 // Mapeamos el primer blocking a IncarnateError tipado.
221 if let Some(first) = v.blocking.first() {
222 if first.contains("unprivileged_userns_clone") {
223 return Err(IncarnateError::UserNsDisabledBySysctl);
224 }
225 if first.contains("LSM") {
226 return Err(IncarnateError::UserNsRestrictedByLsm);
227 }
228 if let Some(ns) = which_ns_blocking(first) {
229 return Err(IncarnateError::NamespaceCapMissing { ns });
230 }
231 if first.contains("payload") {
232 return Err(IncarnateError::NonExecutablePayload);
233 }
234 }
235 }
236 }
237
238 let env_spec = EnvSpec {
239 bus_sock: self.cfg.bus_sock.clone(),
240 notify_socket: self.cfg.notify_socket.clone(),
241 extra: self.cfg.extra_env.clone(),
242 };
243
244 let mut degradations = Vec::new();
245 let pid = if namespaced::needs_namespacing(&card.soma.namespaces) {
246 namespaced::incarnate_namespaced(card, &env_spec, &stdio, &setup, &mut degradations)?
247 } else {
248 plain::incarnate_plain(card, &env_spec, &stdio, &setup)?
249 };
250 Ok(IncarnateOutcome { pid, degradations })
251 }
252}
253
254fn which_ns_blocking(msg: &str) -> Option<&'static str> {
255 for n in ["mount", "pid", "net", "uts", "ipc", "user", "cgroup"] {
256 if msg.starts_with(n) {
257 return Some(match n {
258 "mount" => "mount",
259 "pid" => "pid",
260 "net" => "net",
261 "uts" => "uts",
262 "ipc" => "ipc",
263 "user" => "user",
264 "cgroup" => "cgroup",
265 _ => unreachable!(),
266 });
267 }
268 }
269 None
270}
271
272#[cfg(test)]
273mod tests {
274 use super::*;
275 use brahman_card::{Card, NamespaceSet, Payload};
276
277 fn make_card(payload: Payload, ns: NamespaceSet) -> Card {
278 let mut c = Card::new("test");
279 c.payload = payload;
280 c.soma.namespaces = ns;
281 c
282 }
283
284 #[test]
285 fn dry_run_native_no_ns_works() {
286 let inc = Incarnator::new(IncarnatorConfig::default());
287 let card = make_card(
288 Payload::Native {
289 exec: "/bin/true".into(),
290 argv: vec![],
291 envp: vec![],
292 },
293 NamespaceSet::default(),
294 );
295 let r = inc.dry_run(&card);
296 assert!(r.will_work, "{:?}", r);
297 }
298
299 #[test]
300 fn dry_run_wasm_payload_blocks() {
301 let inc = Incarnator::new(IncarnatorConfig::default());
302 let card = make_card(
303 Payload::Wasm {
304 module_sha256: [0u8; 32],
305 entry: "main".into(),
306 },
307 NamespaceSet::default(),
308 );
309 let r = inc.dry_run(&card);
310 assert!(!r.will_work);
311 assert!(r.blocking.iter().any(|m| m.contains("payload")));
312 }
313
314 /// Smoke: redirección stdout via ChildStdio en path plain.
315 /// Lanza /bin/echo con stdout conectado a un pipe que leemos.
316 #[test]
317 fn incarnate_with_stdout_redirection_captures_output() {
318 use nix::fcntl::OFlag;
319 use nix::unistd::{pipe2, read};
320 use std::os::fd::{AsRawFd, IntoRawFd};
321
322 let inc = Incarnator::new(IncarnatorConfig::default());
323 let card = make_card(
324 Payload::Native {
325 exec: "/bin/echo".into(),
326 argv: vec!["shuma-stdio".into()],
327 envp: vec![],
328 },
329 NamespaceSet::default(),
330 );
331
332 let (r, w) = pipe2(OFlag::empty()).expect("pipe");
333 let w_raw = w.into_raw_fd();
334 let r_raw = r.as_raw_fd();
335
336 let stdio = ChildStdio {
337 stdin_fd: None,
338 stdout_fd: Some(w_raw),
339 stderr_fd: None,
340 };
341 let out = inc.incarnate_with(&card, stdio).expect("incarnate");
342
343 // Cerramos nuestro extremo de write (el hijo tiene su dup2).
344 // Plain path: Command toma ownership y cierra al spawn.
345 // Namespaced path: el padre todavía tiene una copia... pero en plain
346 // no aplica. Para este test usamos plain (NamespaceSet vacío).
347
348 // Cosechamos para no zombi.
349 let _ = nix::sys::wait::waitpid(out.pid, None);
350
351 // Leemos la salida.
352 let mut buf = [0u8; 64];
353 let n = read(r_raw, &mut buf).expect("read");
354 assert!(n > 0);
355 let s = std::str::from_utf8(&buf[..n]).unwrap();
356 assert!(s.contains("shuma-stdio"), "got: {s:?}");
357 // r se cierra al drop del OwnedFd.
358 }
359
360 /// child_pre_exec aplica chdir + NoNewPrivs en path plain.
361 #[test]
362 fn child_pre_exec_chdir_changes_pwd() {
363 use crate::{ChildPreExec, ChildSetup};
364 use nix::fcntl::OFlag;
365 use nix::unistd::{pipe2, read};
366 use std::ffi::CString;
367 use std::os::fd::{AsRawFd, IntoRawFd};
368
369 let inc = Incarnator::new(IncarnatorConfig::default());
370 // Comando: /bin/pwd. Si chdir funciona, output = /tmp.
371 let card = make_card(
372 Payload::Native {
373 exec: "/bin/pwd".into(),
374 argv: vec![],
375 envp: vec![],
376 },
377 NamespaceSet::default(),
378 );
379
380 let (r, w) = pipe2(OFlag::empty()).expect("pipe");
381 let w_raw = w.into_raw_fd();
382 let r_raw = r.as_raw_fd();
383
384 let stdio = ChildStdio {
385 stdin_fd: None,
386 stdout_fd: Some(w_raw),
387 stderr_fd: None,
388 };
389 let setup = ChildSetup::new()
390 .with(ChildPreExec::Chdir(CString::new("/tmp").unwrap()))
391 .with(ChildPreExec::NoNewPrivs);
392 let out = inc.incarnate_full(&card, stdio, setup).expect("incarnate");
393
394 let _ = nix::sys::wait::waitpid(out.pid, None);
395
396 let mut buf = [0u8; 64];
397 let n = read(r_raw, &mut buf).expect("read");
398 let s = std::str::from_utf8(&buf[..n]).unwrap();
399 assert!(s.starts_with("/tmp"), "pwd output was: {s:?}");
400 }
401
402 /// Smoke: encarnar /bin/true sin ns. No requiere root.
403 #[test]
404 fn incarnate_plain_true_succeeds() {
405 let inc = Incarnator::new(IncarnatorConfig::default());
406 let card = make_card(
407 Payload::Native {
408 exec: "/bin/true".into(),
409 argv: vec![],
410 envp: vec![],
411 },
412 NamespaceSet::default(),
413 );
414 let out = inc.incarnate(&card).expect("plain incarnation");
415 assert!(out.pid.as_raw() > 0);
416 // Cosechamos para no dejar zombi.
417 let _ = nix::sys::wait::waitpid(out.pid, None);
418 }
419}