diff --git a/forgetest/forgetest/suite/image.py b/forgetest/forgetest/suite/image.py index 8ceaeba..b62a8c6 100644 --- a/forgetest/forgetest/suite/image.py +++ b/forgetest/forgetest/suite/image.py @@ -106,7 +106,9 @@ def fds_of(pid): description="The image that is running is the image the manifest describes, with the " "kernel options, the module, the pulse ring it maps and the SDMA clocks it holds, " "the daemon ownership, " - "the init ordering, and the file modes the release depends on.") + "the init ordering, the file modes the release depends on, and the mounts: the " + "rootfs read-only, /data writable, the account files and the banner rendered " + "into tmpfs, the sshd host keys on /data, the factory slots on the dev image only.") def image_health(ctx): ev = ctx.evidence manifest = ctx.runner.manifest @@ -247,7 +249,55 @@ def image_health(ctx): ctx.log("/data free: %d MiB", free_mb) ctx.check(free_mb >= 20, "/data has only %d MiB free", free_mb) - # 8. the manifest itself is coherent + # 8. the mounts: the rootfs read-only, /data the writable partition, + # the state a read-only rootfs hands off (the read-only-rootfs image + # feature, forgefirm-users, forgefirm-banner, the sshd host keys). The + # dev image alone mounts the factory slots under /factory. + mounts = {} + for line in (_read("/proc/mounts", "") or "").splitlines(): + parts = line.split() + if len(parts) >= 4: + mounts[parts[1]] = {"source": parts[0], "type": parts[2], "opts": parts[3].split(",")} + + def mount_opts(path): + return (mounts.get(path) or {}).get("opts") or [] + + ev["mounts"] = {p: mounts[p] for p in ("/", "/data", "/var/lib", "/etc/passwd", "/etc/issue") if p in mounts} + ctx.log("/ mounted %s; /data %s; /var/lib %s", ",".join(mount_opts("/")) or "(absent)", + ",".join(mount_opts("/data")) or "(absent)", ",".join(mount_opts("/var/lib")) or "(absent)") + ctx.check("ro" in mount_opts("/"), "the rootfs is not mounted read-only: %s", mounts.get("/")) + ctx.check("rw" in mount_opts("/data"), "/data is not mounted read-write: %s", mounts.get("/data")) + ctx.check("rw" in mount_opts("/var/lib"), + "/var/lib is not a writable copy (read-only-rootfs-hook.sh): %s", mounts.get("/var/lib")) + rcs = _read("/etc/default/rcS", "") or "" + ctx.check("ROOTFS_READ_ONLY=yes" in rcs.splitlines(), "/etc/default/rcS lacks ROOTFS_READ_ONLY=yes") + factory = sorted(p for p in mounts if p.startswith("/factory/")) + ev["factory_mounts"] = factory + dev_image = os.path.exists("/etc/forgefirm-dev") + ev["dev_image"] = dev_image + ctx.log("dev image %s; /factory mounts: %s", dev_image, factory or "none") + if dev_image: + for n in (1, 2): + if os.path.exists("/dev/mmcblk2p%d" % n): + p = "/factory/img%d" % n + ctx.check("ro" in mount_opts(p), "%s is not mounted read-only on the dev image: %s", p, mounts.get(p)) + else: + ctx.check(not factory, "a release image mounts the factory slots: %s", factory) + if os.path.isfile("/data/forgefirm/users"): + names = [l.split(":")[0] for l in (_read("/data/forgefirm/users", "") or "").splitlines() + if l.strip() and not l.startswith("#")] + passwd_names = {l.split(":")[0] for l in (_read("/etc/passwd", "") or "").splitlines()} + ev["record_accounts"] = names + for f in ("/etc/passwd", "/etc/shadow", "/etc/group"): + ctx.check(f in mounts, "%s is not the tmpfs render of the account record", f) + for n in names: + ctx.check(n in passwd_names, "record account %r is missing from /etc/passwd", n) + ctx.check("/etc/issue" in mounts, "/etc/issue is not the bind-mounted banner copy") + if hw.pidof("sshd"): + key = "/data/forgefirm/ssh/ssh_host_ed25519_key" + ctx.check(os.path.isfile(key), "sshd runs but %s is missing", key) + + # 9. the manifest itself is coherent ctx.check(manifest.content_sha and len(manifest.content_sha) == 64, "manifest content_sha256 missing") ctx.check("kernel-module-glowforge" in manifest.components, "manifest lacks kernel-module-glowforge") ctx.check("linux-fslc" in manifest.components, "manifest lacks the kernel entry") diff --git a/meta-forgefirm/classes/forgefirm-image-manifest.bbclass b/meta-forgefirm/classes/forgefirm-image-manifest.bbclass index f846e69..5118cb5 100644 --- a/meta-forgefirm/classes/forgefirm-image-manifest.bbclass +++ b/meta-forgefirm/classes/forgefirm-image-manifest.bbclass @@ -47,7 +47,11 @@ FORGEFIRM_MANIFEST_PIN_SUFFIX ?= "-pin.inc" do_rootfs[depends] += "virtual/kernel:do_deploy kernel-module-glowforge:do_deploy" -ROOTFS_POSTPROCESS_COMMAND += "forgefirm_manifest_assemble;" +# No semicolon after the function name: image.bbclass makes the value of +# ROOTFS_POSTPROCESS_COMMAND the vardeps of do_rootfs, split on whitespace, +# so "name;" names nothing and a change to the function body would not +# make the rootfs again (execute_pre_post_process itself accepts both). +ROOTFS_POSTPROCESS_COMMAND += "forgefirm_manifest_assemble " forgefirm_manifest_assemble[vardepsexclude] += "DATETIME" def forgefirm_manifest_layer_content(path, skip_suffixes=('.md',)): diff --git a/meta-forgefirm/recipes-connectivity/openssh/openssh_%.bbappend b/meta-forgefirm/recipes-connectivity/openssh/openssh_%.bbappend index d3a84cd..d0e8c0b 100644 --- a/meta-forgefirm/recipes-connectivity/openssh/openssh_%.bbappend +++ b/meta-forgefirm/recipes-connectivity/openssh/openssh_%.bbappend @@ -12,6 +12,14 @@ # on (/run/forgefirm/ssh-enabled, tmpfs, gone at reboot) or on the dev # image (/etc/forgefirm-dev). The guard sits in check_for_no_start, which # start, reload and restart call; stop is never gated. +# +# The host keys live under /data/forgefirm/ssh: sshd_check_keys makes +# them at the first start (it reads the HostKey paths from the config), +# and they stay across updates, so the machine's fingerprint does not +# change with a release. The rootfs is read-only, and the +# read-only-rootfs image feature, finding no key in the image, selects +# sshd_config_readonly at rootfs time; both configs carry the same +# HostKey lines, so that selection changes nothing. do_install:append() { for config in sshd_config sshd_config_readonly; do @@ -21,10 +29,15 @@ do_install:append() { -e 's/^[#[:space:]]*PermitRootLogin .*/PermitRootLogin no/' \ -e 's/^[#[:space:]]*PermitEmptyPasswords .*/PermitEmptyPasswords no/' \ -e 's/^[#[:space:]]*PasswordAuthentication .*/PasswordAuthentication yes/' \ + -e '/^[#[:space:]]*HostKey /d' \ "$f" + for t in rsa ecdsa ed25519; do + echo "HostKey /data/forgefirm/ssh/ssh_host_${t}_key" >> "$f" + done grep -q '^PermitRootLogin no$' "$f" \ && grep -q '^PermitEmptyPasswords no$' "$f" \ && grep -q '^PasswordAuthentication yes$' "$f" \ + && [ "$(grep -c '^HostKey /data/forgefirm/ssh/' "$f")" = 3 ] \ || bbfatal "$config: the ForgeFIRM policy lines did not land" done diff --git a/meta-forgefirm/recipes-forgefirm/ffboot/files/ffboot b/meta-forgefirm/recipes-forgefirm/ffboot/files/ffboot index 86e0c85..794049b 100644 --- a/meta-forgefirm/recipes-forgefirm/ffboot/files/ffboot +++ b/meta-forgefirm/recipes-forgefirm/ffboot/files/ffboot @@ -55,8 +55,8 @@ probe_part () { if [ "$1" = "$BOOTED_ROOT" ]; then ROOT_DIR="" else - # Reuse an existing mount (the image keeps the factory slots mounted - # under /factory); a fresh mount is explicit -t ext4 - letting mount + # Reuse an existing mount (the dev image keeps the factory slots + # mounted under /factory); a fresh mount is explicit -t ext4 - letting mount # iterate types provokes a cosmetic kernel "Can't open blockdev" for # each foreign-type claim against an already-mounted device. ROOT_DIR=$(sed -n "s|^$1 \([^ ]*\).*|\1|p" /proc/mounts | head -n 1) diff --git a/meta-forgefirm/recipes-forgefirm/forgefirm-banner/files/forgefirm-banner b/meta-forgefirm/recipes-forgefirm/forgefirm-banner/files/forgefirm-banner index d7a5011..2cf3279 100644 --- a/meta-forgefirm/recipes-forgefirm/forgefirm-banner/files/forgefirm-banner +++ b/meta-forgefirm/recipes-forgefirm/forgefirm-banner/files/forgefirm-banner @@ -7,10 +7,15 @@ # 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. +# +# The rootfs is read-only: at the first change after boot the file is +# bind-mounted from a copy under /run/forgefirm (tmpfs) and the block is +# written through the mount. Before that the image's own file shows. PATH=/sbin:/usr/sbin:/bin:/usr/bin ISSUE=/etc/issue +STATE=/run/forgefirm/issue MARK_BEGIN='# ForgeFIRM addresses' MARK_END='# end' PANEL='Control panel: https://forgefirm.local/' @@ -51,13 +56,21 @@ 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.$$" +# A mount at the file, read from /proc/mounts (mountpoint(1) judges a +# file by its device numbers alone). +if ! awk -v t="$ISSUE" '$2 == t { f = 1 } END { exit !f }' /proc/mounts; then + mkdir -p "${STATE%/*}" \ + && cp -p "$ISSUE" "$STATE" \ + && mount --bind "$STATE" "$ISSUE" || exit 1 +fi + +tmp="$STATE.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" +cat "$tmp" > "$ISSUE" +rm -f "$tmp" exit 0 diff --git a/meta-forgefirm/recipes-forgefirm/forgefirm-logging/files/forgefirm-logging.init b/meta-forgefirm/recipes-forgefirm/forgefirm-logging/files/forgefirm-logging.init index 1ccb74b..baf5abd 100644 --- a/meta-forgefirm/recipes-forgefirm/forgefirm-logging/files/forgefirm-logging.init +++ b/meta-forgefirm/recipes-forgefirm/forgefirm-logging/files/forgefirm-logging.init @@ -22,6 +22,12 @@ CONF=/etc/logrotate.conf PIDFILE=/var/run/forgefirm-logging.pid LEGACY=/data/forgefirm/legacy-logs +# logrotate's state file (the last rotation of each file). The rootfs is +# read-only, so its default under /var/lib is not used; the rules are +# size-capped, so a state that starts fresh at every boot loses nothing. +# logrotate refuses to run at all when it cannot create the file. +STATE=/var/run/forgefirm-logrotate.status + RULES=/data/forgefirm/rsyslog-forgefirm.conf # The daemons log to /dev/log with non-blocking datagrams and drop what @@ -78,10 +84,10 @@ case "$1" in render sweep_legacy [ -x "$LOGROTATE" ] || exit 0 - "$LOGROTATE" "$CONF" 2>/dev/null + "$LOGROTATE" -s "$STATE" "$CONF" 2>/dev/null ( while :; do sleep 3600 - "$LOGROTATE" "$CONF" 2>/dev/null + "$LOGROTATE" -s "$STATE" "$CONF" 2>/dev/null done ) & echo $! > "$PIDFILE" ;; diff --git a/meta-forgefirm/recipes-forgefirm/forgefirm-persist/files/timestamp b/meta-forgefirm/recipes-forgefirm/forgefirm-persist/files/timestamp new file mode 100644 index 0000000..9a29bb5 --- /dev/null +++ b/meta-forgefirm/recipes-forgefirm/forgefirm-persist/files/timestamp @@ -0,0 +1,6 @@ +# /etc/default/timestamp - ForgeFIRM +# The last-known time: save-rtc.sh writes it at shutdown, bootmisc.sh +# sets the clock from it at boot when it is later than the clock (the +# board has no battery-backed RTC). The rootfs is read-only, so the file +# lives with the machine state on /data. +TIMESTAMP_FILE=/data/forgefirm/timestamp diff --git a/meta-forgefirm/recipes-forgefirm/forgefirm-persist/files/urandom b/meta-forgefirm/recipes-forgefirm/forgefirm-persist/files/urandom new file mode 100644 index 0000000..f973600 --- /dev/null +++ b/meta-forgefirm/recipes-forgefirm/forgefirm-persist/files/urandom @@ -0,0 +1,5 @@ +# /etc/default/urandom - ForgeFIRM +# The random seed the urandom init script carries from shutdown to the +# next boot. The rootfs is read-only, so the file lives with the machine +# state on /data. +RANDOM_SEED_FILE=/data/forgefirm/random-seed diff --git a/meta-forgefirm/recipes-forgefirm/forgefirm-persist/forgefirm-persist.bb b/meta-forgefirm/recipes-forgefirm/forgefirm-persist/forgefirm-persist.bb new file mode 100644 index 0000000..38b32ca --- /dev/null +++ b/meta-forgefirm/recipes-forgefirm/forgefirm-persist/forgefirm-persist.bb @@ -0,0 +1,23 @@ +SUMMARY = "ForgeFIRM boot state on /data: the timestamp and the random seed" +DESCRIPTION = "The two files the poky init scripts keep across boots, \ +pointed at /data/forgefirm because the rootfs is read-only: the boot \ +timestamp (bootmisc.sh restores it, save-rtc.sh writes it at shutdown; \ +the board has no battery-backed RTC) and the random seed (the urandom \ +script carries it from shutdown to the next boot)." +LICENSE = "MIT" +LIC_FILES_CHKSUM = "file://${COMMON_LICENSE_DIR}/MIT;md5=0835ade698e0bcf8506ecda2f7b4f302" + +SRC_URI = " \ + file://timestamp \ + file://urandom \ +" + +S = "${WORKDIR}" + +# The scripts that read these defaults. +RDEPENDS:${PN} = "initscripts" + +do_install() { + install -Dm 0644 ${WORKDIR}/timestamp ${D}${sysconfdir}/default/timestamp + install -Dm 0644 ${WORKDIR}/urandom ${D}${sysconfdir}/default/urandom +} diff --git a/meta-forgefirm/recipes-forgefirm/forgefirm-users/files/forgefirm-users.init b/meta-forgefirm/recipes-forgefirm/forgefirm-users/files/forgefirm-users.init index a5934cf..014031b 100644 --- a/meta-forgefirm/recipes-forgefirm/forgefirm-users/files/forgefirm-users.init +++ b/meta-forgefirm/recipes-forgefirm/forgefirm-users/files/forgefirm-users.init @@ -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 : 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 : 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 +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 diff --git a/meta-forgefirm/recipes-forgefirm/forgefirm-users/forgefirm-users.bb b/meta-forgefirm/recipes-forgefirm/forgefirm-users/forgefirm-users.bb index 63531b0..26c4f9e 100644 --- a/meta-forgefirm/recipes-forgefirm/forgefirm-users/forgefirm-users.bb +++ b/meta-forgefirm/recipes-forgefirm/forgefirm-users/forgefirm-users.bb @@ -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 diff --git a/meta-forgefirm/recipes-forgefirm/images/forgefirm-image-dev.bb b/meta-forgefirm/recipes-forgefirm/images/forgefirm-image-dev.bb index 603c041..dcf0bb2 100644 --- a/meta-forgefirm/recipes-forgefirm/images/forgefirm-image-dev.bb +++ b/meta-forgefirm/recipes-forgefirm/images/forgefirm-image-dev.bb @@ -43,6 +43,10 @@ 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)" +# The rootfs functions that write the string are in the do_rootfs +# signature; DATETIME must not be, or the basehash changes at every parse +# and bitbake refuses the build as non-deterministic. +FORGEFIRM_VERSION_STRING[vardepsexclude] += "DATETIME" # /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 @@ -52,4 +56,20 @@ 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;" +ROOTFS_POSTPROCESS_COMMAND += "write_forgefirm_dev_marker " + +# The two factory rootfs slots, read-only under /factory/img1 and +# /factory/img2: a bench convenience for reading a factory image in place +# (the ffboot inventory reuses the mounts). The release image mounts no +# factory slot; it reads one through a temporary read-only mount when it +# needs to (ffboot -l). No nofail: busybox mount hands it to the kernel +# as a filesystem parameter, which the kernel rejects (the mount fails +# with EINVAL); mount -a goes on past a slot that does not mount anyway. +add_factory_slot_mounts() { + install -d ${IMAGE_ROOTFS}/factory/img1 ${IMAGE_ROOTFS}/factory/img2 + printf '%s\n' \ + '/dev/mmcblk2p1 /factory/img1 auto ro,noatime 0 0' \ + '/dev/mmcblk2p2 /factory/img2 auto ro,noatime 0 0' \ + >> ${IMAGE_ROOTFS}${sysconfdir}/fstab +} +ROOTFS_POSTPROCESS_COMMAND += "add_factory_slot_mounts " diff --git a/meta-forgefirm/recipes-forgefirm/images/forgefirm-image.bb b/meta-forgefirm/recipes-forgefirm/images/forgefirm-image.bb index bda18d5..378981e 100644 --- a/meta-forgefirm/recipes-forgefirm/images/forgefirm-image.bb +++ b/meta-forgefirm/recipes-forgefirm/images/forgefirm-image.bb @@ -45,17 +45,35 @@ 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 +# forgefirm-users: renders 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). +# forgefirm-persist: the boot timestamp and the random seed on /data. # 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" +IMAGE_INSTALL:append = " forgefirm-users forgefirm-banner forgefirm-persist avahi-daemon" + +# The rootfs mounts read-only on both images; /data (p3) is the writable +# partition. read-only-rootfs is poky's feature for it: the root line of +# /etc/fstab (the BSP's, already ro) and ROOTFS_READ_ONLY in +# /etc/default/rcS, the volatile links made at rootfs time +# (populate-volatile.sh: /etc/resolv.conf, /tmp), a writable copy of +# /var/lib at boot (read-only-rootfs-hook.sh), a build failure for a +# package whose post-install must run on the machine, and the removal of +# the packages a read-only rootfs cannot use (shadow, base-passwd, +# update-rc.d, update-alternatives; the account files stay). What must +# last or change at run time is handled file by file: the account files +# and /etc/issue (forgefirm-users, forgefirm-banner), the sshd host keys +# (recipes-connectivity/openssh), the timestamp and the random seed +# (forgefirm-persist). The facts are on the docs site, +# technical/forgefirm/image-and-bsp; scripts/release.sh checks the built +# rootfs for this state. +IMAGE_FEATURES += "read-only-rootfs" # 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 @@ -105,7 +123,9 @@ write_forgefirm_version() { echo "ForgeFIRM ${FORGEFIRM_VERSION_STRING}" > ${IMAGE_ROOTFS}${sysconfdir}/motd } write_forgefirm_version[vardepsexclude] += "DATETIME" -ROOTFS_POSTPROCESS_COMMAND += "write_forgefirm_version;" +# No semicolon after a function name here or below (the vardeps rule in +# classes/forgefirm-image-manifest.bbclass). +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 @@ -132,4 +152,4 @@ pack_licenses() { > "${IMAGE_ROOTFS}${datadir}/forgefirm/licenses.tar.gz" rm -rf "$d" } -ROOTFS_POSTPROCESS_COMMAND += "pack_licenses;" +ROOTFS_POSTPROCESS_COMMAND += "pack_licenses " diff --git a/scripts/release.sh b/scripts/release.sh index 3b83ce4..7b3e62b 100644 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -241,6 +241,26 @@ ROOT_PW=$(printf '%s\n' "$ROOT_SHADOW" | awk -F: '{print $2}') || die "release rootfs has a non-empty root password field: the policy is an empty field (empty-root-password in forgefirm-image.bb); a build drift" echo "root policy gate OK (root field empty; sshd refuses root and empty passwords)" +# Read-only rootfs gate. The release rootfs mounts read-only: the root +# line of the built fstab carries ro, the rcS default agrees (the +# read-only-rootfs image feature), no factory-slot mount is in the +# release fstab (those belong to the dev image), and sshd keeps its host +# keys on /data, where the read-only rootfs cannot hold them. +FSTAB=$(debugfs -R "cat /etc/fstab" "$EXT4" 2>/dev/null) +[ -n "$FSTAB" ] \ + || die "release rootfs carries no /etc/fstab" +printf '%s\n' "$FSTAB" | awk '$1 == "/dev/root" && $2 == "/" { print $4 }' \ + | grep -Eq '(^|,)ro(,|$)' \ + || die "release fstab does not mount / read-only (base-files fstab or read-only-rootfs drift?)" +printf '%s\n' "$FSTAB" | grep -Eq '^[^#]*[[:space:]]/factory/' \ + && die "release fstab mounts a factory slot under /factory (dev image only)" +RCS=$(debugfs -R "cat /etc/default/rcS" "$EXT4" 2>/dev/null) +printf '%s\n' "$RCS" | grep -q '^ROOTFS_READ_ONLY=yes$' \ + || die "release rcS has no ROOTFS_READ_ONLY=yes (read-only-rootfs image feature drift?)" +[ "$(printf '%s\n' "$SSHD_CONFIG" | grep -c '^HostKey /data/forgefirm/ssh/')" = 3 ] \ + || die "release sshd_config does not keep the host keys under /data/forgefirm/ssh (recipes-connectivity/openssh drift?)" +echo "read-only rootfs gate OK (/ ro, no /factory mounts, host keys on /data)" + # Config-level guard: debug-tweaks must not sit in the shared kas config, # where it would apply to every target including the release image. if ( cd "$REPO" && kas dump kas/forgefirm-glowforge.yml 2>/dev/null ) \