diff --git a/meta-forgefirm/recipes-forgefirm/forgefirm-app/gfcloud.bb b/meta-forgefirm/recipes-forgefirm/forgefirm-app/gfcloud.bb index 06ccba9..d53ea65 100644 --- a/meta-forgefirm/recipes-forgefirm/forgefirm-app/gfcloud.bb +++ b/meta-forgefirm/recipes-forgefirm/forgefirm-app/gfcloud.bb @@ -5,7 +5,10 @@ require forgefirm-app.inc inherit update-rc.d INITSCRIPT_NAME = "gfcloud" -INITSCRIPT_PARAMS = "defaults 92" +# stop 80 < forgectrl's 90: at runlevel 0/6 the controller goes down +# BEFORE the daemon that carries the cooling engine, fire gates, and +# broker - never the other way around. +INITSCRIPT_PARAMS = "start 92 2 3 4 5 . stop 80 0 1 6 ." do_install() { install -Dm 0755 ${S}/forgefirm-app/gfcloud.py ${D}${sbindir}/gfcloud.py diff --git a/meta-forgefirm/recipes-forgefirm/forgefirm-logrotate/files/forgefirm-logrotate.init b/meta-forgefirm/recipes-forgefirm/forgefirm-logrotate/files/forgefirm-logrotate.init new file mode 100644 index 0000000..5b1f961 --- /dev/null +++ b/meta-forgefirm/recipes-forgefirm/forgefirm-logrotate/files/forgefirm-logrotate.init @@ -0,0 +1,43 @@ +#!/bin/sh +### BEGIN INIT INFO +# Provides: forgefirm-logrotate +# Required-Start: $local_fs +# Required-Stop: +# Default-Start: 2 3 4 5 +# Default-Stop: +# Short-Description: rotate the /data logs at boot and hourly +### END INIT INFO + +# The image runs no cron daemon, so rotation is driven here: once at +# every boot (bounds carry-over bloat), then hourly from a small +# background loop (bounds growth across long uptimes - a full /data +# breaks settings, update staging, and the controllers' own writes). + +LOGROTATE=/usr/sbin/logrotate +CONF=/etc/logrotate.conf +PIDFILE=/var/run/forgefirm-logrotate.pid + +case "$1" in + start) + [ -x "$LOGROTATE" ] || exit 0 + "$LOGROTATE" "$CONF" 2>/dev/null + ( while :; do + sleep 3600 + "$LOGROTATE" "$CONF" 2>/dev/null + done ) & + echo $! > "$PIDFILE" + ;; + stop) + [ -r "$PIDFILE" ] && kill "$(cat "$PIDFILE")" 2>/dev/null + rm -f "$PIDFILE" + ;; + restart) + $0 stop + $0 start + ;; + *) + echo "Usage: $0 {start|stop|restart}" + exit 1 + ;; +esac +exit 0 diff --git a/meta-forgefirm/recipes-forgefirm/forgefirm-logrotate/files/forgefirm.logrotate b/meta-forgefirm/recipes-forgefirm/forgefirm-logrotate/files/forgefirm.logrotate new file mode 100644 index 0000000..062068a --- /dev/null +++ b/meta-forgefirm/recipes-forgefirm/forgefirm-logrotate/files/forgefirm.logrotate @@ -0,0 +1,12 @@ +# ForgeFIRM logs on the persistent /data partition. copytruncate: the +# daemon and both controllers keep their log fds open for their whole +# lifetime, so the file must be truncated in place, never moved out +# from under them. +/data/forgectrl.log /data/glowforge.log /data/gfcloud.log /data/gfhome.log { + size 1M + rotate 3 + copytruncate + compress + missingok + notifempty +} diff --git a/meta-forgefirm/recipes-forgefirm/forgefirm-logrotate/forgefirm-logrotate.bb b/meta-forgefirm/recipes-forgefirm/forgefirm-logrotate/forgefirm-logrotate.bb new file mode 100644 index 0000000..06d0c5b --- /dev/null +++ b/meta-forgefirm/recipes-forgefirm/forgefirm-logrotate/forgefirm-logrotate.bb @@ -0,0 +1,26 @@ +SUMMARY = "Log rotation for the ForgeFIRM logs on /data" +DESCRIPTION = "Size-capped rotation for the daemon and controller logs \ +on the persistent /data partition: a full /data breaks settings, update \ +staging, and the controllers' own writes. Rotation runs at every boot \ +and hourly while the machine is up." +LICENSE = "MIT" +LIC_FILES_CHKSUM = "file://${COMMON_LICENSE_DIR}/MIT;md5=0835ade698e0bcf8506ecda2f7b4f302" + +SRC_URI = " \ + file://forgefirm.logrotate \ + file://forgefirm-logrotate.init \ +" + +S = "${WORKDIR}" + +inherit update-rc.d + +INITSCRIPT_NAME = "forgefirm-logrotate" +INITSCRIPT_PARAMS = "defaults 40" + +RDEPENDS:${PN} = "logrotate" + +do_install() { + install -Dm 0644 ${WORKDIR}/forgefirm.logrotate ${D}${sysconfdir}/logrotate.d/forgefirm + install -Dm 0755 ${WORKDIR}/forgefirm-logrotate.init ${D}${sysconfdir}/init.d/forgefirm-logrotate +} diff --git a/meta-forgefirm/recipes-forgefirm/grblhal-glowforge/grblhal-glowforge.bb b/meta-forgefirm/recipes-forgefirm/grblhal-glowforge/grblhal-glowforge.bb index 09eb9ae..c52a0d4 100644 --- a/meta-forgefirm/recipes-forgefirm/grblhal-glowforge/grblhal-glowforge.bb +++ b/meta-forgefirm/recipes-forgefirm/grblhal-glowforge/grblhal-glowforge.bb @@ -20,7 +20,10 @@ S = "${WORKDIR}/git" inherit cmake update-rc.d INITSCRIPT_NAME = "grblhal" -INITSCRIPT_PARAMS = "defaults 92" +# stop 80 < forgectrl's 90: at runlevel 0/6 the controller goes down +# BEFORE the daemon that carries the cooling engine, fire gates, and +# broker - never the other way around. +INITSCRIPT_PARAMS = "start 92 2 3 4 5 . stop 80 0 1 6 ." do_install:append() { install -d ${D}${sysconfdir}/init.d diff --git a/meta-forgefirm/recipes-forgefirm/grblhal-glowforge/grblhal-glowforge/grblhal.init b/meta-forgefirm/recipes-forgefirm/grblhal-glowforge/grblhal-glowforge/grblhal.init index ad79121..c85bd5b 100644 --- a/meta-forgefirm/recipes-forgefirm/grblhal-glowforge/grblhal-glowforge/grblhal.init +++ b/meta-forgefirm/recipes-forgefirm/grblhal-glowforge/grblhal-glowforge/grblhal.init @@ -11,21 +11,54 @@ # The controller lifecycle belongs to the forgectrl supervisor: it # spawns the controller selected by controller_mode as a direct child # (that relationship carries the pulse-device fd and death detection). -# This script remains only as a manual emergency stop. +# This script is the manual emergency lever, routed THROUGH the +# supervisor: stop asks it to stop the child and hold it down (a bare +# pkill would be safed and respawned seconds later); start resumes +# supervision. + +DAEMON_BIN=/usr/bin/grblHAL_glowforge +TOKEN_FILE=/data/forgefirm/panel.token +CTRL_URL="http://127.0.0.1:${FORGECTRL_PORT:-8080}" + +super_post() { + [ -r "$TOKEN_FILE" ] || return 1 + curl -sf -m 20 -X POST \ + -H "X-ForgeFIRM-Token: $(cat "$TOKEN_FILE")" \ + "$CTRL_URL/controller/$1" >/dev/null 2>&1 +} case "$1" in start) - echo "grblHAL is managed by forgectrl (POST /mode to switch controllers)" + if super_post start; then + echo "grblHAL supervision resumed (forgectrl spawns the selected controller)" + else + echo "grblHAL is managed by forgectrl (POST /mode to switch controllers)" + fi ;; stop) echo "Stopping grblHAL" - pkill -x grblHAL_glowfor 2>/dev/null + if ! super_post stop; then + # Supervisor unreachable: kill the controller directly. If a + # supervisor is alive after all, the child's exit still lands on + # its safing path. Match the full executable path, not the + # 15-char comm truncation. + pkill -f "^$DAEMON_BIN" 2>/dev/null + fi + ;; + status) + if pgrep -f "^$DAEMON_BIN" >/dev/null 2>&1; then + echo "grblHAL is running" + exit 0 + else + echo "grblHAL is not running" + exit 3 + fi ;; restart) $0 stop ;; *) - echo "Usage: $0 {start|stop|restart}" + echo "Usage: $0 {start|stop|status|restart}" exit 1 ;; esac diff --git a/meta-forgefirm/recipes-forgefirm/images/forgefirm-image.bb b/meta-forgefirm/recipes-forgefirm/images/forgefirm-image.bb index d2ccf43..51dc2fc 100644 --- a/meta-forgefirm/recipes-forgefirm/images/forgefirm-image.bb +++ b/meta-forgefirm/recipes-forgefirm/images/forgefirm-image.bb @@ -23,7 +23,9 @@ IMAGE_INSTALL:remove = "gfui-client" # to the inactive rootfs slot. # ffboot: boot-slot inventory and switching (also ships fw_env.config). # slotmigrate: boot-time reclaim of the legacy p4 layout (grows /data). -IMAGE_INSTALL:append = " grblhal-glowforge forgectrl gfhome gfcloud v4l-utils fwup ffboot slotmigrate" +# forgefirm-logrotate: size-capped rotation of the /data logs (boot + +# hourly; a full /data breaks settings, updates, and controller writes). +IMAGE_INSTALL:append = " grblhal-glowforge forgectrl gfhome gfcloud v4l-utils fwup ffboot slotmigrate forgefirm-logrotate" # NXP's firmware EULA covers the i.MX VPU/EPDC blobs the BSP installs, so the # image ships the license text with them (/usr/share/licenses/firmware-imx). diff --git a/meta-forgefirm/recipes-forgefirm/slotmigrate/files/slotmigrate b/meta-forgefirm/recipes-forgefirm/slotmigrate/files/slotmigrate index 3884261..156127e 100644 --- a/meta-forgefirm/recipes-forgefirm/slotmigrate/files/slotmigrate +++ b/meta-forgefirm/recipes-forgefirm/slotmigrate/files/slotmigrate @@ -57,11 +57,20 @@ P3_SIZE=$(sfdisk -d "$DISK" 2>/dev/null | sed -n "s|^${P3} .*size=[ ]*\([0-9]*\) [ -n "$DISK_SECT" ] && [ -n "$P3_START" ] && [ -n "$P3_SIZE" ] \ || { log "cannot read disk/p3 geometry"; exit 0; } -if [ $((P3_START + P3_SIZE)) -lt "$DISK_SECT" ]; then - log "growing p3 to the end of the disk ($((P3_START + P3_SIZE)) -> $DISK_SECT sectors)" +# 2048-sector tolerance (mirrors the filesystem branch): on a disk where +# the grow cannot land exactly on the last sector, an exact comparison +# would rewrite the MBR at S02 on EVERY boot - and a power loss inside +# that window costs the partition table and /data. +if [ $((P3_START + P3_SIZE)) -lt $((DISK_SECT - 2048)) ]; then + log "growing p3 toward the end of the disk ($((P3_START + P3_SIZE)) -> $DISK_SECT sectors)" echo ", +" | sfdisk --no-reread --force -N 3 "$DISK" >/dev/null 2>&1 \ || { log "p3 grow FAILED"; exit 0; } partx -u --nr 3 "$DISK" 2>/dev/null + P3_NEW=$(sfdisk -d "$DISK" 2>/dev/null | sed -n "s|^${P3} .*size=[ ]*\([0-9]*\),.*|\1|p") + if [ -n "$P3_NEW" ] && [ "$P3_NEW" = "$P3_SIZE" ]; then + log "p3 grow made no progress ($P3_SIZE sectors); leaving the table alone" + exit 0 + fi fi # --- filesystem ---------------------------------------------------------- @@ -71,8 +80,42 @@ FS_BLOCKS=$(tune2fs -l "$P3" 2>/dev/null | sed -n 's/^Block count:[ ]*//p') FS_BSIZE=$(tune2fs -l "$P3" 2>/dev/null | sed -n 's/^Block size:[ ]*//p') [ -n "$FS_BLOCKS" ] && [ -n "$FS_BSIZE" ] || { log "cannot read p3 filesystem"; exit 0; } +# Bounded retry: a resize that keeps failing must not cost a full +# e2fsck pass on every boot forever. Nothing else is mounted this +# early, so the attempt counter lives on p3 itself. +TRY_FILE=.slotmigrate-resize-tries +read_tries () { + TRIES=0 + T=$(mktemp -d) || return + if mount "$P3" "$T" 2>/dev/null; then + TRIES=$(cat "$T/$TRY_FILE" 2>/dev/null) + umount "$T" 2>/dev/null + fi + rmdir "$T" 2>/dev/null + case "$TRIES" in + ''|*[!0-9]*) TRIES=0 ;; + esac +} +write_tries () { + T=$(mktemp -d) || return + if mount "$P3" "$T" 2>/dev/null; then + if [ "$1" -gt 0 ]; then + echo "$1" > "$T/$TRY_FILE" + else + rm -f "$T/$TRY_FILE" + fi + umount "$T" 2>/dev/null + fi + rmdir "$T" 2>/dev/null +} + FS_SECT=$((FS_BLOCKS * (FS_BSIZE / 512))) if [ "$FS_SECT" -lt $((PART_SECT - 2048)) ]; then + read_tries + if [ "$TRIES" -ge 3 ]; then + log "resize2fs failed $TRIES times; giving up (grow /data manually with resize2fs $P3)" + exit 0 + fi log "growing /data filesystem ($FS_SECT -> $PART_SECT sectors)" e2fsck -f -p "$P3" >/dev/null 2>&1 RC=$? @@ -80,9 +123,13 @@ if [ "$FS_SECT" -lt $((PART_SECT - 2048)) ]; then log "e2fsck found errors (rc=$RC), NOT resizing" exit 0 fi - resize2fs "$P3" >/dev/null 2>&1 \ - && log "/data grown to full size" \ - || log "resize2fs FAILED (will retry next boot)" + if resize2fs "$P3" >/dev/null 2>&1; then + log "/data grown to full size" + write_tries 0 + else + log "resize2fs FAILED (attempt $((TRIES + 1)) of 3)" + write_tries $((TRIES + 1)) + fi fi exit 0 diff --git a/scripts/bench/build-feeder.sh b/scripts/bench/build-feeder.sh index 2b661c2..a6fc52a 100644 --- a/scripts/bench/build-feeder.sh +++ b/scripts/bench/build-feeder.sh @@ -1,7 +1,17 @@ #!/bin/bash +# Cross-compiles the bench feeder for the factory board, borrowing the +# Yocto cross toolchain from the ulfius recipe work directory. +# +# Environment (defaults derive from this script's location, assuming +# the standard multi-repo checkout layout): +# FF_SRC_TOP checkout holding the sibling repos +# FF_BUILD_TOP Yocto build tree (default: $FF_SRC_TOP/forgefirm/build) set -e -TC=/home/builder/dev/openglow-forgefirm/forgefirm/build/tmp/work/cortexa9t2hf-neon-fslc-linux-gnueabi/ulfius/2.7.15 SP="$(cd "$(dirname "$0")" && pwd)" +FF_SRC_TOP="${FF_SRC_TOP:-$(cd "$SP/../../.." && pwd)}" +FF_BUILD_TOP="${FF_BUILD_TOP:-$FF_SRC_TOP/forgefirm/build}" +TC="$FF_BUILD_TOP/tmp/work/cortexa9t2hf-neon-fslc-linux-gnueabi/ulfius/2.7.15" +[ -d "$TC/recipe-sysroot" ] || { echo "toolchain not staged at $TC (run: bitbake ulfius)"; exit 1; } "$TC/recipe-sysroot-native/usr/bin/arm-fslc-linux-gnueabi/arm-fslc-linux-gnueabi-gcc" \ --sysroot="$TC/recipe-sysroot" \ -mthumb -mfpu=neon -mfloat-abi=hard -mcpu=cortex-a9 \ diff --git a/scripts/bench/build-forgectrl.sh b/scripts/bench/build-forgectrl.sh index a600de2..8607200 100644 --- a/scripts/bench/build-forgectrl.sh +++ b/scripts/bench/build-forgectrl.sh @@ -1,14 +1,22 @@ #!/bin/bash -# Cross-compiles forgectrl (the canonical repo, ../../../forgectrl -> synced -# to ~/dev/openglow-forgefirm/forgectrl) for the factory board, borrowing the -# Yocto cross toolchain + sysroot from the forgectrl recipe work directory -# (which carries ulfius and libjpeg). If that path ages out after a clean, -# regenerate it with: bitbake forgectrl. Run from PowerShell: -# wsl -d forge-yocto -- bash /build-forgectrl.sh +# Cross-compiles forgectrl (the sibling repo) for the factory board, +# borrowing the Yocto cross toolchain + sysroot from the forgectrl +# recipe work directory (which carries ulfius and libjpeg). If that +# path ages out after a clean, regenerate it with: bitbake forgectrl. +# +# Environment (defaults derive from this script's location, assuming +# the standard multi-repo checkout layout): +# FF_SRC_TOP checkout holding the sibling repos +# FF_BUILD_TOP Yocto build tree (default: $FF_SRC_TOP/forgefirm/build) set -e -TC=/home/builder/dev/openglow-forgefirm/forgefirm/build/tmp/work/cortexa9t2hf-neon-fslc-linux-gnueabi/forgectrl/0.1.0 +SP="$(cd "$(dirname "$0")" && pwd)" +FF_SRC_TOP="${FF_SRC_TOP:-$(cd "$SP/../../.." && pwd)}" +FF_BUILD_TOP="${FF_BUILD_TOP:-$FF_SRC_TOP/forgefirm/build}" +TC="$FF_BUILD_TOP/tmp/work/cortexa9t2hf-neon-fslc-linux-gnueabi/forgectrl/0.1.0" +[ -d "$TC/recipe-sysroot" ] || { echo "toolchain not staged at $TC (run: bitbake forgectrl)"; exit 1; } export PATH="$TC/recipe-sysroot-native/usr/bin:$TC/recipe-sysroot-native/usr/bin/arm-fslc-linux-gnueabi:$PATH" -cd /home/builder/dev/openglow-forgefirm/forgectrl +LOG=$(mktemp -t fcbuild.XXXXXX) +cd "$FF_SRC_TOP/forgectrl" rm -rf build-arm cmake -B build-arm \ -DCMAKE_SYSTEM_NAME=Linux -DCMAKE_SYSTEM_PROCESSOR=arm \ @@ -16,7 +24,8 @@ cmake -B build-arm \ -DCMAKE_BUILD_TYPE=None \ "-DCMAKE_C_FLAGS=--sysroot=$TC/recipe-sysroot -mthumb -mfpu=neon -mfloat-abi=hard -mcpu=cortex-a9 -O2 -g" \ "-DCMAKE_EXE_LINKER_FLAGS=--sysroot=$TC/recipe-sysroot" \ - > /tmp/cmake-fc.log 2>&1 -cmake --build build-arm -j8 > /tmp/fcbuild.log 2>&1 || { tail -30 /tmp/fcbuild.log; exit 1; } + > "$LOG" 2>&1 +cmake --build build-arm -j8 >> "$LOG" 2>&1 || { tail -30 "$LOG"; exit 1; } +rm -f "$LOG" echo BUILD-OK file build-arm/forgectrl 2>/dev/null || ls build-arm/ diff --git a/scripts/bench/build-glowforge.sh b/scripts/bench/build-glowforge.sh index f0cf67b..b1b36fa 100644 --- a/scripts/bench/build-glowforge.sh +++ b/scripts/bench/build-glowforge.sh @@ -1,11 +1,22 @@ #!/bin/bash -# Cross-compiles grblHAL-glowforge (the canonical driver repo) for the -# factory board, borrowing the Yocto cross toolchain from the ulfius work -# directory. Run from PowerShell: wsl -d forge-yocto -- bash . +# Cross-compiles grblHAL-glowforge (the sibling driver repo) for the +# factory board, borrowing the Yocto cross toolchain from the ulfius +# recipe work directory. Regenerate a cleaned toolchain path with: +# bitbake ulfius. +# +# Environment (defaults derive from this script's location, assuming +# the standard multi-repo checkout layout): +# FF_SRC_TOP checkout holding the sibling repos +# FF_BUILD_TOP Yocto build tree (default: $FF_SRC_TOP/forgefirm/build) set -e -TC=/home/builder/dev/openglow-forgefirm/forgefirm/build/tmp/work/cortexa9t2hf-neon-fslc-linux-gnueabi/ulfius/2.7.15 +SP="$(cd "$(dirname "$0")" && pwd)" +FF_SRC_TOP="${FF_SRC_TOP:-$(cd "$SP/../../.." && pwd)}" +FF_BUILD_TOP="${FF_BUILD_TOP:-$FF_SRC_TOP/forgefirm/build}" +TC="$FF_BUILD_TOP/tmp/work/cortexa9t2hf-neon-fslc-linux-gnueabi/ulfius/2.7.15" +[ -d "$TC/recipe-sysroot" ] || { echo "toolchain not staged at $TC (run: bitbake ulfius)"; exit 1; } export PATH="$TC/recipe-sysroot-native/usr/bin:$TC/recipe-sysroot-native/usr/bin/arm-fslc-linux-gnueabi:$PATH" -cd /home/builder/dev/openglow-forgefirm/grblHAL-glowforge +LOG=$(mktemp -t gfbuild.XXXXXX) +cd "$FF_SRC_TOP/grblHAL-glowforge" rm -rf build-arm cmake -B build-arm \ -DCMAKE_SYSTEM_NAME=Linux -DCMAKE_SYSTEM_PROCESSOR=arm \ @@ -13,7 +24,8 @@ cmake -B build-arm \ -DCMAKE_BUILD_TYPE=None \ "-DCMAKE_C_FLAGS=--sysroot=$TC/recipe-sysroot -mthumb -mfpu=neon -mfloat-abi=hard -mcpu=cortex-a9 -O1 -g" \ "-DCMAKE_EXE_LINKER_FLAGS=--sysroot=$TC/recipe-sysroot" \ - > /tmp/cmake-gf.log 2>&1 -cmake --build build-arm -j8 > /tmp/gfbuild.log 2>&1 || { tail -30 /tmp/gfbuild.log; exit 1; } + > "$LOG" 2>&1 +cmake --build build-arm -j8 >> "$LOG" 2>&1 || { tail -30 "$LOG"; exit 1; } +rm -f "$LOG" echo BUILD-OK file build-arm/grblHAL_glowforge 2>/dev/null || ls build-arm/ diff --git a/scripts/ffboot b/scripts/ffboot index f725f62..3241989 100644 --- a/scripts/ffboot +++ b/scripts/ffboot @@ -61,9 +61,8 @@ probe_part () { # each foreign-type claim against an already-mounted device. ROOT_DIR=$(sed -n "s|^$1 \([^ ]*\).*|\1|p" /proc/mounts | head -n 1) if [ -z "$ROOT_DIR" ]; then - ROOT_DIR="/tmp/ffboot.probe.$$" P_MOUNTED=yes - mkdir -p "$ROOT_DIR" + ROOT_DIR=$(mktemp -d /tmp/ffboot.probe.XXXXXX) || return 1 if ! mount -o ro -t ext4 "$1" "$ROOT_DIR" 2>/dev/null; then rmdir "$ROOT_DIR" 2>/dev/null return 1 @@ -132,7 +131,7 @@ env_verify () { } set_env () { - SCRIPT="/tmp/ffboot.env.$$" + SCRIPT=$(mktemp /tmp/ffboot.env.XXXXXX) || return 1 # libubootenv format printf 'mmcdev=%s\nmmchwpart=%s\nmmcpart=%s\nmmcroot=%s\n' "$1" "$2" "$3" "$4" > "$SCRIPT" fw_setenv -c "$FWCONFIG" -s "$SCRIPT" 2>/dev/null diff --git a/scripts/install-forgefirm.sh b/scripts/install-forgefirm.sh index 3fd9607..d767cdf 100644 --- a/scripts/install-forgefirm.sh +++ b/scripts/install-forgefirm.sh @@ -52,7 +52,7 @@ stop_gf_services () { # dd's exit status is captured via a file so a read failure is not # masked by gzip succeeding on truncated input. archive_dev () { - RC_FILE="/tmp/ffinstall.rc.$$" + RC_FILE=$(mktemp /tmp/ffinstall.rc.XXXXXX) || return 1 rm -f "$RC_FILE" ( dd if="$1" bs=1M 2>/dev/null; echo $? > "$RC_FILE" ) | gzip -1 > "$2" & GZPID=$! @@ -83,9 +83,8 @@ slot_probe () { else RD=$(sed -n "s|^/dev/mmcblk2p$1 \([^ ]*\).*|\1|p" /proc/mounts | head -n 1) if [ -z "$RD" ]; then - RD="/tmp/ffinstall.probe.$$" S_MOUNTED=yes - mkdir -p "$RD" || return 1 + RD=$(mktemp -d /tmp/ffinstall.probe.XXXXXX) || return 1 mount -o ro -t ext4 "/dev/mmcblk2p$1" "$RD" 2>/dev/null \ || { rmdir "$RD" 2>/dev/null; S_TYPE=unknown; return 0; } fi @@ -116,6 +115,25 @@ slot_desc () { esac } +# ver_lt A B: true when semantic version A < B (leading v ignored). +# Returns false on any non-numeric component (e.g. a dev datetime +# stamp) - no verdict means no downgrade prompt, never a refusal. +ver_lt () { + VA=${1#v}; VB=${2#v} + [ "$VA" = "$VB" ] && return 1 + VI=1 + while [ "$VI" -le 3 ]; do + A=$(echo "$VA" | cut -d. -f$VI) + B=$(echo "$VB" | cut -d. -f$VI) + A=${A:-0}; B=${B:-0} + case "$A$B" in *[!0-9]*) return 1 ;; esac + [ "$A" -lt "$B" ] && return 0 + [ "$A" -gt "$B" ] && return 1 + VI=$((VI + 1)) + done + return 1 +} + # Verified atomic env flip (all four variables, classic u-boot-tools script # format first - that is what factory firmware ships - then libubootenv # format, then per-variable writes; read-back verified in every case). @@ -136,7 +154,7 @@ env_verify () { } set_env () { - SCRIPT="/tmp/ffinstall.env.$$" + SCRIPT=$(mktemp /tmp/ffinstall.env.XXXXXX) || return 1 printf 'mmcdev %s\nmmchwpart %s\nmmcpart %s\nmmcroot %s\n' "$1" "$2" "$3" "$4" > "$SCRIPT" fw_setenv -c "$FWCONFIG" -s "$SCRIPT" 2>/dev/null if env_verify "$1" "$2" "$3" "$4"; then rm -f "$SCRIPT"; return 0; fi @@ -257,12 +275,43 @@ else fi # --- verify signature --------------------------------------------------------- -KEYFILE="/tmp/forgefirm.pub.$$" +KEYFILE=$(mktemp /tmp/forgefirm.pub.XXXXXX) || die "cannot create temp file" printf "$PUBKEY" > "$KEYFILE" [ "$(wc -c < "$KEYFILE")" = "32" ] || die "embedded public key corrupt" echo -e "${ASTERISK}Verifying firmware signature:" fwup -V -i "$FW_FILE" -p "$KEYFILE" || { rm -f "$KEYFILE"; die "signature verification FAILED - refusing to install"; } -fwup -m -i "$FW_FILE" | grep meta-version + +# --- archive identity + downgrade gate ---------------------------------------- +META=$(fwup -m -i "$FW_FILE") +M_PRODUCT=$(echo "$META" | sed -n 's/^meta-product="\(.*\)"$/\1/p') +M_PLATFORM=$(echo "$META" | sed -n 's/^meta-platform="\(.*\)"$/\1/p') +M_VERSION=$(echo "$META" | sed -n 's/^meta-version="\(.*\)"$/\1/p') +[ "$M_PRODUCT" = "ForgeFIRM firmware" ] \ + || { rm -f "$KEYFILE"; die "archive product is '$M_PRODUCT', not ForgeFIRM firmware - wrong archive"; } +[ "$M_PLATFORM" = "glowforge" ] \ + || { rm -f "$KEYFILE"; die "archive platform is '$M_PLATFORM', not glowforge - wrong archive"; } +echo -e "${ASTERISK}Archive: $M_PRODUCT $M_VERSION ($M_PLATFORM)" + +# A validly signed OLDER release must never install silently; downgrades +# need an explicit yes (rollback stays possible, just deliberate). +INSTALLED="" +for S in 1 2; do + slot_probe "$S" + if [ "$S_TYPE" = "forgefirm" ] && [ -n "$S_VER" ]; then + if [ -z "$INSTALLED" ] || ver_lt "$INSTALLED" "$S_VER"; then + INSTALLED="$S_VER" + fi + fi +done +if [ -n "$INSTALLED" ] && ver_lt "$M_VERSION" "$INSTALLED"; then + echo -e "${ASTERISK}This archive ($M_VERSION) is OLDER than the installed ForgeFIRM ($INSTALLED)." + read -n1 -p "Install the downgrade anyway? [y/N] " YN + echo + case "$YN" in + y|Y) ;; + *) rm -f "$KEYFILE"; die "downgrade declined" ;; + esac +fi # --- apply to the inactive slot ----------------------------------------------- echo -e "${ASTERISK}Writing ForgeFIRM to slot $TARGET (/dev/mmcblk2p$TARGET):" @@ -274,8 +323,7 @@ fwup -a -d "/dev/mmcblk2p$TARGET" -i "$FW_FILE" -t "$TASK" -p "$KEYFILE" \ rm -f "$KEYFILE" # --- post-write verify -------------------------------------------------------- -MP="/tmp/ffinstall.verify.$$" -mkdir -p "$MP" +MP=$(mktemp -d /tmp/ffinstall.verify.XXXXXX) || die "cannot create temp dir" mount -o ro -t ext4 "/dev/mmcblk2p$TARGET" "$MP" || die "new rootfs does not mount" NEWVER=$(cat "$MP/etc/forgefirm-version" 2>/dev/null) [ -n "$NEWVER" ] || { umount "$MP"; die "new rootfs has no ForgeFIRM version stamp"; } diff --git a/scripts/mkfw.sh b/scripts/mkfw.sh index f0358a4..9da0676 100644 --- a/scripts/mkfw.sh +++ b/scripts/mkfw.sh @@ -72,10 +72,11 @@ EOF if [ -n "$KEY" ]; then [ -f "$KEY" ] || { echo "ERROR: key '$KEY' not found" >&2; exit 1; } "$FWUP" -S -s "$KEY" -i "$WORK/unsigned.fw" -o "$OUT" + # The post-sign self-check is mandatory: packing without it means a + # release could ship with a signature nothing ever verified. PUB="${KEY%.priv}.pub" - if [ -f "$PUB" ]; then - "$FWUP" -V -i "$OUT" -p "$PUB" || { echo "ERROR: signature self-check failed" >&2; exit 1; } - fi + [ -f "$PUB" ] || { echo "ERROR: public key '$PUB' not found - cannot self-check the signature; refusing to pack unverified" >&2; exit 1; } + "$FWUP" -V -i "$OUT" -p "$PUB" || { echo "ERROR: signature self-check failed" >&2; exit 1; } echo "signed: $OUT" else cp "$WORK/unsigned.fw" "$OUT" diff --git a/scripts/release.sh b/scripts/release.sh index 9231cac..be37898 100644 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -46,7 +46,11 @@ for ARG in "$@"; do --dev) MODE=dev ;; --publish) PUBLISH=1 ;; -*) die "unknown option $ARG" ;; - *) VERSION="$ARG" ;; + *) + [ -z "$VERSION" ] \ + || die "multiple versions given ('$VERSION' and '$ARG')" + VERSION="$ARG" + ;; esac done @@ -163,6 +167,10 @@ if [ -n "${FWUP_COMPAT:-}" ]; then || { rm -f "$RAW"; die "factory-era fwup rejects the archive"; } rm -f "$RAW" echo "factory-era fwup verification OK" +elif [ "$MODE" = release ] && [ -z "${FWUP_COMPAT_SKIP:-}" ]; then + # The factory-compat guarantee is a release property: a public release + # must not skip it silently. FWUP_COMPAT_SKIP=1 bypasses deliberately. + die "FWUP_COMPAT not set - factory-era verification is required for a release (set FWUP_COMPAT_SKIP=1 to bypass deliberately)" else warn "FWUP_COMPAT not set - factory-era verification skipped" fi