Files
sergioandClaude Opus 4.8 3dcc0760fd scripts/ssh-e2e-test: spike E2E de sshd en QEMU (Etapa C, Paso 1 verde)
Spike de validación (sucio, temporal) que cierra el agujero que el bwrap
anidado dejó: bootea un work/rootfs-ssh-test desechable (4/4 limpio del
store + openssh + netup PRECOMPILADOS) como initramfs, con sshd como
genesis card supervisado por arje-zero, red slirp con hostfwd :2222->:22,
y hace el handshake SSH real desde el host.

VERDE: arje valida la seed e instancia el card sshd (Native/Restart);
netup toma lease 10.0.2.15 (DHCP slirp); ssh-keygen -A genera host keys;
sshd escucha :22; "Accepted publickey for root" y el guest ejecuta el
comando (HANDSHAKE_OK uid=0, Linux 6.16.12). Quema las 3 incógnitas:
(1) handshake post-chroot/setuid de privsep como root REAL del guest
(lo que el bwrap no pudo), (2) arje gobierna el lifecycle del daemon,
(3) los fds de red no segfaultean contra musl.

Gotchas: cpio -R 0:0 (sin root-owned /var/empty sshd lo rechaza);
chmod u+w sobre el staging (el store está sellado read-only).

NO es plomería de producto: la consolidación (SERVICE_COMPONENTS +
hidratacion tardia sobre el 4/4 verificado, of_tree blindado) es el Paso 2.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 17:23:06 -04:00

194 lines
9.5 KiB
Bash
Executable File

