sergio/takana
> Una distribución Linux construida con un laboratorio funcional y hermético en el > sótano, y una terminal mutable, clásica y anárquica en el piso de arriba — diseñada > desde el suelo para que una IA programadora entienda, modifique y co…
kernel kexec: el reboot suave por kexec_file_load, sin kexec-tools
1//! A/B del kernel con vuelta atrás (ADR 0017 §2).
2//!
3//! ── LA IDEA, Y POR QUÉ ES BARATA EN TAKANA ──────────────────────────────────────────────────────
4//! El kernel es un artefacto de contenido: el viejo y el nuevo coexisten sin disputarse un nombre,
5//! así que **el rollback ya es gratis**. Lo que falta —y es lo que hace que actualizar el kernel de
6//! una máquina remota deje de dar miedo— es que la vuelta atrás ocurra **sola** cuando el kernel
7//! nuevo no sirve.
8//!
9//! ── DOS MODOS DE FALLO, DOS MECANISMOS ──────────────────────────────────────────────────────────
10//! 1. **El candidato no arranca en absoluto** (pánico temprano, EFI-stub que el firmware rechaza).
11//! Lo cubre `BootNext`, y sale gratis: es una variable **de un solo uso** que el firmware CONSUME
12//! al leerla. Si el kernel muere, el siguiente arranque ya no la encuentra, cae en `BootOrder`
13//! normal y vuelve al estable **sin que corra una sola línea nuestra**. No hay contador que
14//! mantener ni estado que se pueda corromper: el mecanismo es el propio firmware.
15//! 2. **El candidato arranca pero el sistema no queda sano.** Eso el firmware no lo puede saber, así
16//! que hace falta confirmación explícita: el candidato se promueve sólo si alguien dice que
17//! funcionó (`kernel confirm`). Mientras no se confirme, cada arranque gasta un intento.
18//!
19//! ⚠ **El estado en disco NO es la fuente de verdad de por dónde arrancamos**: eso lo dice
20//! `BootCurrent`, que lo escribe el firmware. El fichero sólo recuerda *qué* pusimos a prueba. Si
21//! los dos se contradicen, gana el firmware — el fichero puede quedar viejo si alguien arrancó a
22//! mano desde el menú.
23
24use anyhow::{bail, Context, Result};
25use std::path::{Path, PathBuf};
26
27use crate::efi_boot;
28
29pub const ESTADO: &str = "/var/lib/hammer/boot/ab.json";
30/// Ranura del candidato dentro de la ESP. Deliberadamente **distinta** de la del estable: si se
31/// escribiera encima, un fallo a media copia dejaría sin kernel estable al que volver.
32pub const RUTA_CANDIDATO: &str = "/EFI/takana/takana-cand.efi";
33pub const RUTA_ESTABLE: &str = "/EFI/takana/takanax64.efi";
34pub const ETIQUETA_CANDIDATO: &str = "takana (candidato)";
35
36#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
37pub struct Candidato {
38 pub hash: String,
39 pub ruta: String,
40 pub slot: u16,
41 /// Arranques que le quedan para ser confirmado antes de descartarlo.
42 pub intentos: u32,
43}
44
45#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
46pub struct EstadoAb {
47 pub schema: String,
48 pub estable_hash: Option<String>,
49 pub candidato: Option<Candidato>,
50}
51
52impl Default for EstadoAb {
53 fn default() -> Self {
54 EstadoAb {
55 schema: "takana-kernel-ab/1".into(),
56 estable_hash: None,
57 candidato: None,
58 }
59 }
60}
61
62pub fn leer_estado(p: &Path) -> EstadoAb {
63 std::fs::read_to_string(p)
64 .ok()
65 .and_then(|s| serde_json::from_str::<EstadoAb>(&s).ok())
66 .filter(|e| e.schema == "takana-kernel-ab/1")
67 .unwrap_or_default()
68}
69
70pub fn escribir_estado(p: &Path, e: &EstadoAb) -> Result<()> {
71 if let Some(d) = p.parent() {
72 std::fs::create_dir_all(d)?;
73 }
74 std::fs::write(p, serde_json::to_string_pretty(e)?)?;
75 Ok(())
76}
77
78/// `BootCurrent`: por dónde arrancó **esta** sesión. Lo escribe el firmware y es de sólo lectura.
79pub fn boot_current(efivars: &Path) -> Option<u16> {
80 let p = efivars.join(format!("BootCurrent-{}", efi_boot::GLOBAL_GUID));
81 let (_, d) = efi_boot::read_var(&p).ok()?;
82 (d.len() >= 2).then(|| u16::from_le_bytes([d[0], d[1]]))
83}
84
85/// `BootNext`: arrancá **una vez** por aquí. El firmware la borra al usarla, y en eso se apoya toda
86/// la vuelta atrás del modo de fallo 1.
87pub fn poner_boot_next(efivars: &Path, slot: u16) -> Result<()> {
88 let p = efivars.join(format!("BootNext-{}", efi_boot::GLOBAL_GUID));
89 efi_boot::write_var(&p, efi_boot::ATTRS, &slot.to_le_bytes())
90 .with_context(|| format!("no pude escribir BootNext en {}", p.display()))
91}
92
93pub fn boot_next(efivars: &Path) -> Option<u16> {
94 let p = efivars.join(format!("BootNext-{}", efi_boot::GLOBAL_GUID));
95 let (_, d) = efi_boot::read_var(&p).ok()?;
96 (d.len() >= 2).then(|| u16::from_le_bytes([d[0], d[1]]))
97}
98
99pub fn borrar_boot_next(efivars: &Path) {
100 let p = efivars.join(format!("BootNext-{}", efi_boot::GLOBAL_GUID));
101 let _ = std::fs::remove_file(p);
102}
103
104/// Qué hacer al arrancar, decidido **sólo** con datos y sin efectos: así se puede probar entero.
105#[derive(Debug, Clone, PartialEq, Eq)]
106pub enum Veredicto {
107 /// No hay nada en prueba.
108 SinCandidato,
109 /// Arrancamos por el candidato: está a prueba y le quedan `intentos` después de éste.
110 EnPrueba { slot: u16, intentos_restantes: u32 },
111 /// Arrancamos por el estable habiendo un candidato pendiente ⇒ **el candidato no arrancó**.
112 CandidatoFallo { slot: u16 },
113 /// Se acabaron los intentos sin que nadie confirmara.
114 CandidatoAgotado { slot: u16 },
115}
116
117/// El corazón de la decisión. `actual` es `BootCurrent` (puede faltar: hay firmwares que no la
118/// publican, y entonces no se puede afirmar nada — se prefiere no tocar el candidato antes que
119/// descartarlo por una sospecha).
120pub fn evaluar(estado: &EstadoAb, actual: Option<u16>) -> Veredicto {
121 let Some(c) = &estado.candidato else { return Veredicto::SinCandidato };
122 match actual {
123 Some(a) if a == c.slot => {
124 if c.intentos == 0 {
125 Veredicto::CandidatoAgotado { slot: c.slot }
126 } else {
127 Veredicto::EnPrueba { slot: c.slot, intentos_restantes: c.intentos - 1 }
128 }
129 }
130 Some(_) => Veredicto::CandidatoFallo { slot: c.slot },
131 // Sin BootCurrent no hay forma de saber por dónde vinimos. Descartar un candidato bueno es
132 // peor que dejarlo un arranque más: se informa y no se toca.
133 None => Veredicto::EnPrueba { slot: c.slot, intentos_restantes: c.intentos },
134 }
135}
136
137/// Ruta de la ESP montada + ruta interna del `.efi` ⇒ ruta del sistema de ficheros.
138pub fn en_esp(esp: &Path, ruta_efi: &str) -> PathBuf {
139 // ⚠ El orden importa y no es cosmético. Si se quitan las barras ANTES de traducir los
140 // backslashes, una ruta en formato EFI (`\EFI\takana\x.efi`) sale de aquí como `/EFI/...`,
141 // que es ABSOLUTA — y `Path::join` con una ruta absoluta **descarta la base**: se escribiría en
142 // el `/EFI` de la raíz del sistema en vez de dentro de la ESP. Traducir primero y recortar
143 // después. (Lo cazó el test, no una revisión.)
144 let rel = ruta_efi.replace('\\', "/");
145 esp.join(rel.trim_start_matches('/'))
146}
147
148/// Copia el bzImage de un artefacto del store a la ranura candidata de la ESP.
149///
150/// Escribe a un temporal y renombra: un corte de luz a media copia no puede dejar medio kernel en
151/// la ruta que el firmware va a lanzar. (En FAT el rename es atómico dentro del directorio.)
152pub fn instalar_candidato(bzimage: &Path, esp: &Path, ruta_efi: &str) -> Result<u64> {
153 let destino = en_esp(esp, ruta_efi);
154 let dir = destino.parent().context("ruta de candidato sin directorio")?;
155 std::fs::create_dir_all(dir)?;
156 let tmp = dir.join("takana-cand.tmp");
157 std::fs::copy(bzimage, &tmp)
158 .with_context(|| format!("copiando {} → {}", bzimage.display(), tmp.display()))?;
159 let n = std::fs::metadata(&tmp)?.len();
160 std::fs::rename(&tmp, &destino)
161 .with_context(|| format!("renombrando a {}", destino.display()))?;
162 Ok(n)
163}
164
165// ══ kexec: el reboot suave (ADR 0017 §1) ═══════════════════════════════════════════════════════
166//
167// Se usa **`kexec_file_load`**, no el `kexec_load` viejo, y la diferencia es de orden de magnitud:
168// `kexec_load` recibe los segmentos YA armados y el *purgatory* —el código que corre entre los dos
169// kernels—, o sea que usarlo obliga a reimplementar kexec-tools. `kexec_file_load` recibe los
170// **descriptores** del kernel y del initrd y hace el trabajo dentro del kernel.
171//
172// Se hace con `asm!` en vez de agregar la crate `libc` al workspace: es **un** syscall, su número y
173// su ABI son contrato estable de Linux, y la alternativa era arrastrar una dependencia a un
174// workspace compartido por dos frentes.
175//
176// ⚠ **Esto no es «actualizar sin rebootear».** El userspace muere igual: es un reboot que se salta
177// el firmware. Lo que ahorra son los 20-30 s de POST/UEFI — la mitad del downtime en un servidor
178// remoto, y nada en un portátil. La ayuda del comando lo dice con esas palabras a propósito.
179#[cfg(target_arch = "x86_64")]
180mod sys {
181 /// `kexec_file_load` en x86_64. Contrato: `include/uapi/asm-generic/unistd.h`.
182 pub const NR_KEXEC_FILE_LOAD: u64 = 320;
183 /// `reboot`.
184 pub const NR_REBOOT: u64 = 169;
185
186 pub const LINUX_REBOOT_MAGIC1: u64 = 0xfee1_dead;
187 pub const LINUX_REBOOT_MAGIC2: u64 = 672_274_793;
188 pub const LINUX_REBOOT_CMD_KEXEC: u64 = 0x4558_4543;
189 /// Cargar sin initramfs (el fd de initrd se ignora).
190 pub const KEXEC_FILE_NO_INITRAMFS: u64 = 0x4;
191
192 /// # Safety
193 /// Invoca un syscall crudo. Los argumentos tienen que respetar la ABI del syscall llamado.
194 pub unsafe fn syscall5(n: u64, a: u64, b: u64, c: u64, d: u64, e: u64) -> i64 {
195 let ret: i64;
196 unsafe {
197 std::arch::asm!(
198 "syscall",
199 inlateout("rax") n => ret,
200 in("rdi") a,
201 in("rsi") b,
202 in("rdx") c,
203 in("r10") d,
204 in("r8") e,
205 lateout("rcx") _,
206 lateout("r11") _,
207 options(nostack)
208 );
209 }
210 ret
211 }
212}
213
214/// Carga un kernel para el próximo `reboot -f kexec`. NO salta todavía.
215#[cfg(target_arch = "x86_64")]
216pub fn kexec_cargar(kernel: &Path, initrd: Option<&Path>, cmdline: &str) -> Result<()> {
217 use std::os::fd::AsRawFd;
218 let fk = std::fs::File::open(kernel)
219 .with_context(|| format!("abriendo el kernel {}", kernel.display()))?;
220 // El cmdline viaja con su NUL: el kernel cuenta `cmdline_len` INCLUYENDO el terminador, y sin él
221 // lee un byte de más. Es el error clásico de esta llamada.
222 let mut cl: Vec<u8> = cmdline.as_bytes().to_vec();
223 cl.push(0);
224
225 let (fd_i, flags) = match initrd {
226 Some(p) => {
227 let fi = std::fs::File::open(p)
228 .with_context(|| format!("abriendo el initrd {}", p.display()))?;
229 (Some(fi), 0u64)
230 }
231 None => (None, sys::KEXEC_FILE_NO_INITRAMFS),
232 };
233 let raw_i = fd_i.as_ref().map(|f| f.as_raw_fd()).unwrap_or(-1);
234
235 let r = unsafe {
236 sys::syscall5(
237 sys::NR_KEXEC_FILE_LOAD,
238 fk.as_raw_fd() as u64,
239 raw_i as u64,
240 cl.len() as u64,
241 cl.as_ptr() as u64,
242 flags,
243 )
244 };
245 if r < 0 {
246 let e = -r;
247 // Traducir los tres errores que de verdad pasan, porque «Permission denied» a secas manda a
248 // buscar permisos de fichero cuando el problema es otro.
249 // ⚠ EPERM y ENOSYS se confunden fácil y mandan a lugares OPUESTOS. Medido en este mismo hub:
250 // el kernel de Artix trae `CONFIG_KEXEC_FILE=y` y aun así devolvió EPERM — por no ser root.
251 // El primer texto de este match decía «falta CONFIG_KEXEC_FILE» para EPERM, que es
252 // exactamente el diagnóstico equivocado: manda a recompilar un kernel que estaba bien.
253 let pista = match e {
254 1 => " ⇒ falta CAP_SYS_BOOT: hay que ser root (y si YA sos root, es lockdown: Secure Boot no deja cargar un kernel sin firmar)",
255 38 => " ⇒ el kernel que CORRE no implementa kexec_file_load: le falta CONFIG_KEXEC_FILE",
256 13 => " ⇒ no puedo leer el fichero del kernel o del initrd",
257 22 => " ⇒ la imagen no le gustó al kernel (¿es un bzImage x86_64 válido?)",
258 _ => "",
259 };
260 bail!("kexec_file_load falló: errno {e}{pista}");
261 }
262 Ok(())
263}
264
265/// Salta al kernel ya cargado. **No vuelve.**
266#[cfg(target_arch = "x86_64")]
267pub fn kexec_saltar() -> Result<()> {
268 let r = unsafe {
269 sys::syscall5(
270 sys::NR_REBOOT,
271 sys::LINUX_REBOOT_MAGIC1,
272 sys::LINUX_REBOOT_MAGIC2,
273 sys::LINUX_REBOOT_CMD_KEXEC,
274 0,
275 0,
276 )
277 };
278 bail!("reboot(KEXEC) volvió con {r}: no había kernel cargado, o no somos root")
279}
280
281#[cfg(not(target_arch = "x86_64"))]
282pub fn kexec_cargar(_: &Path, _: Option<&Path>, _: &str) -> Result<()> {
283 bail!("kexec: sólo implementado para x86_64")
284}
285#[cfg(not(target_arch = "x86_64"))]
286pub fn kexec_saltar() -> Result<()> {
287 bail!("kexec: sólo implementado para x86_64")
288}
289
290/// ¿El kernel que corre soporta `kexec_file_load`? Se responde **preguntándole al kernel**, no
291/// leyendo un `.config` que puede no ser el suyo.
292pub fn kexec_file_disponible() -> bool {
293 // Un `kexec_file_load` con fd inválido devuelve EBADF (9) si el syscall EXISTE, y ENOSYS (38)
294 // si no está compilado. Distinguir los dos es toda la prueba, y no carga nada.
295 #[cfg(target_arch = "x86_64")]
296 {
297 let r = unsafe { sys::syscall5(sys::NR_KEXEC_FILE_LOAD, u64::MAX, u64::MAX, 0, 0, sys::KEXEC_FILE_NO_INITRAMFS) };
298 -r != 38
299 }
300 #[cfg(not(target_arch = "x86_64"))]
301 {
302 false
303 }
304}
305
306#[cfg(test)]
307mod tests {
308 use super::*;
309
310 fn con_candidato(slot: u16, intentos: u32) -> EstadoAb {
311 EstadoAb {
312 candidato: Some(Candidato {
313 hash: "b3:abc".into(),
314 ruta: RUTA_CANDIDATO.into(),
315 slot,
316 intentos,
317 }),
318 ..Default::default()
319 }
320 }
321
322 #[test]
323 fn sin_candidato_no_hay_nada_que_decidir() {
324 assert_eq!(evaluar(&EstadoAb::default(), Some(4)), Veredicto::SinCandidato);
325 assert_eq!(evaluar(&EstadoAb::default(), None), Veredicto::SinCandidato);
326 }
327
328 #[test]
329 fn arrancar_por_el_candidato_lo_pone_a_prueba_y_gasta_un_intento() {
330 assert_eq!(
331 evaluar(&con_candidato(7, 1), Some(7)),
332 Veredicto::EnPrueba { slot: 7, intentos_restantes: 0 }
333 );
334 assert_eq!(
335 evaluar(&con_candidato(7, 3), Some(7)),
336 Veredicto::EnPrueba { slot: 7, intentos_restantes: 2 }
337 );
338 }
339
340 #[test]
341 fn arrancar_por_otro_lado_con_candidato_pendiente_significa_que_no_arranco() {
342 // Es EL caso que justifica todo: pusimos BootNext al candidato y el firmware terminó
343 // arrancando otra cosa ⇒ el candidato no llegó a ejecutarse.
344 assert_eq!(evaluar(&con_candidato(7, 1), Some(4)), Veredicto::CandidatoFallo { slot: 7 });
345 }
346
347 #[test]
348 fn sin_intentos_se_agota_en_vez_de_reintentar_para_siempre() {
349 assert_eq!(evaluar(&con_candidato(7, 0), Some(7)), Veredicto::CandidatoAgotado { slot: 7 });
350 }
351
352 #[test]
353 fn sin_boot_current_no_se_descarta_por_sospecha() {
354 // Hay firmwares que no publican BootCurrent. Descartar un candidato bueno es peor que
355 // dejarlo un arranque más, así que NO se consume intento.
356 assert_eq!(
357 evaluar(&con_candidato(7, 2), None),
358 Veredicto::EnPrueba { slot: 7, intentos_restantes: 2 },
359 "sin dato del firmware no se gasta intento"
360 );
361 }
362
363 #[test]
364 fn el_estado_ida_y_vuelta_y_un_esquema_ajeno_no_se_come() {
365 let tmp = std::env::temp_dir().join(format!("takana-ab-{}", std::process::id()));
366 std::fs::create_dir_all(&tmp).unwrap();
367 let f = tmp.join("ab.json");
368 let e = con_candidato(9, 2);
369 escribir_estado(&f, &e).unwrap();
370 assert_eq!(leer_estado(&f), e);
371 // Un fichero de otro esquema (o basura) devuelve el default, no un estado a medias.
372 std::fs::write(&f, r#"{"schema":"otra-cosa/9","candidato":null}"#).unwrap();
373 assert_eq!(leer_estado(&f), EstadoAb::default());
374 std::fs::write(&f, "no soy json").unwrap();
375 assert_eq!(leer_estado(&f), EstadoAb::default());
376 std::fs::remove_dir_all(&tmp).ok();
377 }
378
379 #[test]
380 fn la_ranura_del_candidato_no_pisa_la_del_estable() {
381 // Si compartieran ruta, un fallo a media copia se llevaría puesto el kernel al que hay que
382 // volver — el rollback dejaría de existir justo cuando hace falta.
383 assert_ne!(RUTA_CANDIDATO, RUTA_ESTABLE);
384 let esp = Path::new("/boot/efi");
385 assert_eq!(en_esp(esp, RUTA_CANDIDATO), esp.join("EFI/takana/takana-cand.efi"));
386 assert_eq!(en_esp(esp, "\\EFI\\takana\\x.efi"), esp.join("EFI/takana/x.efi"));
387 }
388
389 #[test]
390 fn instalar_candidato_es_atomico_y_no_deja_el_temporal() {
391 let tmp = std::env::temp_dir().join(format!("takana-ab-inst-{}", std::process::id()));
392 std::fs::create_dir_all(&tmp).unwrap();
393 let src = tmp.join("bzImage");
394 std::fs::write(&src, vec![0xAAu8; 4096]).unwrap();
395 let esp = tmp.join("esp");
396 let n = instalar_candidato(&src, &esp, RUTA_CANDIDATO).unwrap();
397 assert_eq!(n, 4096);
398 let dst = en_esp(&esp, RUTA_CANDIDATO);
399 assert_eq!(std::fs::read(&dst).unwrap().len(), 4096);
400 assert!(
401 !dst.parent().unwrap().join("takana-cand.tmp").exists(),
402 "el temporal se renombró, no quedó suelto"
403 );
404 std::fs::remove_dir_all(&tmp).ok();
405 }
406}