From bf3c93cf8530f828a1f086cf4720c7ffd154e776 Mon Sep 17 00:00:00 2001 From: BFE Engineering Date: Sat, 29 Aug 2026 18:53:16 -0600 Subject: [PATCH] =?UTF-8?q?qemu:=20device-sim=20bringup=20=E2=80=94=20boot?= =?UTF-8?q?=20smoke,=20A/B=20disk=20harness,=20virt=20kernel=20variant?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The third simulator (deliberately not named "sim"): a QEMU -M virt VM that boots the real 6.18.46 kernel and enters at -kernel zImage — everything below (BootROM/idblock/U-Boot/real BCB A/B selection) is closed blobs + mask ROM and is explicitly out of scope. - qemu/mkinitramfs.sh: pinned static busybox (sha256 fail-closed) + rootfs/ - qemu/mkimage.sh: unprivileged sparse disk image with the device's canonical 12-partition blkdevparts A/B layout (vda == mmcblk0 mapping) - qemu/rootfs/: stage-1 init (by-name symlinks from PARTNAME uevents, whole-token warden.slot= parse, switch_root) + stage-2 init (userdata/oem mounts, slirp networking, payload daemon start) - qemu/run.sh: runner with --slot/--rtc/--watchdog/--rs485/--qmp/--display - qemu/configs/virt.fragment + WARDEN_KCONFIG_FRAGMENT hook in build/build-kernel.sh (canonical RV1106 build untouched when unset): adds PCI, pci-serial, i6300esb watchdog, WireGuard, virtio-gpu/input - qemu/tests/boot-smoke.sh: sentinel-asserting boot test Verified on QEMU 10.0.11: canonical zImage boots -M virt unmodified (the feared DEBUG_UNCOMPRESS decompressor hang does not exist in 6.18); full stack boots both slots; 12 by-name symlinks; userdata persists across reboot; -rtc base=2021-01-01 reproduces the no-RTC wrong-clock class. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_018HUayid7W5w7jBdb9Rrj1K --- .gitignore | 6 ++ build/build-kernel.sh | 16 +++ qemu/README.md | 47 +++++++++ qemu/blkdevparts.conf | 10 ++ qemu/busybox.sha256 | 1 + qemu/configs/virt.fragment | 36 +++++++ qemu/lib.sh | 78 ++++++++++++++ qemu/mkimage.sh | 99 ++++++++++++++++++ qemu/mkinitramfs.sh | 27 +++++ qemu/payload/README.md | 24 +++++ qemu/rootfs/etc/rc | 54 ++++++++++ qemu/rootfs/init | 32 ++++++ qemu/rootfs/sbin/init | 80 +++++++++++++++ qemu/rootfs/usr/share/udhcpc/default.script | 18 ++++ qemu/run.sh | 107 ++++++++++++++++++++ qemu/tests/boot-smoke.sh | 45 ++++++++ 16 files changed, 680 insertions(+) create mode 100644 qemu/README.md create mode 100644 qemu/blkdevparts.conf create mode 100644 qemu/busybox.sha256 create mode 100644 qemu/configs/virt.fragment create mode 100644 qemu/lib.sh create mode 100755 qemu/mkimage.sh create mode 100755 qemu/mkinitramfs.sh create mode 100644 qemu/payload/README.md create mode 100755 qemu/rootfs/etc/rc create mode 100755 qemu/rootfs/init create mode 100755 qemu/rootfs/sbin/init create mode 100755 qemu/rootfs/usr/share/udhcpc/default.script create mode 100755 qemu/run.sh create mode 100755 qemu/tests/boot-smoke.sh diff --git a/.gitignore b/.gitignore index 862a5b0..cb4c663 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,9 @@ __pycache__/ # driver MC/DC harness build dirs **/test/build/ + +# qemu device sim: build outputs (initramfs, disk images, cached busybox) and +# payload binaries (dropped in from flare-edge builds, never committed) +qemu/out/ +qemu/payload/* +!qemu/payload/README.md diff --git a/build/build-kernel.sh b/build/build-kernel.sh index 4e5c832..b346ef7 100755 --- a/build/build-kernel.sh +++ b/build/build-kernel.sh @@ -12,6 +12,9 @@ # SDK_TC dir holding the arm-rockchip830 uclibc cross toolchain bin/ # WORK build scratch dir (default: a mktemp under $TMPDIR) # JOBS parallel make jobs (default: nproc) +# WARDEN_KCONFIG_FRAGMENT +# optional kconfig fragment merged onto warden_defconfig +# (qemu/configs/virt.fragment builds the QEMU -M virt variant) # # Requires: `python` (not python3) on PATH — the SDK quirk; the CI runner provides # a project-local venv. Builds are SERIAL on the shared SDK box — never run two. @@ -97,6 +100,19 @@ log "patch series applied ($(basename "$SENTINEL") present)" # 4. configure log "configuring (warden_defconfig)" cp "$HERE/warden_defconfig" "$SRC/.config" +# Optional kconfig fragment overlay (e.g. qemu/configs/virt.fragment for the +# QEMU -M virt device-sim variant). Fail closed if set but unreadable — never +# silently build the wrong kernel. Unset => the canonical RV1106 build, +# byte-identical to a build without this hook. +if [ -n "${WARDEN_KCONFIG_FRAGMENT:-}" ]; then + [ -f "$WARDEN_KCONFIG_FRAGMENT" ] || { + echo "FATAL: WARDEN_KCONFIG_FRAGMENT set but not a file: $WARDEN_KCONFIG_FRAGMENT" >&2 + exit 1 + } + FRAG="$(cd "$(dirname "$WARDEN_KCONFIG_FRAGMENT")" && pwd)/$(basename "$WARDEN_KCONFIG_FRAGMENT")" + log "merging kconfig fragment $(basename "$FRAG")" + ( cd "$SRC" && ARCH=arm ./scripts/kconfig/merge_config.sh -m .config "$FRAG" ) +fi # CROSS_COMPILE defaults to the Luckfox SDK uclibc prefix (set SDK_TC to its bin/), # but the kernel is freestanding, so a caller may override with a generic arm cross # toolchain instead — e.g. CROSS_COMPILE=arm-linux-gnueabihf- (in Debian's diff --git a/qemu/README.md b/qemu/README.md new file mode 100644 index 0000000..6a4a8e2 --- /dev/null +++ b/qemu/README.md @@ -0,0 +1,47 @@ +# qemu/ — the WardenOS device simulator + +A QEMU virtual machine that boots the real forward-ported kernel (`build/` + +`patches/`) and real userspace, so the *device* — init, daemons, networking, +OTA, watchdog — can be tested off-hardware. The third simulator in the stack, +deliberately not named "sim": + +- `lvglsim` (flare-edge) — SDL desktop build of the UI. Rendering only. +- `sim/` (this repo) — register-level Rust models of RV1106 blocks behind + driver seams. +- `qemu/` (this) — the whole machine above the kernel entry point. + +## The boundary (read this before trusting a green run) + +There is no RV1106 machine model in QEMU and everything below the kernel is +closed rkbin blobs plus mask ROM, so the VM **enters at `-kernel zImage`** on +`-M virt` (generic ARMv7 machine, virtio peripherals). That means: + +- **Not emulated, not tested here:** BootROM, idblock/DDR-init, SPL, U-Boot, + the real BCB-driven A/B slot selection, bootcount auto-revert. The untested + A/B rollback chain stays untested by this tool. +- **Substituted, not modeled:** display (virtio-gpu, not VOP), input + (virtio-tablet, not GT911), network (virtio-net, not GMAC), storage + (virtio-blk, not eMMC). NPU/RGA/HPMCU behavior stays `sim/` territory. +- "Boots under emulation" is **not** "works on silicon". On-device claims + still need on-device evidence; the VM narrows which claims need a panel. + +## Quick start + +```sh +# 1. build the QEMU kernel variant (canonical RV1106 build + virt fragment) +WORK=$HOME/kbuild-out CROSS_COMPILE=arm-linux-gnueabihf- \ + WARDEN_KCONFIG_FRAGMENT=qemu/configs/virt.fragment bash build/build-kernel.sh + +# 2. build the initramfs (sha256-pinned static busybox + qemu/rootfs/) +bash qemu/mkinitramfs.sh + +# 3. smoke it +bash qemu/tests/boot-smoke.sh $HOME/kbuild-out/linux-6.18.46/arch/arm/boot/zImage +``` + +Host requirements: `qemu-system-arm` (Debian 13 ships QEMU 10), `curl`, `cpio`, +`gcc-arm-linux-gnueabihf` for the kernel build. + +Status: bringup — boot smoke only. Disk/network harness, RS485 bridge to +`sim/`, portal scenarios, and display/touch land in later phases (see +`docs/decisions/0006-qemu-device-sim.md` once written). diff --git a/qemu/blkdevparts.conf b/qemu/blkdevparts.conf new file mode 100644 index 0000000..4778504 --- /dev/null +++ b/qemu/blkdevparts.conf @@ -0,0 +1,10 @@ +# The device's canonical 12-partition A/B layout, expressed for the VM's +# virtio disk (vda). On hardware the same string names mmcblk0 and is baked +# into the U-Boot env — source: flare-edge docs/decisions/0003-partition-layout.md. +# There is no MBR/GPT anywhere: U-Boot and Linux both parse this string, which +# is why handing it to the VM kernel on the cmdline reproduces the exact +# partition map (vda9 = rootfs_a = hardware mmcblk0p9). +# +# Sourced by qemu/mkimage.sh (computes byte offsets from it) and qemu/run.sh +# (passes it verbatim in -append). Single source of truth — edit only here. +WARDEN_BLKDEVPARTS='vda:32K(env),512K@32K(idblock),512K(uboot),512K(misc),32M(boot_a),32M(boot_b),128M(oem_a),128M(oem_b),1G(rootfs_a),1G(rootfs_b),32M(recovery),1G(userdata)' diff --git a/qemu/busybox.sha256 b/qemu/busybox.sha256 new file mode 100644 index 0000000..35abe05 --- /dev/null +++ b/qemu/busybox.sha256 @@ -0,0 +1 @@ +cd04052b8b6885f75f50b2a280bfcbf849d8710c8e61d369c533acf307eda064 diff --git a/qemu/configs/virt.fragment b/qemu/configs/virt.fragment new file mode 100644 index 0000000..e833b3a --- /dev/null +++ b/qemu/configs/virt.fragment @@ -0,0 +1,36 @@ +# QEMU -M virt kernel variant — merged onto build/warden_defconfig via +# WARDEN_KCONFIG_FRAGMENT (see build/build-kernel.sh). The RV1106 zImage stays +# canonical and byte-identical when the variable is unset. +# +# The canonical zImage already BOOTS on -M virt as-is (verified 2026-08-29: +# the multi_v7 heritage supplies ARCH_VIRT + the virtio set, and DEBUG_LL's +# hardcoded RV1106 UART is inert as long as `earlyprintk` is never passed on +# the cmdline). This fragment therefore only ADDS what the device-sim +# scenarios need beyond the canonical config. + +# -M virt exposes exactly one PL011 (probed: QEMU 10 dtb has a single +# pl011@9000000); every additional device below rides the machine's PCIe +# (ECAM "host generic") root. +CONFIG_PCI=y +CONFIG_PCI_HOST_GENERIC=y + +# Second UART for the RS485/Modbus bridge: -device pci-serial (16550-class, +# shows up as ttyS0; stage-2 init aliases it to the device's /dev/ttyS4). +CONFIG_SERIAL_8250_PCI=y + +# /dev/watchdog for flared's watchdog_loop() — untestable on both existing +# sims. i6300esb is the watchdog QEMU offers on arm virt (PCI device): +# -device i6300esb -action watchdog=reset. +CONFIG_WATCHDOG=y +CONFIG_I6300ESB_WDT=y + +# wg0 mesh scenarios (flared owns identity, the panel shells out `wg`). +CONFIG_WIREGUARD=y + +# Display + touch: virtio-gpu scanout with fbdev emulation (the VM UI build +# uses LVGL's fbdev backend — no libdrm needed in the guest), virtio-tablet +# for absolute-coordinate touch injection via QMP. +CONFIG_FB=y +CONFIG_DRM_VIRTIO_GPU=y +CONFIG_DRM_FBDEV_EMULATION=y +CONFIG_VIRTIO_INPUT=y diff --git a/qemu/lib.sh b/qemu/lib.sh new file mode 100644 index 0000000..e622836 --- /dev/null +++ b/qemu/lib.sh @@ -0,0 +1,78 @@ +# Shared helpers for the qemu/ device-sim build scripts. Sourced, not executed. +# Callers must run under `set -euo pipefail` and define QEMU_DIR (the qemu/ dir). + +BB_VER=1.31.0 +BB_URL="https://busybox.net/downloads/binaries/${BB_VER}-defconfig-multiarch-musl/busybox-armv7l" + +qemu_log() { printf '\033[36m== %s\033[0m\n' "$*"; } + +# Fetch (or accept via $BUSYBOX) the pinned static armv7 busybox and verify it +# against qemu/busybox.sha256. FAILS CLOSED: a missing pin refuses to build, +# never silently skips verification — mirroring build/build-kernel.sh's +# tarball handling. Sets $BB to the verified binary's path. +qemu_get_busybox() { + local sha_file="$QEMU_DIR/busybox.sha256" + local out="${OUT:-$QEMU_DIR/out}" + mkdir -p "$out" + BB="${BUSYBOX:-$out/busybox-armv7l}" + if [ ! -f "$BB" ]; then + qemu_log "downloading $BB_URL" + curl -fSL "$BB_URL" -o "$BB" + fi + [ -f "$sha_file" ] || { + echo "FATAL: no pinned sha256 for busybox (expected $sha_file) — refusing to build from an unverified binary" >&2 + exit 1 + } + local want got + want="$(cat "$sha_file")" + got="$(sha256sum "$BB" | awk '{print $1}')" + [ "$want" = "$got" ] || { echo "busybox sha256 mismatch: want $want got $got" >&2; exit 1; } + qemu_log "busybox sha256 verified" +} + +# Stage the shared rootfs skeleton (qemu/rootfs/ + busybox) into $1. +# Requires qemu_get_busybox to have run (uses $BB). +qemu_stage_rootfs() { + local root="$1" + mkdir -p "$root/bin" "$root/sbin" "$root/dev" "$root/proc" "$root/sys" \ + "$root/etc" "$root/tmp" "$root/mnt" "$root/userdata" "$root/oem" \ + "$root/usr/bin" "$root/usr/share/udhcpc" + install -m 0755 "$BB" "$root/bin/busybox" + cp -a "$QEMU_DIR/rootfs/." "$root/" + chmod 0755 "$root/init" "$root/sbin/init" "$root/etc/rc" \ + "$root/usr/share/udhcpc/default.script" +} + +# Parse a "SIZE[@OFFSET](NAME)" blkdevparts entry list (without the "vda:" +# prefix) and invoke a callback `$1 name offset_bytes size_bytes` per entry. +qemu_each_partition() { + local cb="$1" parts entry size_s off_s name size off + parts="${WARDEN_BLKDEVPARTS#*:}" + off=0 + local IFS=',' + for entry in $parts; do + name="${entry##*(}"; name="${name%)}" + size_s="${entry%%(*}" + if [ "${size_s#*@}" != "$size_s" ]; then + off_s="${size_s#*@}"; size_s="${size_s%@*}" + off="$(qemu_to_bytes "$off_s")" + fi + size="$(qemu_to_bytes "$size_s")" + "$cb" "$name" "$off" "$size" + off=$((off + size)) + done +} + +qemu_to_bytes() { + local v="$1" n mult=1 + case "$v" in + *K) n="${v%K}"; mult=1024 ;; + *M) n="${v%M}"; mult=1048576 ;; + *G) n="${v%G}"; mult=1073741824 ;; + *) n="$v" ;; + esac + case "$n" in + ''|*[!0-9]*) echo "FATAL: bad blkdevparts size/offset token: '$v'" >&2; exit 1 ;; + esac + echo $((n * mult)) +} diff --git a/qemu/mkimage.sh b/qemu/mkimage.sh new file mode 100755 index 0000000..57ddd42 --- /dev/null +++ b/qemu/mkimage.sh @@ -0,0 +1,99 @@ +#!/usr/bin/env bash +# Build the VM's virtio disk image carrying the device's canonical 12-partition +# A/B layout (qemu/blkdevparts.conf — the same string U-Boot and Linux parse on +# hardware; there is no MBR/GPT). Every partition is placed at the exact offset +# the cmdline string declares; rootfs_a/rootfs_b/oem_a/oem_b/userdata get ext4, +# the boot-chain partitions (env/idblock/uboot/misc/boot_a/boot_b/recovery) +# stay zeroed — the VM enters at -kernel and never reads them. +# +# Built entirely UNPRIVILEGED: per-partition mkfs.ext4 -d (no loop mounts, no +# sudo), then dd'd into a sparse raw image. +# +# Usage: mkimage.sh [--portal-url URL] [--state KEY=VALUE]... +# Env: +# BUSYBOX path to a local busybox binary (skips the download; still verified) +# OUT output dir (default: qemu/out); image at $OUT/disk.img +set -euo pipefail + +QEMU_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +. "$QEMU_DIR/lib.sh" +. "$QEMU_DIR/blkdevparts.conf" +OUT="${OUT:-$QEMU_DIR/out}" +# mkfs.ext4 lives in sbin, which user shells on Debian don't have on PATH. +PATH="$PATH:/usr/sbin:/sbin" + +PORTAL_URL="" +STATE_KV=() +while [ $# -gt 0 ]; do + case "$1" in + --portal-url) PORTAL_URL="${2:?--portal-url needs a value}"; shift 2 ;; + --state) STATE_KV+=("${2:?--state needs KEY=VALUE}"); shift 2 ;; + *) echo "FATAL: unknown argument '$1' (usage: mkimage.sh [--portal-url URL] [--state KEY=VALUE]...)" >&2; exit 1 ;; + esac +done + +qemu_get_busybox + +SCRATCH="$(mktemp -d "${TMPDIR:-/tmp}/warden-qemu-image.XXXXXX")" +trap 'rm -rf "$SCRATCH"' EXIT + +# Stage the rootfs tree once (skeleton + payload into /usr/bin), reused for +# both slots so A and B start byte-identical, like a factory flash. +ROOT="$SCRATCH/root" +qemu_stage_rootfs "$ROOT" +for p in "$QEMU_DIR"/payload/*; do + [ -f "$p" ] || continue + case "$(basename "$p")" in README.md) continue ;; esac + install -m 0755 "$p" "$ROOT/usr/bin/$(basename "$p")" +done + +# Seed persistent state (flared: one file per key under /userdata/warden). +UDATA="$SCRATCH/userdata" +mkdir -p "$UDATA/warden" +[ -n "$PORTAL_URL" ] && printf '%s' "$PORTAL_URL" > "$UDATA/warden/flare.url" +for kv in ${STATE_KV[@]+"${STATE_KV[@]}"}; do + printf '%s' "${kv#*=}" > "$UDATA/warden/${kv%%=*}" +done + +mkdir -p "$SCRATCH/empty" + +# mkfs an ext4 partition image of exactly $2 bytes from staged dir $1. +mkfs_part() { + local stage="$1" bytes="$2" img="$3" + rm -f "$img" + truncate -s "$bytes" "$img" + mkfs.ext4 -F -q -d "$stage" "$img" +} + +DISK="$OUT/disk.img" +rm -f "$DISK" + +place_partition() { + local name="$1" off="$2" size="$3" stage="" + case "$name" in + rootfs_a|rootfs_b) stage="$ROOT" ;; + userdata) stage="$UDATA" ;; + oem_a|oem_b) stage="$SCRATCH/empty" ;; + *) stage="" ;; # boot-chain partition: left zeroed + esac + # dd in 4K blocks — every offset in the canonical layout is 4K-aligned; + # assert rather than assume, a misaligned write would corrupt a neighbor. + [ $((off % 4096)) -eq 0 ] && [ $((size % 4096)) -eq 0 ] || { + echo "FATAL: partition $name not 4K-aligned (off=$off size=$size)" >&2 + exit 1 + } + DISK_END=$((off + size)) + [ -z "$stage" ] && return 0 + local img="$SCRATCH/$name.img" + mkfs_part "$stage" "$size" "$img" + dd if="$img" of="$DISK" bs=4096 seek=$((off / 4096)) \ + conv=notrunc,sparse status=none + qemu_log " $name: ext4, $((size / 1048576))M @ $off" +} + +DISK_END=0 +qemu_log "building $DISK ($WARDEN_BLKDEVPARTS)" +truncate -s 0 "$DISK" +qemu_each_partition place_partition +truncate -s "$DISK_END" "$DISK" +qemu_log "disk image: $DISK ($(du -h "$DISK" | cut -f1) used, $((DISK_END / 1048576))M apparent)" diff --git a/qemu/mkinitramfs.sh b/qemu/mkinitramfs.sh new file mode 100755 index 0000000..4281527 --- /dev/null +++ b/qemu/mkinitramfs.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +# Build the QEMU device-sim initramfs: the pinned static busybox + qemu/rootfs/. +# The busybox binary is the ONLY external input (sha256-pinned, fail-closed — +# see qemu/lib.sh). +# +# Env: +# BUSYBOX path to a local busybox binary (skips the download; still verified) +# OUT output dir (default: qemu/out); initramfs at $OUT/initramfs.cpio.gz +set -euo pipefail + +QEMU_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +. "$QEMU_DIR/lib.sh" +OUT="${OUT:-$QEMU_DIR/out}" + +qemu_get_busybox + +# Assemble in a scratch dir, removed on exit (scratch-dir leaks were a past +# review finding in this repo). +SCRATCH="$(mktemp -d "${TMPDIR:-/tmp}/warden-qemu-initramfs.XXXXXX")" +trap 'rm -rf "$SCRATCH"' EXIT +qemu_stage_rootfs "$SCRATCH/root" + +# Pack (newc cpio, gzip). No device nodes required: /init mounts devtmpfs and +# reopens the console itself, so the archive builds unprivileged. +( cd "$SCRATCH/root" && find . -print0 | cpio -0 -o -H newc -R +0:+0 2>/dev/null ) \ + | gzip -9 > "$OUT/initramfs.cpio.gz" +qemu_log "initramfs: $OUT/initramfs.cpio.gz ($(du -h "$OUT/initramfs.cpio.gz" | cut -f1))" diff --git a/qemu/payload/README.md b/qemu/payload/README.md new file mode 100644 index 0000000..cba4483 --- /dev/null +++ b/qemu/payload/README.md @@ -0,0 +1,24 @@ +# qemu/payload/ — guest binaries (never committed) + +Drop **static musl armv7** binaries here; `qemu/mkimage.sh` copies everything +in this directory (except this README) into `/usr/bin/` of both rootfs slots. +Static musl is the same target the device uses for its Rust daemons, so the +exact production binaries run unmodified in the VM. + +Typical payload, built in a flare-edge checkout: + +```sh +# flared (static musl armv7) +tools/build-flared.sh --local +# warden-modbus and friends: see tools/build-firmware.sh for the recipes +``` + +Then: + +```sh +cp /target/armv7-unknown-linux-musleabihf/release/warden-flared qemu/payload/ +``` + +Stage-2 init starts `warden-flared` and `warden-modbus` automatically when +present (logs land in `/tmp/.log` inside the guest). An empty payload is +valid — the image boots to a busybox-only userspace. diff --git a/qemu/rootfs/etc/rc b/qemu/rootfs/etc/rc new file mode 100755 index 0000000..f868fd5 --- /dev/null +++ b/qemu/rootfs/etc/rc @@ -0,0 +1,54 @@ +#!/bin/busybox sh +# Stage-1 rc: sourced by /init (still PID 1, initramfs root) when a virtio +# disk is present. Emulates U-Boot's slot choice — parse warden.slot= from the +# cmdline, mount that rootfs, switch_root into it. This is an EMULATION of the +# A/B selection outcome, not the BCB/bootcount mechanism itself. + +# /dev/block/by-name/ symlinks: the contract flare-edge slotctl.rs +# relies on. blkdevparts= gives every vda partition a PARTNAME in sysfs. +mkdir -p /dev/block/by-name +for uev in /sys/class/block/vda*/uevent; do + [ -f "$uev" ] || continue + partname="" + devname="" + while IFS='=' read -r k v; do + case "$k" in + PARTNAME) partname="$v" ;; + DEVNAME) devname="$v" ;; + esac + done < "$uev" + [ -n "$partname" ] && [ -n "$devname" ] \ + && ln -sf "/dev/$devname" "/dev/block/by-name/$partname" +done + +# Slot select: whole-token parse of warden.slot= (never a substring match). +slot="_a" +for tok in $(cat /proc/cmdline); do + case "$tok" in + warden.slot=*) slot="${tok#warden.slot=}" ;; + esac +done +case "$slot" in + _a|_b) ;; + *) echo "rc: bad warden.slot='$slot', falling back to _a"; slot="_a" ;; +esac + +root="/dev/block/by-name/rootfs${slot}" +if [ ! -e "$root" ]; then + echo "rc: $root missing — staying in initramfs" + return 0 +fi + +mkdir -p /mnt +if ! mount -t ext4 "$root" /mnt; then + echo "rc: mount of $root failed — staying in initramfs" + return 0 +fi +if [ ! -x /mnt/sbin/init ]; then + echo "rc: $root has no /sbin/init — staying in initramfs" + umount /mnt + return 0 +fi + +echo "rc: switching root to rootfs${slot} ($root)" +exec switch_root /mnt /sbin/init diff --git a/qemu/rootfs/init b/qemu/rootfs/init new file mode 100755 index 0000000..ad759fe --- /dev/null +++ b/qemu/rootfs/init @@ -0,0 +1,32 @@ +#!/bin/busybox sh +# WardenOS QEMU device sim: initramfs /init (PID 1). +# +# Phase-1 duty: prove the kernel booted on -M virt — print the sentinel the +# smoke test greps for, then power off (PSCI SYSTEM_OFF, so qemu exits). +# `warden.shell` on the kernel cmdline drops to an interactive shell instead. + +/bin/busybox mount -t devtmpfs devtmpfs /dev 2>/dev/null + +# The cpio archive carries no device nodes (it is built unprivileged, no +# mknod); reopen stdio on the real console now that devtmpfs is mounted. +exec /dev/console 2>&1 + +/bin/busybox --install -s /bin +mount -t proc proc /proc +mount -t sysfs sysfs /sys + +echo "WARDEN-QEMU-BOOT-OK" + +# With a virtio disk attached, hand over to the stage-1 rc (by-name symlinks, +# slot select, switch_root). It only returns on failure — then fall through to +# the diskless shell/poweroff behavior below. +if [ -b /dev/vda ]; then + . /etc/rc +fi + +if grep -qw warden.shell /proc/cmdline; then + echo "warden.shell: interactive shell (exit to power off)" + setsid cttyhack sh +fi + +poweroff -f diff --git a/qemu/rootfs/sbin/init b/qemu/rootfs/sbin/init new file mode 100755 index 0000000..ac17777 --- /dev/null +++ b/qemu/rootfs/sbin/init @@ -0,0 +1,80 @@ +#!/bin/busybox sh +# Stage-2 init: PID 1 on the disk rootfs (rootfs_a or rootfs_b), reached via +# switch_root from the initramfs. Brings up the minimum a WardenOS userspace +# needs — mounts, by-name symlinks, network, serial alias — then starts any +# payload daemons and holds. This stands in for the device's BusyBox SysV +# /etc/init.d/S* sequence; it is deliberately tiny, not a model of it. + +/bin/busybox mount -t devtmpfs devtmpfs /dev 2>/dev/null +exec /dev/console 2>&1 +/bin/busybox --install -s /bin + +mount -t proc proc /proc +mount -t sysfs sysfs /sys +mount -t tmpfs tmpfs /tmp + +# Fresh devtmpfs — repopulate the by-name contract (slotctl.rs depends on it). +mkdir -p /dev/block/by-name +for uev in /sys/class/block/vda*/uevent; do + [ -f "$uev" ] || continue + partname="" + devname="" + while IFS='=' read -r k v; do + case "$k" in + PARTNAME) partname="$v" ;; + DEVNAME) devname="$v" ;; + esac + done < "$uev" + [ -n "$partname" ] && [ -n "$devname" ] \ + && ln -sf "/dev/$devname" "/dev/block/by-name/$partname" +done + +# Slot (whole-token parse, same rule as stage 1). +slot="_a" +for tok in $(cat /proc/cmdline); do + case "$tok" in + warden.slot=*) slot="${tok#warden.slot=}" ;; + esac +done + +# The device's matched mounts: persistent state and the slot's oem partition. +mount -t ext4 /dev/block/by-name/userdata /userdata || echo "init: userdata mount failed" +mount -t ext4 "/dev/block/by-name/oem${slot}" /oem || echo "init: oem${slot} mount failed" +mkdir -p /userdata/warden + +# RS485: warden-modbus hardcodes /dev/ttyS4 at compile time; alias it to the +# VM's pci-serial UART when one is present (needs the virt.fragment kernel). +[ -c /dev/ttyS0 ] && ln -sf /dev/ttyS0 /dev/ttyS4 + +# Network: slirp user-mode net on eth0 (DHCP, fallback to QEMU's static map). +ip link set lo up +if [ -e /sys/class/net/eth0 ]; then + ip link set eth0 up + if ! udhcpc -i eth0 -n -q -t 5 -T 2 >/dev/null 2>&1; then + ip addr add 10.0.2.15/24 dev eth0 2>/dev/null + ip route replace default via 10.0.2.2 dev eth0 + echo "nameserver 10.0.2.3" > /etc/resolv.conf + fi +fi + +hostname warden-qemu + +echo "WARDEN-QEMU-ROOTFS-OK slot=${slot}" + +# Payload daemons (dropped into /usr/bin by qemu/mkimage.sh from qemu/payload/). +for d in /usr/bin/warden-flared /usr/bin/warden-modbus; do + if [ -x "$d" ]; then + name="$(basename "$d")" + echo "init: starting $name" + "$d" > "/tmp/${name}.log" 2>&1 & + fi +done + +if grep -qw warden.shell /proc/cmdline; then + echo "warden.shell: interactive shell (exit powers off)" + setsid cttyhack sh + poweroff -f +fi + +# Hold: daemons run, console idles, scenarios drive the VM from outside. +while :; do sleep 3600; done diff --git a/qemu/rootfs/usr/share/udhcpc/default.script b/qemu/rootfs/usr/share/udhcpc/default.script new file mode 100755 index 0000000..3020db7 --- /dev/null +++ b/qemu/rootfs/usr/share/udhcpc/default.script @@ -0,0 +1,18 @@ +#!/bin/busybox sh +# Minimal udhcpc hook for the VM (busybox looks here by default). +[ -n "$1" ] || exit 1 +case "$1" in + deconfig) + ip addr flush dev "$interface" + ip link set "$interface" up + ;; + bound|renew) + ip addr replace "$ip/${mask:-24}" dev "$interface" + [ -n "${router:-}" ] && ip route replace default via "${router%% *}" dev "$interface" + if [ -n "${dns:-}" ]; then + : > /etc/resolv.conf + for d in $dns; do echo "nameserver $d" >> /etc/resolv.conf; done + fi + ;; +esac +exit 0 diff --git a/qemu/run.sh b/qemu/run.sh new file mode 100755 index 0000000..0868884 --- /dev/null +++ b/qemu/run.sh @@ -0,0 +1,107 @@ +#!/usr/bin/env bash +# Launch the WardenOS device VM (qemu-system-arm -M virt, single Cortex-A7, +# 256M — the RV1106G3's shape). See qemu/README.md for what this does and does +# not emulate. +# +# Usage: run.sh --kernel [options] [-- ] +# --kernel PATH zImage (canonical or virt.fragment variant) +# --initrd PATH initramfs (default: qemu/out/initramfs.cpio.gz) +# --disk PATH virtio disk image from mkimage.sh (default: qemu/out/disk.img +# when present; pass --no-disk for a diskless initramfs boot) +# --no-disk boot without a disk (initramfs shell/smoke behavior) +# --slot _a|_b rootfs slot to boot (default _a) +# --rtc DATE guest RTC base, e.g. 2021-01-01 — reproduces the no-RTC +# "device boots believing 2021" incident class +# --rs485 SOCK unix socket chardev for the RS485/Modbus bridge +# (pci-serial: needs the virt.fragment kernel) +# --watchdog add i6300esb watchdog, reset on expiry (fragment kernel) +# --qmp SOCK QMP unix socket (screendump, input-send-event, quit) +# --display MODE off (default, -nographic) | on (gtk window) | headless +# (virtio-gpu without a window; screendump via --qmp) +# --ssh-port N hostfwd 127.0.0.1:N -> guest :22 (default 2222) +# --http-port N hostfwd 127.0.0.1:N -> guest :80 (default 8080) +# --api-port N hostfwd 127.0.0.1:N -> guest :28443 (default 28443) +# --shell interactive shell in the guest instead of daemon hold +set -euo pipefail + +QEMU_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +. "$QEMU_DIR/blkdevparts.conf" +OUT="${OUT:-$QEMU_DIR/out}" + +KERNEL="" INITRD="$OUT/initramfs.cpio.gz" DISK="" NO_DISK=0 SLOT="_a" +RTC="" RS485="" WATCHDOG=0 QMP="" DISPLAY_MODE="off" SHELL_FLAG=0 +SSH_PORT=2222 HTTP_PORT=8080 API_PORT=28443 +EXTRA=() + +while [ $# -gt 0 ]; do + case "$1" in + --kernel) KERNEL="${2:?}"; shift 2 ;; + --initrd) INITRD="${2:?}"; shift 2 ;; + --disk) DISK="${2:?}"; shift 2 ;; + --no-disk) NO_DISK=1; shift ;; + --slot) SLOT="${2:?}"; shift 2 ;; + --rtc) RTC="${2:?}"; shift 2 ;; + --rs485) RS485="${2:?}"; shift 2 ;; + --watchdog) WATCHDOG=1; shift ;; + --qmp) QMP="${2:?}"; shift 2 ;; + --display) DISPLAY_MODE="${2:?}"; shift 2 ;; + --ssh-port) SSH_PORT="${2:?}"; shift 2 ;; + --http-port) HTTP_PORT="${2:?}"; shift 2 ;; + --api-port) API_PORT="${2:?}"; shift 2 ;; + --shell) SHELL_FLAG=1; shift ;; + --) shift; EXTRA=("$@"); break ;; + *) echo "FATAL: unknown argument '$1' (see header of $0)" >&2; exit 1 ;; + esac +done + +[ -n "$KERNEL" ] && [ -f "$KERNEL" ] || { + echo "FATAL: --kernel required and must exist (got '${KERNEL:-}')" >&2 + exit 1 +} +[ -f "$INITRD" ] || { + echo "FATAL: initramfs not found at $INITRD — run qemu/mkinitramfs.sh" >&2 + exit 1 +} +case "$SLOT" in _a|_b) ;; *) echo "FATAL: --slot must be _a or _b" >&2; exit 1 ;; esac + +if [ "$NO_DISK" -eq 0 ] && [ -z "$DISK" ] && [ -f "$OUT/disk.img" ]; then + DISK="$OUT/disk.img" +fi +if [ -n "$DISK" ] && [ ! -f "$DISK" ]; then + echo "FATAL: disk image $DISK not found — run qemu/mkimage.sh (or pass --no-disk)" >&2 + exit 1 +fi + +# NOTE: never add `earlyprintk` — the config's DEBUG_UART_PHYS is the RV1106's +# 0xff4c0000, which does not exist on -M virt. +APPEND="console=ttyAMA0 rdinit=/init" +ARGS=( + -M virt -cpu cortex-a7 -smp 1 -m 256M + -kernel "$KERNEL" -initrd "$INITRD" + -netdev "user,id=n0,hostfwd=tcp:127.0.0.1:${SSH_PORT}-:22,hostfwd=tcp:127.0.0.1:${HTTP_PORT}-:80,hostfwd=tcp:127.0.0.1:${API_PORT}-:28443" + -device virtio-net-device,netdev=n0 + -no-reboot +) + +if [ -n "$DISK" ] && [ "$NO_DISK" -eq 0 ]; then + APPEND="$APPEND blkdevparts=$WARDEN_BLKDEVPARTS warden.slot=$SLOT" + ARGS+=( -drive "if=none,file=$DISK,format=raw,id=vd0" + -device virtio-blk-device,drive=vd0 ) +fi +[ "$SHELL_FLAG" -eq 1 ] && APPEND="$APPEND warden.shell" +[ -n "$RTC" ] && ARGS+=( -rtc "base=$RTC" ) +[ "$WATCHDOG" -eq 1 ] && ARGS+=( -device i6300esb -action watchdog=reset ) +[ -n "$RS485" ] && ARGS+=( -chardev "socket,id=rs485,path=$RS485,server=on,wait=off" + -device pci-serial,chardev=rs485 ) +[ -n "$QMP" ] && ARGS+=( -qmp "unix:$QMP,server=on,wait=off" ) + +case "$DISPLAY_MODE" in + off) ARGS+=( -nographic ) ;; + on) ARGS+=( -device virtio-gpu-device,xres=720,yres=720 + -device virtio-tablet-device -serial mon:stdio ) ;; + headless) ARGS+=( -device virtio-gpu-device,xres=720,yres=720 + -device virtio-tablet-device -display none -serial mon:stdio ) ;; + *) echo "FATAL: --display must be off|on|headless" >&2; exit 1 ;; +esac + +exec qemu-system-arm "${ARGS[@]}" -append "$APPEND" ${EXTRA[@]+"${EXTRA[@]}"} diff --git a/qemu/tests/boot-smoke.sh b/qemu/tests/boot-smoke.sh new file mode 100755 index 0000000..d661d2e --- /dev/null +++ b/qemu/tests/boot-smoke.sh @@ -0,0 +1,45 @@ +#!/usr/bin/env bash +# Boot the warden kernel under qemu-system-arm -M virt and assert the initramfs +# sentinel appears on the console. FAILS CLOSED: a missing zImage, initramfs, +# or qemu binary is an error, never a skip. +# +# Usage: boot-smoke.sh [initramfs.cpio.gz] +set -euo pipefail + +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # qemu/tests/ +QDIR="$(cd "$HERE/.." && pwd)" # qemu/ + +ZIMAGE="${1:-}" +[ -n "$ZIMAGE" ] && [ -f "$ZIMAGE" ] || { + echo "FATAL: usage: $0 [initramfs] — zImage missing or not a file: '${ZIMAGE:-}'" >&2 + exit 1 +} +INITRD="${2:-$QDIR/out/initramfs.cpio.gz}" +[ -f "$INITRD" ] || { + echo "FATAL: initramfs not found at $INITRD — run qemu/mkinitramfs.sh first" >&2 + exit 1 +} +command -v qemu-system-arm >/dev/null || { + echo "FATAL: qemu-system-arm not on PATH (apt-get install qemu-system-arm) — see qemu/README.md" >&2 + exit 1 +} + +LOG="$(mktemp "${TMPDIR:-/tmp}/warden-qemu-smoke.XXXXXX")" +trap 'rm -f "$LOG"' EXIT + +# NOTE: never pass `earlyprintk` — the config's DEBUG_UART_PHYS is the RV1106's +# 0xff4c0000, which does not exist on -M virt. +timeout 180 qemu-system-arm \ + -M virt -cpu cortex-a7 -smp 1 -m 256M \ + -kernel "$ZIMAGE" -initrd "$INITRD" \ + -append "console=ttyAMA0 rdinit=/init" \ + -nographic -no-reboot &2 + exit 1 + } + +grep -q "WARDEN-QEMU-BOOT-OK" "$LOG" || { + echo "FATAL: boot sentinel WARDEN-QEMU-BOOT-OK not found in console log" >&2 + exit 1 +} +echo "boot smoke OK"