Mount the rootfs read-only on both images

The rootfs mounted read-write, so a slot ran with its own files open to
change, and the factory-slot mounts rode along on the release image.
Both images now carry the read-only-rootfs feature: the ro root line and
the rcS default, the volatile links made at rootfs time, a writable copy
of /var/lib at boot, a build failure for a post-install that needs the
machine, and the removal of shadow, base-passwd, update-rc.d and
update-alternatives.

What must last or change at run time is handled file by file:

- forgefirm-users renders the four account files from the record into
  /run/forgefirm/accounts and bind-mounts each copy over its /etc file
  (useradd and the rest are gone with shadow); a render writes through
  the mount, and the image's own files apply until the first render.
- forgefirm-banner bind-mounts a copy of /etc/issue and writes the
  address block through it.
- sshd keeps its host keys under /data/forgefirm/ssh, so the fingerprint
  survives updates; both sshd configs carry the same HostKey lines.
- forgefirm-logging passes logrotate a state file under /var/run
  (logrotate refuses to run without one).
- forgefirm-persist points the boot timestamp and the random seed at
  /data/forgefirm.

The dev image appends the /factory slot mounts, without nofail (busybox
mount hands it to the kernel, which rejects it). The rootfs command
entries lose their semicolons: on scarthgap the value is the task's
vardeps, split on whitespace, so "name;" left the function body out of
the signature and a changed body did not remake the rootfs; with the
bodies tracked, the dev image's DATETIME string needs a vardepsexclude.
release.sh gains the read-only gate (root ro, no /factory line,
ROOTFS_READ_ONLY=yes, host keys on /data). image.health checks the
mounts, the account binds, the banner bind, the host keys and the
dev-only /factory mounts.