#!/usr/bin/env bash
# ssh-e2e-test.sh — SPIKE de validación (sucio, temporal) del Paso 1: probar el handshake SSH REAL
# contra un hammer booteado en QEMU, con sshd supervisado por arje-zero como genesis card.
#
# Esto NO es plomería de producto: arma un `work/rootfs-ssh-test` desechable = el 4/4 limpio del
# store + openssh + netup (todos PRECOMPILADOS de la caché del lab, no rebuild) + una seed con un
# card `sshd`, y lo bootea como initramfs. La red es QEMU slirp con hostfwd :2222->:22, así que el
# host hace `ssh -p 2222 root@localhost` SIN tocar firewall/bridge (la lección de netup). El chroot
# de privsep —que el bwrap anidado no pudo— acá corre como root REAL del guest.
#
# Quema tres incógnitas que el sandbox de bwrap dejó abiertas:
# 1. el handshake completo (post-chroot, post-setuid sshd-user) ocurre,
# 2. arje maneja el ciclo de vida del daemon (Restart) como cualquier card Native,
# 3. los fds de red de sshd no segfaultean contra el musl del target.
#
# Variables: KERNEL (def store/*-linux/boot/bzImage) MEM (def 2048) KVM (1 si /dev/kvm)
# PORT (hostfwd, def 2222) DEADLINE (def 150s)
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
cd "$ROOT"
KERNEL="${KERNEL:-$(ls store/*-linux/boot/bzImage 2>/dev/null | head -1)}"
MEM="${MEM:-2048}"
KVM="${KVM:-1}"
PORT="${PORT:-2222}"
DEADLINE="${DEADLINE:-150}"
WORK="$ROOT/work/ssh-e2e"
RFS="$ROOT/work/rootfs-ssh-test"
LOG="$WORK/console.log"
pick() { ls -d store/*-"$1" 2>/dev/null | head -1; }
MUSL=$(pick musl); BB=$(pick busybox); ARJE="store/69e2cab8ac00c33cde7680eb393952e2c7f9ae9af24c180c6593d7a86482efb9-arje-zero"
NETUP=$(pick netup); OSSH=$(pick openssh)
for v in KERNEL MUSL BB ARJE NETUP OSSH; do
p="${!v}"; [ -n "$p" ] && [ -e "$p" ] || { echo "falta $v ($p)"; exit 1; }
done
echo "==> kernel $KERNEL"
echo "==> componentes: musl busybox arje-zero netup openssh (precompilados del store)"
# ── 1) ensamblar el rootfs desechable ───────────────────────────────────────────────────────────
rm -rf "$RFS" "$WORK"; mkdir -p "$RFS" "$WORK"
for src in "$MUSL" "$BB" "$ARJE" "$NETUP" "$OSSH"; do
cp -a "$src"/. "$RFS"/ 2>/dev/null || true
done
chmod -R u+w "$RFS" # el store está sellado read-only; el staging debe ser escribible
rm -rf "$RFS/.hammer"
mkdir -p "$RFS"/{proc,sys,dev,dev/pts,run,tmp,etc,etc/ssh,root,root/.ssh,var,var/empty,ente,sbin,bin,usr/bin}
chmod 0711 "$RFS/var/empty"
ln -sf /usr/bin/arje-zero "$RFS/sbin/init"
# busybox a /bin/sh + applets que la getty/seed necesitan (por si el cp no dejó symlinks en /bin)
[ -e "$RFS/bin/busybox" ] || cp -a "$BB"/bin/busybox "$RFS/bin/busybox"
for a in sh getty; do ln -sf busybox "$RFS/bin/$a"; done
# usuarios: root (login por llave) + sshd (privsep). shells reales para que sshd ejecute el comando.
cat > "$RFS/etc/passwd" <<'EOF'
root:x:0:0:root:/root:/bin/sh
sshd:x:74:74:sshd privsep:/var/empty:/bin/false
EOF
cat > "$RFS/etc/group" <<'EOF'
root:x:0:
sshd:x:74:
EOF
cat > "$RFS/etc/ssh/sshd_config" <<'EOF'
Port 22
ListenAddress 0.0.0.0
PermitRootLogin yes
PubkeyAuthentication yes
PasswordAuthentication no
AuthorizedKeysFile /root/.ssh/authorized_keys
StrictModes no
Subsystem sftp /usr/libexec/sftp-server
EOF
# ── 2) llave de cliente desechable (host) → authorized_keys del guest ────────────────────────────
"$OSSH/usr/bin/ssh-keygen" -t ed25519 -N '' -f "$WORK/clientkey" -q
cat "$WORK/clientkey.pub" > "$RFS/root/.ssh/authorized_keys"
chmod 0600 "$RFS/root/.ssh/authorized_keys"
# ── 3) seed card: console-getty + un card `sshd` (Native, Restart) ───────────────────────────────
# El card sshd arranca la red (netup, DHCP slirp), genera host keys (ssh-keygen -A) y exec sshd -D.
# Mismo esquema validado de STAGE1_SEED_CARD (hammer-bootstrap); ULIDs nuevos A10/A11/A12.
SSHD_CMD='/usr/bin/netup; /usr/bin/ssh-keygen -A; exec /usr/sbin/sshd -D -e'
cat > "$RFS/ente/seed.card.json" <<EOF
{
"schema_version": 1,
"id": "01HQAR53D4M2NBV8KZTYXFQA10",
"lineage": null,
"label": "hammer-ssh-spike",
"provides": ["Spawn", "Journal"],
"requires": [],
"permissions": { "networking": "full", "filesystem": "read-write", "ipc": { "allow": [] }, "processes": true },
"soma": {
"namespaces": { "mount": false, "pid": false, "net": false, "uts": false, "ipc": false, "user": false, "cgroup": false },
"rlimits": { "mem_bytes": null, "nproc": null, "nofile": null },
"cgroup": { "path": "arje.slice/hammer", "cpu_weight": null, "io_weight": null },
"cpu_affinity": null
},
"payload": "Virtual",
"supervision": "OneShot",
"lifecycle": "daemon",
"priority": "normal",
"flow": { "input": [], "output": [] },
"genesis": [
{
"schema_version": 1,
"id": "01HQAR53D4M2NBV8KZTYXFQA11",
"lineage": null,
"label": "console-getty",
"provides": [],
"requires": [],
"permissions": { "networking": "none", "filesystem": "read-write", "ipc": { "allow": [] }, "processes": true },
"soma": {
"namespaces": { "mount": false, "pid": false, "net": false, "uts": false, "ipc": false, "user": false, "cgroup": false },
"rlimits": { "mem_bytes": null, "nproc": null, "nofile": null },
"cgroup": { "path": "arje.slice/getty", "cpu_weight": null, "io_weight": null },
"cpu_affinity": null
},
"payload": { "Native": { "exec": "/bin/busybox", "argv": ["getty", "-n", "-l", "/bin/sh", "115200", "console"], "envp": [] }},
"supervision": { "Restart": { "initial": 200, "max": 10000 } },
"lifecycle": "daemon",
"priority": "normal",
"flow": { "input": [], "output": [] },
"genesis": []
},
{
"schema_version": 1,
"id": "01HQAR53D4M2NBV8KZTYXFQA12",
"lineage": null,
"label": "sshd",
"provides": [],
"requires": [],
"permissions": { "networking": "full", "filesystem": "read-write", "ipc": { "allow": [] }, "processes": true },
"soma": {
"namespaces": { "mount": false, "pid": false, "net": false, "uts": false, "ipc": false, "user": false, "cgroup": false },
"rlimits": { "mem_bytes": null, "nproc": null, "nofile": null },
"cgroup": { "path": "arje.slice/sshd", "cpu_weight": null, "io_weight": null },
"cpu_affinity": null
},
"payload": { "Native": { "exec": "/bin/busybox", "argv": ["sh", "-c", "${SSHD_CMD}"], "envp": [["HOME", "/root"]] }},
"supervision": { "Restart": { "initial": 500, "max": 20000 } },
"lifecycle": "daemon",
"priority": "normal",
"flow": { "input": [], "output": [] },
"genesis": []
}
]
}
EOF
# ── 4) empaquetar initramfs ──────────────────────────────────────────────────────────────────────
# -R 0:0: el cpio corre como uid no-root; sin esto el rootfs queda con nuestro uid y sshd rechaza
# /var/empty ("must be owned by root"). Reasigna todo a root:root dentro del archivo.
( cd "$RFS" && find . -print0 | cpio --null -o -H newc -R 0:0 2>/dev/null | gzip -1 ) > "$WORK/initramfs.cpio.gz"
echo "==> initramfs $(du -h "$WORK/initramfs.cpio.gz" | cut -f1)"
# ── 5) boot QEMU (slirp + hostfwd) en background ─────────────────────────────────────────────────
accel=(-cpu Broadwell)
if [ "$KVM" = 1 ] && [ -w /dev/kvm ]; then accel=(-enable-kvm -cpu host); fi
echo "==> boot QEMU (mem ${MEM}, kvm $([ "${accel[0]}" = -enable-kvm ] && echo|| echo no)), hostfwd :$PORT->:22"
qemu-system-x86_64 -m "$MEM" -no-reboot -nographic "${accel[@]}" \
-kernel "$KERNEL" -initrd "$WORK/initramfs.cpio.gz" \
-append "console=ttyS0 rdinit=/sbin/init" \
-netdev "user,id=n0,hostfwd=tcp::$PORT-:22" -device e1000,netdev=n0 \
> "$LOG" 2>&1 &
QPID=$!
trap 'kill $QPID 2>/dev/null || true' EXIT
# ── 6) esperar a sshd y hacer el handshake real ──────────────────────────────────────────────────
SSHOPTS=(-p "$PORT" -i "$WORK/clientkey" -o StrictHostKeyChecking=no
-o UserKnownHostsFile=/dev/null -o GlobalKnownHostsFile=/dev/null
-o PasswordAuthentication=no -o ConnectTimeout=4 -o LogLevel=ERROR)
echo "==> esperando handshake (deadline ${DEADLINE}s)…"
start=$(date +%s); ok=0
while [ $(( $(date +%s) - start )) -lt "$DEADLINE" ]; do
if ! kill -0 $QPID 2>/dev/null; then echo "!! QEMU murió"; break; fi
if out=$(ssh "${SSHOPTS[@]}" root@localhost \
'echo HANDSHAKE_OK uid=$(id -u) user=$(id -un); uname -srm; cat /etc/os-release 2>/dev/null | head -1' 2>/dev/null); then
echo "================ RESPUESTA DEL GUEST ================"
echo "$out"
echo "===================================================="
echo "$out" | grep -q HANDSHAKE_OK && ok=1
break
fi
sleep 3
done
# ── 7) veredicto + diagnóstico ───────────────────────────────────────────────────────────────────
echo "---- consola (cola) ----"; tail -25 "$LOG" 2>/dev/null
if [ "$ok" = 1 ]; then
echo; echo "*** SSH E2E OK — handshake real contra hammer+arje, sshd como card ***"; exit 0
else
echo; echo "*** SSH E2E FALLÓ — ver $LOG ***"; exit 1
fi