commissioning: the layer, the acceptance tests, the harness rule, the docs, and the bench drills

meta-forgefirm: the forgefirm-users init replays the account at boot;
sshd refuses root and empty passwords and runs only while the panel
turns it on; the release image keeps an empty root password for the
console; the console banner; avahi announces forgefirm.local; https in
libmicrohttpd and ulfius; the panel on 80 and 443; the license bundle on
the rootfs; release.sh checks the root policy on the built rootfs.

forgetest: the commission suites (commission, commission_dark,
commission_sheet: 23 cases); the runner turns cloud mode on with the
typed phrase for a test that declares it; the baseline's motor_lock is
0; the log-export test checks the bundle for the camera key; the record
helpers write bytes as given and join the daemon's paths as POSIX. The
stream harness gains rule 24: a hold verdict is held again after a
resume. Bench drills: lens_travel.py and lens_stop_accel.py.

Docs: BRINGUP carries the present state; CAMPAIGN-LOG carries the dated
record.
This commit is contained in:
ScottW514
2026-09-06 19:56:05 -04:00
parent ff9796cde6
commit 97287aa6a9
59 changed files with 15113 additions and 9139 deletions
@@ -14,7 +14,9 @@ S = "${WORKDIR}/git"
inherit cmake update-rc.d forgefirm-manifest
DEPENDS += "ulfius jpeg"
# jansson (settings, API bodies) and gnutls (the panel certificate) are
# linked directly, not only through ulfius.
DEPENDS += "ulfius jpeg jansson gnutls libxcrypt"
# media-ctl / v4l2-ctl configure the imx-media pipeline at runtime;
# the update manager drives ffboot + fwup and verifies against the
# shipped keyring; release checks and downloads use curl; the WiFi
@@ -0,0 +1,12 @@
#!/bin/sh
# udhcpc hook: /usr/share/udhcpc/default.script runs every file in
# /etc/udhcpc.d with the lease event as $1; 50default applies the lease
# first. Refresh the address block of the console banner on every event.
case "$1" in
bound|renew|deconfig|leasefail|nak)
/usr/sbin/forgefirm-banner
;;
esac
exit 0
@@ -0,0 +1,63 @@
#!/bin/sh
# Rewrites the address block of /etc/issue, the serial-console login
# banner: the control panel by mDNS name, then one https:// URL per
# global address of wlan0 (and eth0 when the machine has one). Every
# other line of /etc/issue stays as the image build wrote it. The block
# sits between the marker lines "# ForgeFIRM addresses" and "# end" and
# is appended when absent. Called by the init script at boot and by the
# udhcpc hook on every lease event. Idempotent: the file is written only
# when the block changes.
PATH=/sbin:/usr/sbin:/bin:/usr/bin
ISSUE=/etc/issue
MARK_BEGIN='# ForgeFIRM addresses'
MARK_END='# end'
PANEL='Control panel: https://forgefirm.local/'
# One URL per global address; an IPv6 address gets its URL brackets.
# Tentative, deprecated and temporary addresses are left out.
addresses () {
command -v ip >/dev/null 2>&1 || return 0
for dev in wlan0 eth0; do
[ -d "/sys/class/net/$dev" ] || continue
ip addr show dev "$dev" 2>/dev/null | awk '
($1 == "inet" || $1 == "inet6") && / scope global/ \
&& !/tentative/ && !/deprecated/ && !/temporary/ {
a = $2
sub(/\/.*/, "", a)
if ($1 == "inet6") a = "[" a "]"
print "https://" a "/"
}'
done
}
block () {
echo "$MARK_BEGIN"
echo "$PANEL"
addrs=$(addresses)
if [ -n "$addrs" ]; then
echo "$addrs"
else
echo "no network address yet"
fi
echo "$MARK_END"
}
[ -f "$ISSUE" ] || exit 0
new=$(block)
old=$(awk -v b="$MARK_BEGIN" -v e="$MARK_END" \
'$0 == b { p = 1 } p { print } $0 == e { p = 0 }' "$ISSUE")
[ "$new" = "$old" ] && exit 0
tmp="$ISSUE.tmp.$$"
awk -v b="$MARK_BEGIN" -v e="$MARK_END" -v blk="$new" '
$0 == b { print blk; seen = 1; skip = 1; next }
$0 == e && skip { skip = 0; next }
!skip { print }
END { if (!seen) print blk }
' "$ISSUE" > "$tmp" || { rm -f "$tmp"; exit 1; }
chmod 0644 "$tmp"
mv -f "$tmp" "$ISSUE"
exit 0
@@ -0,0 +1,26 @@
#!/bin/sh
### BEGIN INIT INFO
# Provides: forgefirm-banner
# Required-Start: $network
# Required-Stop:
# Default-Start: 2 3 4 5
# Default-Stop:
# Short-Description: ForgeFIRM console banner: control panel addresses
### END INIT INFO
# Writes the address block of /etc/issue once at boot; the udhcpc hook
# (/etc/udhcpc.d/60forgefirm-banner) keeps it current afterward.
case "$1" in
start|restart|reload|force-reload)
/usr/sbin/forgefirm-banner
;;
stop)
;;
*)
echo "Usage: $0 {start|stop|restart}"
exit 1
;;
esac
exit 0
@@ -0,0 +1,30 @@
SUMMARY = "ForgeFIRM console banner: the control panel addresses in /etc/issue"
DESCRIPTION = "Keeps an address block in the serial-console login banner \
(/etc/issue): the control panel by mDNS name and by every global address \
of wlan0 and eth0. Refreshed at boot and on every DHCP lease event."
LICENSE = "MIT"
LIC_FILES_CHKSUM = "file://${COMMON_LICENSE_DIR}/MIT;md5=0835ade698e0bcf8506ecda2f7b4f302"
SRC_URI = " \
file://forgefirm-banner \
file://forgefirm-banner.init \
file://60forgefirm-banner \
"
S = "${WORKDIR}"
inherit update-rc.d
INITSCRIPT_NAME = "forgefirm-banner"
# 99: after networking (S01); an address the lease brings later arrives
# through the udhcpc hook.
INITSCRIPT_PARAMS = "start 99 2 3 4 5 ."
do_install() {
install -Dm 0755 ${WORKDIR}/forgefirm-banner ${D}${sbindir}/forgefirm-banner
install -Dm 0755 ${WORKDIR}/forgefirm-banner.init ${D}${sysconfdir}/init.d/forgefirm-banner
# busybox udhcpc runs /etc/udhcpc.d/* (run-parts) on every lease event
install -Dm 0755 ${WORKDIR}/60forgefirm-banner ${D}${sysconfdir}/udhcpc.d/60forgefirm-banner
}
FILES:${PN} += "${sysconfdir}/udhcpc.d"
@@ -0,0 +1,11 @@
# ForgeFIRM: an interactive root shell (the serial console, or SSH on
# the dev image) starts with a warning. Non-interactive shells (scp,
# rsync, ssh with a command) print nothing.
case "$-" in
*i*)
if [ "$(id -u 2>/dev/null)" = "0" ]; then
echo "You are root on a laser cutter."
echo "A wrong command here can damage the machine or hurt someone. Take care."
fi
;;
esac
@@ -0,0 +1,152 @@
#!/bin/sh
### BEGIN INIT INFO
# Provides: forgefirm-users
# Required-Start: $local_fs
# Required-Stop:
# Default-Start: 2 3 4 5
# Default-Stop:
# Short-Description: ForgeFIRM accounts: replay the record into the system files
### END INIT INFO
# The account record is the source of truth for the operator accounts;
# the account files on the rootfs are rebuilt from it. forgectrl writes
# the record and runs "reload" here. At boot this runs at S05: /data is
# mounted (mountall, rcS) and sshd (S09) is not up yet.
#
# Record: /data/forgefirm/users, one line per account
# name:hash:uid
# hash is a sha512-crypt string ($6$...), uid is 1000 or more.
# Home directory /data/forgefirm/home/<name>, mode 0700. Shell /bin/sh.
#
# Rules:
# - idempotent: a second run changes nothing;
# - no record: nothing happens (a machine before the first-run wizard,
# or a bench image without /data);
# - every local account with a uid from 1000 to 65533 that the record
# does not name is removed, so an account reset removes the old
# account; root and the system accounts are never touched.
PATH=/sbin:/usr/sbin:/bin:/usr/bin
RECORD=/data/forgefirm/users
HOMES=/data/forgefirm/home
LOGIN_SHELL=/bin/sh
UID_LOW=1000
UID_HIGH=65533
log () {
echo "forgefirm-users: $*"
{ echo "forgefirm-users: $*" > /dev/kmsg; } 2>/dev/null
}
# A portable account name: a letter or underscore, then letters, digits,
# underscore, dash.
valid_name () {
case "$1" in
''|[!a-z_]*|*[!a-z0-9_-]*) return 1 ;;
esac
return 0
}
valid_uid () {
case "$1" in
''|*[!0-9]*) return 1 ;;
esac
[ "$1" -ge "$UID_LOW" ] && [ "$1" -le "$UID_HIGH" ]
}
passwd_uid () {
awk -F: -v n="$1" '$1 == n { print $3; exit }' /etc/passwd
}
group_exists () {
awk -F: -v n="$1" '$1 == n { f = 1 } END { exit !f }' /etc/group
}
# ensure_account name hash uid
ensure_account () {
name=$1
hash=$2
uid=$3
home="$HOMES/$name"
cur=$(passwd_uid "$name")
if [ -n "$cur" ] && [ "$cur" != "$uid" ]; then
log "account $name has uid $cur, the record says $uid: recreating it"
userdel -f "$name" >/dev/null 2>&1
groupdel "$name" >/dev/null 2>&1
cur=""
fi
if ! group_exists "$name"; then
groupadd -g "$uid" "$name" || { log "groupadd $name failed"; return 1; }
fi
if [ -z "$cur" ]; then
useradd -M -u "$uid" -g "$uid" -d "$home" -s "$LOGIN_SHELL" "$name" \
|| { log "useradd $name failed"; return 1; }
log "account $name created (uid $uid)"
fi
if [ ! -d "$home" ]; then
mkdir -p "$home" && chmod 0700 "$home"
fi
chown "$uid:$uid" "$home"
# -p stores the hash as it is. Home and shell are set again so an
# account file edited by hand converges on the record.
usermod -d "$home" -s "$LOGIN_SHELL" -p "$hash" "$name" \
|| log "usermod $name failed"
}
# Remove every local account in the operator uid range that the record
# does not name. KEEP holds the record's names, space separated.
prune () {
for name in $(awk -F: -v lo="$UID_LOW" -v hi="$UID_HIGH" \
'$3 + 0 >= lo && $3 + 0 <= hi { print $1 }' /etc/passwd); do
[ "$name" = root ] && continue
case " $KEEP " in
*" $name "*) continue ;;
esac
log "removing account $name (not in the record)"
userdel -f "$name" >/dev/null 2>&1 || log "userdel $name failed"
groupdel "$name" >/dev/null 2>&1
done
}
replay () {
[ -f "$RECORD" ] || return 0
mkdir -p "$HOMES"
KEEP=""
while IFS=: read -r name hash uid rest; do
[ -n "$name" ] || continue
case "$name" in '#'*) continue ;; esac
if ! valid_name "$name" || ! valid_uid "$uid" || [ -z "$hash" ]; then
log "skipping a malformed record line ($name)"
continue
fi
if [ "$name" = root ]; then
log "ignoring a root line in the record"
continue
fi
ensure_account "$name" "$hash" "$uid" && KEEP="$KEEP $name"
done < "$RECORD"
prune
}
case "$1" in
start|reload|restart|force-reload)
replay
;;
stop)
;;
status)
awk -F: -v lo="$UID_LOW" -v hi="$UID_HIGH" \
'$3 + 0 >= lo && $3 + 0 <= hi { print $1 " (uid " $3 ")" }' /etc/passwd
;;
*)
echo "Usage: $0 {start|stop|reload|restart|status}"
exit 1
;;
esac
exit 0
@@ -0,0 +1,29 @@
SUMMARY = "ForgeFIRM operator accounts: record replay and the root shell warning"
DESCRIPTION = "Replays the account record (/data/forgefirm/users, written \
by forgectrl) into the system account files at boot and on reload, \
removes the local accounts the record does not name, and installs the \
warning an interactive root shell prints."
LICENSE = "MIT"
LIC_FILES_CHKSUM = "file://${COMMON_LICENSE_DIR}/MIT;md5=0835ade698e0bcf8506ecda2f7b4f302"
SRC_URI = " \
file://forgefirm-users.init \
file://forgefirm-root.sh \
"
S = "${WORKDIR}"
inherit update-rc.d
INITSCRIPT_NAME = "forgefirm-users"
# 05: rcS has run (mountall mounted /data at S03) and sshd starts at
# S09, so the accounts exist before the first login can arrive.
INITSCRIPT_PARAMS = "start 05 2 3 4 5 ."
# useradd, groupadd, usermod, userdel, groupdel
RDEPENDS:${PN} += "shadow"
do_install() {
install -Dm 0755 ${WORKDIR}/forgefirm-users.init ${D}${sysconfdir}/init.d/forgefirm-users
install -Dm 0644 ${WORKDIR}/forgefirm-root.sh ${D}${sysconfdir}/profile.d/forgefirm-root.sh
}
@@ -18,7 +18,7 @@
DAEMON_BIN=/usr/bin/grblHAL_glowforge
TOKEN_FILE=/data/forgefirm/panel.token
CTRL_URL="http://127.0.0.1:${FORGECTRL_PORT:-8080}"
CTRL_URL="http://127.0.0.1:${FORGECTRL_PORT:-80}"
super_post() {
[ -r "$TOKEN_FILE" ] || return 1
@@ -43,3 +43,13 @@ IMAGE_ROOTFS_EXTRA_SPACE = "262144"
# Dev builds identify by build timestamp (matches the artifact name),
# tagged so a bench machine is never mistaken for a release.
FORGEFIRM_VERSION_STRING = "${DATETIME} (dev)"
# /etc/forgefirm-dev marks a dev image on the rootfs; its content is the
# version string. The sshd init script starts sshd on a dev image without
# the panel's enable flag (/run/forgefirm/ssh-enabled), and forgectrl
# reads the same marker. A release image has no such file.
write_forgefirm_dev_marker() {
echo "${FORGEFIRM_VERSION_STRING}" > ${IMAGE_ROOTFS}${sysconfdir}/forgefirm-dev
}
write_forgefirm_dev_marker[vardepsexclude] += "DATETIME"
ROOTFS_POSTPROCESS_COMMAND += "write_forgefirm_dev_marker;"
@@ -23,9 +23,10 @@ FORGEFIRM_RELEASE_TRIM ?= "nano"
IMAGE_INSTALL:remove = "python3 ${FORGEFIRM_RELEASE_TRIM}"
# grblhal-glowforge: the grblHAL motion controller (Grbl over TCP:23).
# forgectrl: the ForgeFIRM machine-services daemon (HTTP :8080): controller
# supervisor, pulse-device broker, cooling engine, cameras, telemetry,
# settings, diagnostics, web control panel, and A/B updates.
# forgectrl: the ForgeFIRM machine-services daemon (HTTP :80, HTTPS :443
# with a self-signed certificate): controller supervisor, pulse-device
# broker, cooling engine, cameras, telemetry, settings, diagnostics, web
# control panel, and A/B updates.
# gfhome: one-shot Glowforge web-service homing, invoked by the controller
# for $H when homing_mode = gfcloud (/data/forgefirm.conf).
# gfcloud: full Glowforge web-service controller daemon (the factory cloud
@@ -44,6 +45,31 @@ IMAGE_INSTALL:remove = "python3 ${FORGEFIRM_RELEASE_TRIM}"
# VIRTUAL-RUNTIME_base-utils-syslog (conf/distro/forgefirm.conf).
IMAGE_INSTALL:append = " grblhal-glowforge forgectrl gfhome gfcloud v4l-utils fwup ffboot slotmigrate forgefirm-logging"
# forgefirm-users: replays the operator account record
# (/data/forgefirm/users, written by forgectrl) into the system account
# files at boot, before sshd, and on reload; also installs the warning an
# interactive root shell prints. forgefirm-banner: keeps the control
# panel addresses in the serial-console banner (/etc/issue).
# avahi-daemon: mDNS, so the panel answers at https://forgefirm.local/
# and shows up in service browsers. The daemon is installed by name (the
# zeroconf distro feature stays off: it would bring libnss-mdns); the
# build options and the configuration are in conf/distro/forgefirm.conf
# and recipes-connectivity/avahi.
IMAGE_INSTALL:append = " forgefirm-users forgefirm-banner avahi-daemon"
# Root policy. root has no password and logs in at the serial console
# only: that is the recovery path when the network, the panel or an
# account is broken, and the console is behind the case. Over the
# network, sshd refuses root (PermitRootLogin no) and any account without
# a password (PermitEmptyPasswords no), both set by
# recipes-connectivity/openssh, and sshd runs only when the panel turns
# it on. Operator logins are the accounts in the record (forgefirm-users).
# empty-root-password keeps the rootfs postprocess from locking root
# (zap_empty_root_password in rootfs-postcommands.bbclass); it is not
# debug-tweaks, which belongs to the dev image alone and would open SSH.
# scripts/release.sh checks the built rootfs for exactly this state.
IMAGE_FEATURES += "empty-root-password"
# Mesa GLES2/EGL on etnaviv for forgectrl's GPU demosaic (loaded with
# dlopen at runtime; forgectrl itself has no build-time GL dependency,
# and without these packages it falls back to the NEON path).
@@ -80,3 +106,30 @@ write_forgefirm_version() {
}
write_forgefirm_version[vardepsexclude] += "DATETIME"
ROOTFS_POSTPROCESS_COMMAND += "write_forgefirm_version;"
# The license texts ride with the software. The license class writes
# the image's license manifest (every installed package with its
# license) and copies each package's license texts into
# /usr/share/common-licenses, one copy of each generic text and
# symlinks to it per package. That tree costs several megabytes of
# small files on a raw ext4 rootfs, so it is packed into one
# reproducible tar.gz (sorted names, no timestamps, no owners) at
# /usr/share/forgefirm/licenses.tar.gz and the tree is removed. The
# control panel serves the bundle and its manifest (GET /system/licenses,
# GET /system/licenses/manifest). The license class runs first
# (license_create_manifest is prepended to this list); this step is
# appended, so it runs after.
COPY_LIC_MANIFEST = "1"
COPY_LIC_DIRS = "1"
pack_licenses() {
d="${IMAGE_ROOTFS}${datadir}/common-licenses"
[ -d "$d" ] || bbfatal "pack_licenses: $d is missing (COPY_LIC_DIRS off?)"
[ -f "$d/license.manifest" ] || bbfatal "pack_licenses: no license.manifest in $d"
install -d "${IMAGE_ROOTFS}${datadir}/forgefirm"
tar -C "${IMAGE_ROOTFS}${datadir}" --sort=name --mtime=@0 --owner=0 --group=0 \
--numeric-owner -cf - common-licenses | gzip -9 -n \
> "${IMAGE_ROOTFS}${datadir}/forgefirm/licenses.tar.gz"
rm -rf "$d"
}
ROOTFS_POSTPROCESS_COMMAND += "pack_licenses;"