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>
diff --git a/crates/init/arje-kernel/src/surface.rs b/crates/init/arje-kernel/src/surface.rs
index 0f50bb3..ab395a3 100644
--- a/crates/init/arje-kernel/src/surface.rs
+++ b/crates/init/arje-kernel/src/surface.rs
@@ -1,32 +1,65 @@
-//! Bootstrap del entorno kernel para PID 1: monta procfs/sysfs/devtmpfs/cgroup2
-//! y registra al proceso como subreaper para adoptar huérfanos.
+//! Bootstrap del entorno kernel para PID 1: remonta `/` rw, monta
+//! 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ó),
-//! el segundo mount falla con EBUSY y simplemente lo ignoramos.
+//! Idempotente: si un punto de montaje ya existe (lo montó el initramfs),
+//! 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 tracing::debug;
+use tracing::{debug, warn};
-/// Monta los pseudo-filesystems esenciales. Errores benignos (ya montados)
-/// se ignoran; errores serios se propagan.
+/// Prepara el entorno del kernel para PID 1. Nunca falla de forma dura:
+/// 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<()> {
- // Cada uno con sus flags estándar — NOSUID/NOEXEC/NODEV donde aplica.
- mount::<str, str, str, str>(
- Some("proc"), "/proc", Some("proc"),
- MsFlags::MS_NOSUID | MsFlags::MS_NOEXEC | MsFlags::MS_NODEV, None,
- ).ok();
- mount::<str, str, str, str>(
- Some("sysfs"), "/sys", Some("sysfs"),
- MsFlags::MS_NOSUID | MsFlags::MS_NOEXEC | MsFlags::MS_NODEV, None,
- ).ok();
- mount::<str, str, str, str>(
- Some("devtmpfs"), "/dev", Some("devtmpfs"),
- MsFlags::MS_NOSUID, None,
- ).ok();
- mount::<str, str, str, str>(
- Some("cgroup2"), "/sys/fs/cgroup", Some("cgroup2"),
- MsFlags::MS_NOSUID | MsFlags::MS_NOEXEC | MsFlags::MS_NODEV, None,
- ).ok();
+ // 1) Remontar `/` lectura-escritura. El cmdline casi siempre trae
+ // `ro`; sin esto el resto del sistema queda de sólo lectura.
+ if let Err(e) = mount::<str, str, str, str>(
+ None, "/", None, MsFlags::MS_REMOUNT, None,
+ ) {
+ warn!(?e, "remount / rw falló — el sistema puede quedar de sólo lectura");
+ }
+
+ // 2) Pseudo-filesystems del kernel. NOSUID/NOEXEC/NODEV donde aplica.
+ let pseudo: [(&str, &str, &str, MsFlags); 4] = [
+ ("proc", "/proc", "proc",
+ MsFlags::MS_NOSUID.union(MsFlags::MS_NOEXEC).union(MsFlags::MS_NODEV)),
+ ("sysfs", "/sys", "sysfs",
+ MsFlags::MS_NOSUID.union(MsFlags::MS_NOEXEC).union(MsFlags::MS_NODEV)),
+ ("devtmpfs", "/dev", "devtmpfs", MsFlags::MS_NOSUID),
+ ("cgroup2", "/sys/fs/cgroup", "cgroup2",
+ 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");
Ok(())
}
diff --git a/crates/init/arje-zero/src/main.rs b/crates/init/arje-zero/src/main.rs
index d4f7932..18c3d3e 100644
--- a/crates/init/arje-zero/src/main.rs
+++ b/crates/init/arje-zero/src/main.rs
@@ -83,8 +83,27 @@ fn main() -> anyhow::Result<()> {
if dev_mode {
warn!(?pid, "ente-zero corriendo en DEV MODE (no PID 1) — kernel surface no se monta");
- } else {
- info!("ente-zero despierta como PID 1");
+ return run(cli, true);
+ }
+
+ 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")?;
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(
seed_card: arje_card::EntityCard,
dev_mode: bool,
diff --git a/scripts/install-arje-as-init.sh b/scripts/install-arje-as-init.sh
index 5e772d9..fbe0ba7 100755
--- a/scripts/install-arje-as-init.sh
+++ b/scripts/install-arje-as-init.sh
@@ -133,23 +133,44 @@ menuentry "arje (init=/sbin/arje-zero) — kernel $KVER" {
insmod gzio
insmod part_msdos
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
}
# END ARJE-MENUENTRY
EOF
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
- echo " ejecutando update-grub"
+ echo " ejecutando update-grub (Debian/Ubuntu)"
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
- echo " ejecutando grub-mkconfig"
+ echo " ejecutando grub-mkconfig (Arch/otros)"
grub-mkconfig -o /boot/grub/grub.cfg
else
- echo "[install-arje] WARN: no encontré update-grub ni grub-mkconfig." >&2
- echo " Regenerá grub.cfg manualmente." >&2
+ echo "[install-arje] WARN: no encontré update-grub, grub2-mkconfig ni grub-mkconfig." >&2
+ echo " Regenerá la grub.cfg manualmente." >&2
fi
cat <<EOF
diff --git a/scripts/uninstall-arje.sh b/scripts/uninstall-arje.sh
index 017cb60..8722528 100755
--- a/scripts/uninstall-arje.sh
+++ b/scripts/uninstall-arje.sh
@@ -38,6 +38,17 @@ fi
if command -v update-grub >/dev/null 2>&1; then
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
grub-mkconfig -o /boot/grub/grub.cfg
fi