fix(init): el reboot-loop de Fedora — remount rw + /run tmpfs + shell de rescate

Diagnóstico: en el VPS Fedora arje-zero caía como PID 1 y el cmdline
traía `panic=10`, así que el kernel rebooteaba cada 10 s. Tres causas
encadenadas, todas arregladas:

1) **Cmdline `ro` + sin `/run` tmpfs.** El menuentry montaba `/` como
   sólo lectura (systemd lo remonta rw temprano; arje no). Sin eso, el
   socket del bus interno se intenta crear sobre un FS de sólo lectura
   y falla con EROFS → spawn_bus devuelve Err → PID 1 sale → kernel
   panic. arje-kernel ahora remonta `/` rw en el bootstrap y monta
   `/run`, `/tmp`, `/dev/pts`, `/dev/shm` como tmpfs — superficies
   escribibles aunque la raíz quede ro.

2) **PID 1 saliendo en cualquier `?`.** Doctrina dura nueva: PID 1
   NUNCA puede salir. Cualquier error de arranque ahora cae a una
   `emergency_shell()` que imprime el diagnóstico en `/dev/console`,
   abre `/bin/sh` y, si la shell muere, la reabre — así el operador
   puede reparar en vez de mirar la máquina reiniciarse en bucle.

3) **El script no conocía grub2 (Fedora).** `install-arje-as-init.sh`
   sólo probaba `update-grub` (Debian) y `grub-mkconfig` (Arch). Ahora
   detecta `grub2-mkconfig` y resuelve el `grub.cfg` correcto
   (UEFI/BIOS, fedora/redhat/centos/almalinux/rocky). El menuentry
   también pasa de `ro` a `rw` — el remount es belt-and-suspenders.
   Mismo arreglo en `uninstall-arje.sh`.

