mirror of
https://github.com/openglow-org/forgefirm.git
synced 2026-09-28 09:11:11 -07:00
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:
@@ -106,7 +106,9 @@ def fds_of(pid):
|
|||||||
description="The image that is running is the image the manifest describes, with the "
|
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, "
|
"kernel options, the module, the pulse ring it maps and the SDMA clocks it holds, "
|
||||||
"the daemon ownership, "
|
"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):
|
def image_health(ctx):
|
||||||
ev = ctx.evidence
|
ev = ctx.evidence
|
||||||
manifest = ctx.runner.manifest
|
manifest = ctx.runner.manifest
|
||||||
@@ -247,7 +249,55 @@ def image_health(ctx):
|
|||||||
ctx.log("/data free: %d MiB", free_mb)
|
ctx.log("/data free: %d MiB", free_mb)
|
||||||
ctx.check(free_mb >= 20, "/data has only %d MiB free", 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(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("kernel-module-glowforge" in manifest.components, "manifest lacks kernel-module-glowforge")
|
||||||
ctx.check("linux-fslc" in manifest.components, "manifest lacks the kernel entry")
|
ctx.check("linux-fslc" in manifest.components, "manifest lacks the kernel entry")
|
||||||
|
|||||||
@@ -47,7 +47,11 @@ FORGEFIRM_MANIFEST_PIN_SUFFIX ?= "-pin.inc"
|
|||||||
|
|
||||||
do_rootfs[depends] += "virtual/kernel:do_deploy kernel-module-glowforge:do_deploy"
|
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"
|
forgefirm_manifest_assemble[vardepsexclude] += "DATETIME"
|
||||||
|
|
||||||
def forgefirm_manifest_layer_content(path, skip_suffixes=('.md',)):
|
def forgefirm_manifest_layer_content(path, skip_suffixes=('.md',)):
|
||||||
|
|||||||
@@ -12,6 +12,14 @@
|
|||||||
# on (/run/forgefirm/ssh-enabled, tmpfs, gone at reboot) or on the dev
|
# 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
|
# image (/etc/forgefirm-dev). The guard sits in check_for_no_start, which
|
||||||
# start, reload and restart call; stop is never gated.
|
# 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() {
|
do_install:append() {
|
||||||
for config in sshd_config sshd_config_readonly; do
|
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:]]*PermitRootLogin .*/PermitRootLogin no/' \
|
||||||
-e 's/^[#[:space:]]*PermitEmptyPasswords .*/PermitEmptyPasswords no/' \
|
-e 's/^[#[:space:]]*PermitEmptyPasswords .*/PermitEmptyPasswords no/' \
|
||||||
-e 's/^[#[:space:]]*PasswordAuthentication .*/PasswordAuthentication yes/' \
|
-e 's/^[#[:space:]]*PasswordAuthentication .*/PasswordAuthentication yes/' \
|
||||||
|
-e '/^[#[:space:]]*HostKey /d' \
|
||||||
"$f"
|
"$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 '^PermitRootLogin no$' "$f" \
|
||||||
&& grep -q '^PermitEmptyPasswords no$' "$f" \
|
&& grep -q '^PermitEmptyPasswords no$' "$f" \
|
||||||
&& grep -q '^PasswordAuthentication yes$' "$f" \
|
&& grep -q '^PasswordAuthentication yes$' "$f" \
|
||||||
|
&& [ "$(grep -c '^HostKey /data/forgefirm/ssh/' "$f")" = 3 ] \
|
||||||
|| bbfatal "$config: the ForgeFIRM policy lines did not land"
|
|| bbfatal "$config: the ForgeFIRM policy lines did not land"
|
||||||
done
|
done
|
||||||
|
|
||||||
|
|||||||
@@ -55,8 +55,8 @@ probe_part () {
|
|||||||
if [ "$1" = "$BOOTED_ROOT" ]; then
|
if [ "$1" = "$BOOTED_ROOT" ]; then
|
||||||
ROOT_DIR=""
|
ROOT_DIR=""
|
||||||
else
|
else
|
||||||
# Reuse an existing mount (the image keeps the factory slots mounted
|
# Reuse an existing mount (the dev image keeps the factory slots
|
||||||
# under /factory); a fresh mount is explicit -t ext4 - letting mount
|
# mounted under /factory); a fresh mount is explicit -t ext4 - letting mount
|
||||||
# iterate types provokes a cosmetic kernel "Can't open blockdev" for
|
# iterate types provokes a cosmetic kernel "Can't open blockdev" for
|
||||||
# each foreign-type claim against an already-mounted device.
|
# each foreign-type claim against an already-mounted device.
|
||||||
ROOT_DIR=$(sed -n "s|^$1 \([^ ]*\).*|\1|p" /proc/mounts | head -n 1)
|
ROOT_DIR=$(sed -n "s|^$1 \([^ ]*\).*|\1|p" /proc/mounts | head -n 1)
|
||||||
|
|||||||
@@ -7,10 +7,15 @@
|
|||||||
# is appended when absent. Called by the init script at boot and by the
|
# 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
|
# udhcpc hook on every lease event. Idempotent: the file is written only
|
||||||
# when the block changes.
|
# 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
|
PATH=/sbin:/usr/sbin:/bin:/usr/bin
|
||||||
|
|
||||||
ISSUE=/etc/issue
|
ISSUE=/etc/issue
|
||||||
|
STATE=/run/forgefirm/issue
|
||||||
MARK_BEGIN='# ForgeFIRM addresses'
|
MARK_BEGIN='# ForgeFIRM addresses'
|
||||||
MARK_END='# end'
|
MARK_END='# end'
|
||||||
PANEL='Control panel: https://forgefirm.local/'
|
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")
|
'$0 == b { p = 1 } p { print } $0 == e { p = 0 }' "$ISSUE")
|
||||||
[ "$new" = "$old" ] && exit 0
|
[ "$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" '
|
awk -v b="$MARK_BEGIN" -v e="$MARK_END" -v blk="$new" '
|
||||||
$0 == b { print blk; seen = 1; skip = 1; next }
|
$0 == b { print blk; seen = 1; skip = 1; next }
|
||||||
$0 == e && skip { skip = 0; next }
|
$0 == e && skip { skip = 0; next }
|
||||||
!skip { print }
|
!skip { print }
|
||||||
END { if (!seen) print blk }
|
END { if (!seen) print blk }
|
||||||
' "$ISSUE" > "$tmp" || { rm -f "$tmp"; exit 1; }
|
' "$ISSUE" > "$tmp" || { rm -f "$tmp"; exit 1; }
|
||||||
chmod 0644 "$tmp"
|
cat "$tmp" > "$ISSUE"
|
||||||
mv -f "$tmp" "$ISSUE"
|
rm -f "$tmp"
|
||||||
exit 0
|
exit 0
|
||||||
|
|||||||
@@ -22,6 +22,12 @@ CONF=/etc/logrotate.conf
|
|||||||
PIDFILE=/var/run/forgefirm-logging.pid
|
PIDFILE=/var/run/forgefirm-logging.pid
|
||||||
LEGACY=/data/forgefirm/legacy-logs
|
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
|
RULES=/data/forgefirm/rsyslog-forgefirm.conf
|
||||||
|
|
||||||
# The daemons log to /dev/log with non-blocking datagrams and drop what
|
# The daemons log to /dev/log with non-blocking datagrams and drop what
|
||||||
@@ -78,10 +84,10 @@ case "$1" in
|
|||||||
render
|
render
|
||||||
sweep_legacy
|
sweep_legacy
|
||||||
[ -x "$LOGROTATE" ] || exit 0
|
[ -x "$LOGROTATE" ] || exit 0
|
||||||
"$LOGROTATE" "$CONF" 2>/dev/null
|
"$LOGROTATE" -s "$STATE" "$CONF" 2>/dev/null
|
||||||
( while :; do
|
( while :; do
|
||||||
sleep 3600
|
sleep 3600
|
||||||
"$LOGROTATE" "$CONF" 2>/dev/null
|
"$LOGROTATE" -s "$STATE" "$CONF" 2>/dev/null
|
||||||
done ) &
|
done ) &
|
||||||
echo $! > "$PIDFILE"
|
echo $! > "$PIDFILE"
|
||||||
;;
|
;;
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -5,14 +5,24 @@
|
|||||||
# Required-Stop:
|
# Required-Stop:
|
||||||
# Default-Start: 2 3 4 5
|
# Default-Start: 2 3 4 5
|
||||||
# Default-Stop:
|
# 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
|
### END INIT INFO
|
||||||
|
|
||||||
# The account record is the source of truth for the operator accounts;
|
# 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 system account files are rendered from it. forgectrl writes the
|
||||||
# the record and runs "reload" here. At boot this runs at S05: /data is
|
# record and runs "reload" here. At boot this runs at S05: /data is
|
||||||
# mounted (mountall, rcS) and sshd (S09) is not up yet.
|
# 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
|
# Record: /data/forgefirm/users, one line per account
|
||||||
# name:hash:uid
|
# name:hash:uid
|
||||||
# hash is a sha512-crypt string ($6$...), uid is 1000 or more.
|
# 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,
|
# - no record: nothing happens (a machine before the first-run wizard,
|
||||||
# or a bench image without /data);
|
# or a bench image without /data);
|
||||||
# - every local account with a uid from 1000 to 65533 that the record
|
# - 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
|
# does not name is left out of the render, so an account reset
|
||||||
# account; root and the system accounts are never touched.
|
# removes the old account; root and the system accounts are never
|
||||||
|
# touched.
|
||||||
|
|
||||||
PATH=/sbin:/usr/sbin:/bin:/usr/bin
|
PATH=/sbin:/usr/sbin:/bin:/usr/bin
|
||||||
|
umask 077
|
||||||
|
|
||||||
RECORD=/data/forgefirm/users
|
RECORD=/data/forgefirm/users
|
||||||
HOMES=/data/forgefirm/home
|
HOMES=/data/forgefirm/home
|
||||||
|
STATE=/run/forgefirm/accounts
|
||||||
|
ACCOUNT_FILES="passwd shadow group gshadow"
|
||||||
LOGIN_SHELL=/bin/sh
|
LOGIN_SHELL=/bin/sh
|
||||||
UID_LOW=1000
|
UID_LOW=1000
|
||||||
UID_HIGH=65533
|
UID_HIGH=65533
|
||||||
@@ -55,67 +69,74 @@ valid_uid () {
|
|||||||
[ "$1" -ge "$UID_LOW" ] && [ "$1" -le "$UID_HIGH" ]
|
[ "$1" -ge "$UID_LOW" ] && [ "$1" -le "$UID_HIGH" ]
|
||||||
}
|
}
|
||||||
|
|
||||||
passwd_uid () {
|
# is_mounted <path>: a mount (a file bind mount included) sits at path.
|
||||||
awk -F: -v n="$1" '$1 == n { print $3; exit }' /etc/passwd
|
# 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 () {
|
# The operator accounts the current files hold: every name in the
|
||||||
awk -F: -v n="$1" '$1 == n { f = 1 } END { exit !f }' /etc/group
|
# 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
|
# Each account file shows a tmpfs copy of itself. The copy keeps the
|
||||||
ensure_account () {
|
# file's mode and owner (cp -p); a file the image does not carry is
|
||||||
name=$1
|
# skipped.
|
||||||
hash=$2
|
bind_files () {
|
||||||
uid=$3
|
mkdir -p "$STATE" || { log "cannot create $STATE"; return 1; }
|
||||||
home="$HOMES/$name"
|
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")
|
# render_file <name>: the file less the operator accounts (DROP), plus
|
||||||
if [ -n "$cur" ] && [ "$cur" != "$uid" ]; then
|
# one line per record account (ACCOUNTS: "name:hash:uid" lines). Written
|
||||||
log "account $name has uid $cur, the record says $uid: recreating it"
|
# through the bind mount; a file the image does not carry is skipped.
|
||||||
userdel -f "$name" >/dev/null 2>&1
|
render_file () {
|
||||||
groupdel "$name" >/dev/null 2>&1
|
f=$1
|
||||||
cur=""
|
[ -f "/etc/$f" ] || return 0
|
||||||
fi
|
is_mounted "/etc/$f" || { log "/etc/$f is not the tmpfs copy; not written"; return 1; }
|
||||||
|
tmp="$STATE/$f.new"
|
||||||
if ! group_exists "$name"; then
|
{
|
||||||
groupadd -g "$uid" "$name" || { log "groupadd $name failed"; return 1; }
|
awk -F: -v drop=" $DROP " 'index(drop, " " $1 " ") == 0' "/etc/$f"
|
||||||
fi
|
printf '%s\n' "$ACCOUNTS" | while IFS=: read -r name hash uid; do
|
||||||
if [ -z "$cur" ]; then
|
[ -n "$name" ] || continue
|
||||||
useradd -M -u "$uid" -g "$uid" -d "$home" -s "$LOGIN_SHELL" "$name" \
|
case "$f" in
|
||||||
|| { log "useradd $name failed"; return 1; }
|
passwd) printf '%s:x:%s:%s::%s/%s:%s\n' "$name" "$uid" "$uid" "$HOMES" "$name" "$LOGIN_SHELL" ;;
|
||||||
log "account $name created (uid $uid)"
|
shadow) printf '%s:%s:%s:0:99999:7:::\n' "$name" "$hash" "$DAY" ;;
|
||||||
fi
|
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
|
if [ ! -d "$home" ]; then
|
||||||
mkdir -p "$home" && chmod 0700 "$home"
|
mkdir -p "$home" && chmod 0700 "$home"
|
||||||
fi
|
fi
|
||||||
chown "$uid:$uid" "$home"
|
chown "$2:$2" "$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 () {
|
replay () {
|
||||||
[ -f "$RECORD" ] || return 0
|
[ -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=""
|
KEEP=""
|
||||||
while IFS=: read -r name hash uid rest; do
|
while IFS=: read -r name hash uid rest; do
|
||||||
[ -n "$name" ] || continue
|
[ -n "$name" ] || continue
|
||||||
@@ -128,9 +149,37 @@ replay () {
|
|||||||
log "ignoring a root line in the record"
|
log "ignoring a root line in the record"
|
||||||
continue
|
continue
|
||||||
fi
|
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"
|
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
|
case "$1" in
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
SUMMARY = "ForgeFIRM operator accounts: record replay and the root shell warning"
|
SUMMARY = "ForgeFIRM operator accounts: record render and the root shell warning"
|
||||||
DESCRIPTION = "Replays the account record (/data/forgefirm/users, written \
|
DESCRIPTION = "Renders the account record (/data/forgefirm/users, written \
|
||||||
by forgectrl) into the system account files at boot and on reload, \
|
by forgectrl) into the system account files at boot and on reload. The \
|
||||||
removes the local accounts the record does not name, and installs the \
|
rootfs is read-only: the four files show tmpfs copies, bind-mounted, and \
|
||||||
warning an interactive root shell prints."
|
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"
|
LICENSE = "MIT"
|
||||||
LIC_FILES_CHKSUM = "file://${COMMON_LICENSE_DIR}/MIT;md5=0835ade698e0bcf8506ecda2f7b4f302"
|
LIC_FILES_CHKSUM = "file://${COMMON_LICENSE_DIR}/MIT;md5=0835ade698e0bcf8506ecda2f7b4f302"
|
||||||
|
|
||||||
@@ -16,12 +17,12 @@ S = "${WORKDIR}"
|
|||||||
inherit update-rc.d
|
inherit update-rc.d
|
||||||
|
|
||||||
INITSCRIPT_NAME = "forgefirm-users"
|
INITSCRIPT_NAME = "forgefirm-users"
|
||||||
# 05: rcS has run (mountall mounted /data at S03) and sshd starts at
|
# 05: rcS has run (mountall mounted /data and /run at S03) and sshd starts
|
||||||
# S09, so the accounts exist before the first login can arrive.
|
# at S09, so the accounts exist before the first login can arrive.
|
||||||
INITSCRIPT_PARAMS = "start 05 2 3 4 5 ."
|
INITSCRIPT_PARAMS = "start 05 2 3 4 5 ."
|
||||||
|
|
||||||
# useradd, groupadd, usermod, userdel, groupdel
|
# No shadow tools: the read-only-rootfs image feature drops the shadow
|
||||||
RDEPENDS:${PN} += "shadow"
|
# package from the image, and the script writes the account lines itself.
|
||||||
|
|
||||||
do_install() {
|
do_install() {
|
||||||
install -Dm 0755 ${WORKDIR}/forgefirm-users.init ${D}${sysconfdir}/init.d/forgefirm-users
|
install -Dm 0755 ${WORKDIR}/forgefirm-users.init ${D}${sysconfdir}/init.d/forgefirm-users
|
||||||
|
|||||||
@@ -43,6 +43,10 @@ IMAGE_ROOTFS_EXTRA_SPACE = "262144"
|
|||||||
# Dev builds identify by build timestamp (matches the artifact name),
|
# Dev builds identify by build timestamp (matches the artifact name),
|
||||||
# tagged so a bench machine is never mistaken for a release.
|
# tagged so a bench machine is never mistaken for a release.
|
||||||
FORGEFIRM_VERSION_STRING = "${DATETIME} (dev)"
|
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
|
# /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
|
# 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
|
echo "${FORGEFIRM_VERSION_STRING}" > ${IMAGE_ROOTFS}${sysconfdir}/forgefirm-dev
|
||||||
}
|
}
|
||||||
write_forgefirm_dev_marker[vardepsexclude] += "DATETIME"
|
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 "
|
||||||
|
|||||||
@@ -45,17 +45,35 @@ IMAGE_INSTALL:remove = "python3 ${FORGEFIRM_RELEASE_TRIM}"
|
|||||||
# VIRTUAL-RUNTIME_base-utils-syslog (conf/distro/forgefirm.conf).
|
# VIRTUAL-RUNTIME_base-utils-syslog (conf/distro/forgefirm.conf).
|
||||||
IMAGE_INSTALL:append = " grblhal-glowforge forgectrl gfhome gfcloud v4l-utils fwup ffboot slotmigrate forgefirm-logging"
|
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
|
# (/data/forgefirm/users, written by forgectrl) into the system account
|
||||||
# files at boot, before sshd, and on reload; also installs the warning an
|
# files at boot, before sshd, and on reload; also installs the warning an
|
||||||
# interactive root shell prints. forgefirm-banner: keeps the control
|
# interactive root shell prints. forgefirm-banner: keeps the control
|
||||||
# panel addresses in the serial-console banner (/etc/issue).
|
# 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/
|
# avahi-daemon: mDNS, so the panel answers at https://forgefirm.local/
|
||||||
# and shows up in service browsers. The daemon is installed by name (the
|
# and shows up in service browsers. The daemon is installed by name (the
|
||||||
# zeroconf distro feature stays off: it would bring libnss-mdns); the
|
# zeroconf distro feature stays off: it would bring libnss-mdns); the
|
||||||
# build options and the configuration are in conf/distro/forgefirm.conf
|
# build options and the configuration are in conf/distro/forgefirm.conf
|
||||||
# and recipes-connectivity/avahi.
|
# 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
|
# 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
|
# 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
|
echo "ForgeFIRM ${FORGEFIRM_VERSION_STRING}" > ${IMAGE_ROOTFS}${sysconfdir}/motd
|
||||||
}
|
}
|
||||||
write_forgefirm_version[vardepsexclude] += "DATETIME"
|
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 license texts ride with the software. The license class writes
|
||||||
# the image's license manifest (every installed package with its
|
# the image's license manifest (every installed package with its
|
||||||
@@ -132,4 +152,4 @@ pack_licenses() {
|
|||||||
> "${IMAGE_ROOTFS}${datadir}/forgefirm/licenses.tar.gz"
|
> "${IMAGE_ROOTFS}${datadir}/forgefirm/licenses.tar.gz"
|
||||||
rm -rf "$d"
|
rm -rf "$d"
|
||||||
}
|
}
|
||||||
ROOTFS_POSTPROCESS_COMMAND += "pack_licenses;"
|
ROOTFS_POSTPROCESS_COMMAND += "pack_licenses "
|
||||||
|
|||||||
@@ -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"
|
|| 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)"
|
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,
|
# Config-level guard: debug-tweaks must not sit in the shared kas config,
|
||||||
# where it would apply to every target including the release image.
|
# where it would apply to every target including the release image.
|
||||||
if ( cd "$REPO" && kas dump kas/forgefirm-glowforge.yml 2>/dev/null ) \
|
if ( cd "$REPO" && kas dump kas/forgefirm-glowforge.yml 2>/dev/null ) \
|
||||||
|
|||||||
Reference in New Issue
Block a user