Proven on the bench reference (dev image 20260909140901): / ro, /data
rw, /var/lib a tmpfs copy, the four account files and /etc/issue bound
from tmpfs, the host keys in /data/forgefirm/ssh, no "Read-only file
system" line in any log; forgectrl.auth and commission.account-login (a
temporary account rendered, logged in over HTTPS and removed again),
kernel.latch-locked-idle and motion.liveness-probe PASS; logrotate runs
with the volatile state. forgetest unit tests 335 OK; both images build
clean, and debugfs on the built rootfs shows every setting above.
This commit is contained in:
ScottW514
2026-09-09 11:02:10 -04:00
parent 2936890eaa
commit 8af8b197ee
14 changed files with 309 additions and 79 deletions
@@ -5,14 +5,24 @@
# Required-Stop:
# Default-Start: 2 3 4 5
# Default-Stop:
# Short-Description: ForgeFIRM accounts: replay the record into the system files
# Short-Description: ForgeFIRM accounts: render 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
# the system account files are rendered 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.
#
# The rootfs is read-only, so the four account files are not written in
# place. Each shows a copy of itself under /run/forgefirm/accounts
# (tmpfs), bind-mounted at the first render after boot: the image's own
# accounts (root and the system accounts) plus the record's. A render
# writes through the mount, so a login that arrives mid-write reads an
# empty file and is refused, never given a stale account. Until the
# first render the rootfs files are in effect: root at the console works
# from the first second of the boot. The shadow tools (useradd and the
# rest) are not on a read-only image; the lines are written here.
#
# Record: /data/forgefirm/users, one line per account
# name:hash:uid
# hash is a sha512-crypt string ($6$...), uid is 1000 or more.
@@ -23,13 +33,17 @@
# - 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.
# does not name is left out of the render, so an account reset
# removes the old account; root and the system accounts are never
# touched.
PATH=/sbin:/usr/sbin:/bin:/usr/bin
umask 077
RECORD=/data/forgefirm/users
HOMES=/data/forgefirm/home
STATE=/run/forgefirm/accounts
ACCOUNT_FILES="passwd shadow group gshadow"
LOGIN_SHELL=/bin/sh
UID_LOW=1000
UID_HIGH=65533
@@ -55,67 +69,74 @@ valid_uid () {
[ "$1" -ge "$UID_LOW" ] && [ "$1" -le "$UID_HIGH" ]
}
passwd_uid () {
awk -F: -v n="$1" '$1 == n { print $3; exit }' /etc/passwd
# is_mounted <path>: a mount (a file bind mount included) sits at path.
# Read from /proc/mounts: mountpoint(1) judges a file by its device
# numbers alone.
is_mounted () {
awk -v t="$1" '$2 == t { f = 1 } END { exit !f }' /proc/mounts
}
group_exists () {
awk -F: -v n="$1" '$1 == n { f = 1 } END { exit !f }' /etc/group
# The operator accounts the current files hold: every name in the
# operator uid range. A render replaces exactly these.
operator_names () {
awk -F: -v lo="$UID_LOW" -v hi="$UID_HIGH" \
'$3 + 0 >= lo && $3 + 0 <= hi && $1 != "root" { print $1 }' /etc/passwd
}
# ensure_account name hash uid
ensure_account () {
name=$1
hash=$2
uid=$3
home="$HOMES/$name"
# Each account file shows a tmpfs copy of itself. The copy keeps the
# file's mode and owner (cp -p); a file the image does not carry is
# skipped.
bind_files () {
mkdir -p "$STATE" || { log "cannot create $STATE"; return 1; }
for f in $ACCOUNT_FILES; do
[ -f "/etc/$f" ] || continue
is_mounted "/etc/$f" && continue
cp -p "/etc/$f" "$STATE/$f" || { log "cannot copy /etc/$f"; return 1; }
mount --bind "$STATE/$f" "/etc/$f" || { log "cannot bind /etc/$f"; return 1; }
done
return 0
}
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
# render_file <name>: the file less the operator accounts (DROP), plus
# one line per record account (ACCOUNTS: "name:hash:uid" lines). Written
# through the bind mount; a file the image does not carry is skipped.
render_file () {
f=$1
[ -f "/etc/$f" ] || return 0
is_mounted "/etc/$f" || { log "/etc/$f is not the tmpfs copy; not written"; return 1; }
tmp="$STATE/$f.new"
{
awk -F: -v drop=" $DROP " 'index(drop, " " $1 " ") == 0' "/etc/$f"
printf '%s\n' "$ACCOUNTS" | while IFS=: read -r name hash uid; do
[ -n "$name" ] || continue
case "$f" in
passwd) printf '%s:x:%s:%s::%s/%s:%s\n' "$name" "$uid" "$uid" "$HOMES" "$name" "$LOGIN_SHELL" ;;
shadow) printf '%s:%s:%s:0:99999:7:::\n' "$name" "$hash" "$DAY" ;;
group) printf '%s:x:%s:\n' "$name" "$uid" ;;
gshadow) printf '%s:!::\n' "$name" ;;
esac
done
} > "$tmp" || { rm -f "$tmp"; log "render of $f failed"; return 1; }
cat "$tmp" > "/etc/$f" || { rm -f "$tmp"; log "write of /etc/$f failed"; return 1; }
rm -f "$tmp"
return 0
}
# ensure_home <name> <uid>
ensure_home () {
home="$HOMES/$1"
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
chown "$2:$2" "$home"
}
replay () {
[ -f "$RECORD" ] || return 0
mkdir -p "$HOMES"
# 0755: a login traverses it to reach its home (the umask above is for
# the tmpfs state).
[ -d "$HOMES" ] || { mkdir -p "$HOMES" && chmod 0755 "$HOMES"; }
ACCOUNTS=""
KEEP=""
while IFS=: read -r name hash uid rest; do
[ -n "$name" ] || continue
@@ -128,9 +149,37 @@ replay () {
log "ignoring a root line in the record"
continue
fi
ensure_account "$name" "$hash" "$uid" && KEEP="$KEEP $name"
case " $KEEP " in
*" $name "*) log "skipping a second line for $name"; continue ;;
esac
ACCOUNTS="$ACCOUNTS$name:$hash:$uid
"
KEEP="$KEEP $name"
done < "$RECORD"
prune
bind_files || return 1
BEFORE=$(operator_names)
DROP=$(printf '%s' "$BEFORE" | tr '\n' ' ')
DAY=$(( $(date +%s) / 86400 ))
for f in $ACCOUNT_FILES; do
render_file "$f" || return 1
done
for name in $KEEP; do
case " $DROP " in
*" $name "*) ;;
*) log "account $name created" ;;
esac
uid=$(awk -F: -v n="$name" '$1 == n { print $3; exit }' /etc/passwd)
[ -n "$uid" ] && ensure_home "$name" "$uid"
done
for name in $BEFORE; do
case " $KEEP " in
*" $name "*) ;;
*) log "removing account $name (not in the record)" ;;
esac
done
return 0
}
case "$1" in
@@ -1,8 +1,9 @@
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."
SUMMARY = "ForgeFIRM operator accounts: record render and the root shell warning"
DESCRIPTION = "Renders the account record (/data/forgefirm/users, written \
by forgectrl) into the system account files at boot and on reload. The \
rootfs is read-only: the four files show tmpfs copies, bind-mounted, and \
a render writes through them; the accounts the record does not name are \
left out. Also installs the warning an interactive root shell prints."
LICENSE = "MIT"
LIC_FILES_CHKSUM = "file://${COMMON_LICENSE_DIR}/MIT;md5=0835ade698e0bcf8506ecda2f7b4f302"
@@ -16,12 +17,12 @@ 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.
# 05: rcS has run (mountall mounted /data and /run 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"
# No shadow tools: the read-only-rootfs image feature drops the shadow
# package from the image, and the script writes the account lines itself.
do_install() {
install -Dm 0755 ${WORKDIR}/forgefirm-users.init ${D}${sysconfdir}/init.d/forgefirm-users