Renaser intacto: estos cambios son Linux-side puro (arje-kernel y
arje-zero usan nix/libc/tracing); renaser sólo comparte mirada-layout y
formato, ninguno tocado.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
sergio
2026-05-22 23:02:45 +00:00
parent 922ad1f86b
commit d1b700eb2b
4 changed files with 185 additions and 32 deletions
+57 -24
View File
@@ -1,32 +1,65 @@
//! Bootstrap del entorno kernel para PID 1: monta procfs/sysfs/devtmpfs/cgroup2 //! Bootstrap del entorno kernel para PID 1: remonta `/` rw, monta
//! y registra al proceso como subreaper para adoptar huérfanos. //! procfs/sysfs/devtmpfs/cgroup2 y las superficies escribibles volátiles
//! (`/run`, `/tmp`, `/dev/pts`, `/dev/shm`), y registra al proceso como
//! subreaper para adoptar huérfanos.
//! //!
//! Idempotente: si los puntos de montaje ya existen (initramfs los montó), //! Idempotente: si un punto de montaje ya existe (lo montó el initramfs),
//! el segundo mount falla con EBUSY y simplemente lo ignoramos. //! el segundo mount falla con EBUSY y simplemente se ignora.
//!
//! **Por qué importa `/run`:** el cmdline de arranque suele traer `ro`
//! (systemd remonta rw temprano; nosotros también debemos). Sin remontar
//! `/` y sin `/run` como tmpfs, crear el socket del bus interno falla con
//! EROFS — y PID 1 moriría, provocando un kernel panic. Esta función es
//! infalible a propósito: devuelve `Ok` siempre y sólo loggea los fallos.
use nix::mount::{mount, MsFlags}; use nix::mount::{mount, MsFlags};
use tracing::debug; use tracing::{debug, warn};
/// Monta los pseudo-filesystems esenciales. Errores benignos (ya montados) /// Prepara el entorno del kernel para PID 1. Nunca falla de forma dura:
/// se ignoran; errores serios se propagan. /// cada paso es best-effort y los problemas se loggean, porque un `Err`
/// que llegue hasta `main` terminaría PID 1.
pub fn bootstrap_kernel_surface() -> anyhow::Result<()> { pub fn bootstrap_kernel_surface() -> anyhow::Result<()> {
// Cada uno con sus flags estándar — NOSUID/NOEXEC/NODEV donde aplica. // 1) Remontar `/` lectura-escritura. El cmdline casi siempre trae
mount::<str, str, str, str>( // `ro`; sin esto el resto del sistema queda de sólo lectura.
Some("proc"), "/proc", Some("proc"), if let Err(e) = mount::<str, str, str, str>(
MsFlags::MS_NOSUID | MsFlags::MS_NOEXEC | MsFlags::MS_NODEV, None, None, "/", None, MsFlags::MS_REMOUNT, None,
).ok(); ) {
mount::<str, str, str, str>( warn!(?e, "remount / rw falló — el sistema puede quedar de sólo lectura");
Some("sysfs"), "/sys", Some("sysfs"), }
MsFlags::MS_NOSUID | MsFlags::MS_NOEXEC | MsFlags::MS_NODEV, None,
).ok(); // 2) Pseudo-filesystems del kernel. NOSUID/NOEXEC/NODEV donde aplica.
mount::<str, str, str, str>( let pseudo: [(&str, &str, &str, MsFlags); 4] = [
Some("devtmpfs"), "/dev", Some("devtmpfs"), ("proc", "/proc", "proc",
MsFlags::MS_NOSUID, None, MsFlags::MS_NOSUID.union(MsFlags::MS_NOEXEC).union(MsFlags::MS_NODEV)),
).ok(); ("sysfs", "/sys", "sysfs",
mount::<str, str, str, str>( MsFlags::MS_NOSUID.union(MsFlags::MS_NOEXEC).union(MsFlags::MS_NODEV)),
Some("cgroup2"), "/sys/fs/cgroup", Some("cgroup2"), ("devtmpfs", "/dev", "devtmpfs", MsFlags::MS_NOSUID),
MsFlags::MS_NOSUID | MsFlags::MS_NOEXEC | MsFlags::MS_NODEV, None, ("cgroup2", "/sys/fs/cgroup", "cgroup2",
).ok(); MsFlags::MS_NOSUID.union(MsFlags::MS_NOEXEC).union(MsFlags::MS_NODEV)),
];
for (src, dst, fstype, flags) in pseudo {
let _ = mount::<str, str, str, str>(Some(src), dst, Some(fstype), flags, None);
}
// 3) Superficies escribibles volátiles. `/run` como tmpfs es lo que
// permite crear el socket del bus interno aun con `/` de sólo
// lectura. `mkdir` best-effort antes de cada montaje.
let volatile: [(&str, &str, &str, MsFlags, &str); 4] = [
("tmpfs", "/run", "tmpfs",
MsFlags::MS_NOSUID.union(MsFlags::MS_NODEV), "mode=0755"),
("tmpfs", "/tmp", "tmpfs",
MsFlags::MS_NOSUID.union(MsFlags::MS_NODEV), "mode=1777"),
("devpts", "/dev/pts", "devpts",
MsFlags::MS_NOSUID.union(MsFlags::MS_NOEXEC), "mode=0620,gid=5"),
("tmpfs", "/dev/shm", "tmpfs",
MsFlags::MS_NOSUID.union(MsFlags::MS_NODEV), "mode=1777"),
];
for (src, dst, fstype, flags, data) in volatile {
let _ = std::fs::create_dir_all(dst);
let _ = mount::<str, str, str, str>(Some(src), dst, Some(fstype), flags, Some(data));
}
let _ = std::fs::create_dir_all("/run/lock");
debug!("kernel surface bootstrap completo"); debug!("kernel surface bootstrap completo");
Ok(()) Ok(())
} }
+89 -1
View File
@@ -83,8 +83,27 @@ fn main() -> anyhow::Result<()> {
if dev_mode { if dev_mode {
warn!(?pid, "ente-zero corriendo en DEV MODE (no PID 1) — kernel surface no se monta"); warn!(?pid, "ente-zero corriendo en DEV MODE (no PID 1) — kernel surface no se monta");
} else { return run(cli, true);
}
info!("ente-zero despierta como PID 1"); info!("ente-zero despierta como PID 1");
// Doctrina dura: PID 1 NUNCA puede salir — el kernel haría panic
// ("Attempted to kill init") y, con `panic=N` en el cmdline, la
// máquina cae en un reboot-loop. Por eso cualquier fallo de arranque
// se desvía a una shell de rescate: deja diagnosticar y reparar en
// vez de reiniciar a ciegas cada diez segundos.
match run(cli, false) {
Ok(()) => emergency_shell(
"el bucle primordial terminó — el fractal pidió shutdown",
),
Err(e) => emergency_shell(&format!("{e:#}")),
}
}
/// Arranque + bucle primordial. En PID 1, cualquier `Err` que devuelva
/// lo intercepta `main` y lo convierte en shell de rescate.
fn run(cli: CliArgs, dev_mode: bool) -> anyhow::Result<()> {
if !dev_mode {
bootstrap_kernel_surface().context("bootstrap kernel surface")?; bootstrap_kernel_surface().context("bootstrap kernel surface")?;
become_child_subreaper().context("PR_SET_CHILD_SUBREAPER")?; become_child_subreaper().context("PR_SET_CHILD_SUBREAPER")?;
} }
@@ -105,6 +124,75 @@ fn main() -> anyhow::Result<()> {
)) ))
} }
/// Último recurso de PID 1: imprime el diagnóstico en la consola y abre
/// una shell de rescate. **Nunca retorna** — si lo hiciera, el proceso
/// saldría y el kernel haría panic.
fn emergency_shell(reason: &str) -> ! {
let banner = format!(
"\n\n\
=============== arje-zero — ARRANQUE FALLIDO ================\n\
{reason}\n\
---------------------------------------------------------------\n\
Se abre una shell de rescate sobre la consola. Revisá el sistema\n\
(p. ej. /ente/seed.card.json y los binarios en /usr/sbin) y\n\
reiniciá con `reboot -f`. Salir de la shell la vuelve a abrir.\n\n",
);
write_to_console(&banner);
error!(reason = %reason.replace('\n', " "), "arranque de PID 1 fallido");
loop {
match spawn_console_shell() {
Ok(status) => write_to_console(&format!(
"\n[arje-zero] la shell de rescate terminó ({status}) — reabriendo.\n",
)),
Err(e) => {
write_to_console(&format!(
"\n[arje-zero] no hay shell de rescate disponible: {e}\n\
PID 1 queda en espera pasiva — usá la consola del proveedor.\n",
));
loop {
std::thread::sleep(Duration::from_secs(3600));
}
}
}
}
}
/// Abre una shell interactiva con stdin/stdout/stderr sobre
/// `/dev/console` y espera a que termine.
fn spawn_console_shell() -> std::io::Result<std::process::ExitStatus> {
let shell = ["/bin/sh", "/bin/bash", "/usr/bin/sh", "/usr/bin/bash"]
.into_iter()
.find(|p| std::path::Path::new(p).exists())
.ok_or_else(|| {
std::io::Error::new(
std::io::ErrorKind::NotFound,
"ninguna shell en /bin ni /usr/bin",
)
})?;
let console = std::fs::OpenOptions::new()
.read(true)
.write(true)
.open("/dev/console")?;
std::process::Command::new(shell)
.stdin(console.try_clone()?)
.stdout(console.try_clone()?)
.stderr(console)
.env("HOME", "/root")
.env("PATH", "/usr/sbin:/usr/bin:/sbin:/bin")
.env("PS1", "arje-rescate# ")
.env("TERM", "linux")
.status()
}
/// Escribe un mensaje directo a `/dev/console`, con stderr de respaldo.
fn write_to_console(msg: &str) {
use std::io::Write;
if let Ok(mut f) = std::fs::OpenOptions::new().write(true).open("/dev/console") {
let _ = f.write_all(msg.as_bytes());
}
eprint!("{msg}");
}
async fn primordial_loop( async fn primordial_loop(
seed_card: arje_card::EntityCard, seed_card: arje_card::EntityCard,
dev_mode: bool, dev_mode: bool,
+27 -6
View File
@@ -133,23 +133,44 @@ menuentry "arje (init=/sbin/arje-zero) — kernel $KVER" {
insmod gzio insmod gzio
insmod part_msdos insmod part_msdos
insmod ext2 insmod ext2
linux $VMLINUZ $ROOT_OPT ro init=/sbin/arje-zero console=tty1 console=ttyS0,115200 panic=10 linux $VMLINUZ $ROOT_OPT rw init=/sbin/arje-zero console=tty1 console=ttyS0,115200 panic=10
initrd $INITRD initrd $INITRD
} }
# END ARJE-MENUENTRY # END ARJE-MENUENTRY
EOF EOF
chmod 0755 "$CUSTOM" chmod 0755 "$CUSTOM"
# Regenerar grub.cfg. # Regenerar grub.cfg. Detectamos la convención de la distro: Debian /
# Ubuntu usan `update-grub` (wrapper de grub-mkconfig). Fedora / RHEL /
# CentOS usan `grub2-mkconfig` y el .cfg vive en /boot/grub2/ (BIOS) o
# /boot/efi/EFI/<distro>/ (UEFI). Arch usa `grub-mkconfig`.
if command -v update-grub >/dev/null 2>&1; then if command -v update-grub >/dev/null 2>&1; then
echo " ejecutando update-grub" echo " ejecutando update-grub (Debian/Ubuntu)"
update-grub update-grub
elif command -v grub2-mkconfig >/dev/null 2>&1; then
# Fedora / RHEL: buscar el grub.cfg activo. UEFI primero — su path es
# el que arranca realmente; el de BIOS suele ser un symlink al UEFI.
GRUB_CFG=""
for cand in /boot/efi/EFI/fedora/grub.cfg \
/boot/efi/EFI/redhat/grub.cfg \
/boot/efi/EFI/centos/grub.cfg \
/boot/efi/EFI/almalinux/grub.cfg \
/boot/efi/EFI/rocky/grub.cfg \
/boot/grub2/grub.cfg; do
if [ -f "$cand" ] || [ -L "$cand" ]; then GRUB_CFG="$cand"; break; fi
done
if [ -z "$GRUB_CFG" ]; then
GRUB_CFG="/boot/grub2/grub.cfg"
echo " no detecté grub.cfg existente — usando $GRUB_CFG por default"
fi
echo " ejecutando grub2-mkconfig -o $GRUB_CFG (Fedora/RHEL)"
grub2-mkconfig -o "$GRUB_CFG"
elif command -v grub-mkconfig >/dev/null 2>&1; then elif command -v grub-mkconfig >/dev/null 2>&1; then
echo " ejecutando grub-mkconfig" echo " ejecutando grub-mkconfig (Arch/otros)"
grub-mkconfig -o /boot/grub/grub.cfg grub-mkconfig -o /boot/grub/grub.cfg
else else
echo "[install-arje] WARN: no encontré update-grub ni grub-mkconfig." >&2 echo "[install-arje] WARN: no encontré update-grub, grub2-mkconfig ni grub-mkconfig." >&2
echo " Regenerá grub.cfg manualmente." >&2 echo " Regenerá la grub.cfg manualmente." >&2
fi fi
cat <<EOF cat <<EOF
+11
View File
@@ -38,6 +38,17 @@ fi
if command -v update-grub >/dev/null 2>&1; then if command -v update-grub >/dev/null 2>&1; then
update-grub update-grub
elif command -v grub2-mkconfig >/dev/null 2>&1; then
GRUB_CFG=""
for cand in /boot/efi/EFI/fedora/grub.cfg \
/boot/efi/EFI/redhat/grub.cfg \
/boot/efi/EFI/centos/grub.cfg \
/boot/efi/EFI/almalinux/grub.cfg \
/boot/efi/EFI/rocky/grub.cfg \
/boot/grub2/grub.cfg; do
if [ -f "$cand" ] || [ -L "$cand" ]; then GRUB_CFG="$cand"; break; fi
done
grub2-mkconfig -o "${GRUB_CFG:-/boot/grub2/grub.cfg}"
elif command -v grub-mkconfig >/dev/null 2>&1; then elif command -v grub-mkconfig >/dev/null 2>&1; then
grub-mkconfig -o /boot/grub/grub.cfg grub-mkconfig -o /boot/grub/grub.cfg
fi fi