diff --git a/COVERAGE.md b/COVERAGE.md index e80d6ce..8a86eed 100644 --- a/COVERAGE.md +++ b/COVERAGE.md @@ -59,6 +59,21 @@ pattern, the digest match, the grep status check, `-I` or the symlink rule each turns the suite red. Coverage: layer-lib and bt-layer unchanged at one uncovered line each, the same lines as on 19.x. +## 2026-09-30: the machine-id reset + +`layer_reset_machine_id` leaves every exported layer with an empty +`/etc/machine-id` and `/var/lib/dbus/machine-id` as a link to it, so systemd +gives each machine its own id at boot (docs/traps.md, "Every appliance built +from core has the same machine-id"). `bt-layer` runs it after +`layer_check_build`, before the rootfs copy and the tarball. `tests/layer` +covers a populated id and D-Bus copy, a tree already reset left untouched +(file and link), a missing id, a symlinked id, a D-Bus link elsewhere, no +D-Bus copy, no `/etc`, a directory in the way of either, the `core` tarball +and rootfs of a build that wrote an id, and a `bt-layer` run whose id cannot +be reset (refused, no tarball). Mutating each condition or the mode turns the +suite red. Coverage: layer-lib 383 of 384 and bt-layer 111 of 112, the same +one uncovered line each as on 19.x. + ## 2026-09-26: the signing identity `bin/generate-signature` no longer hardcodes TurnKey's release key. Its logic diff --git a/bin/layer-lib b/bin/layer-lib index 8eb02f9..dce462c 100755 --- a/bin/layer-lib +++ b/bin/layer-lib @@ -568,6 +568,50 @@ layer_audit_keys() { return 0 } +# The machine identity a layer ships. Every image built from a layer holds +# the same bytes, so an id in a layer is an id every machine made from it +# shares: the same DHCP DUID and IAID, the same journal and networkd +# identity, the same MariaDB server_id (docs/traps.md, "Every appliance built +# from core has the same machine-id"). The build always leaves one, whatever +# the parent shipped: the bootstrap carries one, and systemd's postinst runs +# systemd-machine-id-setup on every configure, so a child that upgrades +# systemd writes a fresh one of its own. +# +# /etc/machine-id is left an empty file, as Debian's cloud images ship it, +# and systemd makes the id at each machine's boot. Not missing and not +# "uninitialized": either makes that boot a first boot to systemd, which +# presets every unit and undoes what the build enabled and masked +# (machine-id(5), "First Boot Semantics"). D-Bus's copy becomes the link to +# it that tmpfiles.d/dbus.conf makes when it is absent, because systemd reads +# that file when /etc/machine-id is empty and would bring the old id back. +LAYER_MACHINE_ID=etc/machine-id +LAYER_DBUS_MACHINE_ID=var/lib/dbus/machine-id + +# A tree already in that state is not written, so a child layer does not +# copy its parent's empty file up into its own tarball. +layer_reset_machine_id() { + local rootfs=$1 + local id=$rootfs/$LAYER_MACHINE_ID + local dbus=$rootfs/$LAYER_DBUS_MACHINE_ID + if [[ ! -d "$rootfs/etc" ]]; then + layer_error "no /etc in $rootfs to reset the machine-id in" + return 1 + fi + if [[ -L "$id" || ! -f "$id" || -s "$id" ]]; then + if ! { rm -f "$id" && : > "$id" && chmod 0444 "$id"; } 2>/dev/null; then + layer_error "cannot leave an empty /$LAYER_MACHINE_ID in $rootfs" + return 1 + fi + fi + if [[ -e "$dbus" || -L "$dbus" ]] && [[ "$(readlink "$dbus")" != "/$LAYER_MACHINE_ID" ]]; then + if ! { rm -f "$dbus" && ln -s "/$LAYER_MACHINE_ID" "$dbus"; } 2>/dev/null; then + layer_error "cannot link /$LAYER_DBUS_MACHINE_ID to /$LAYER_MACHINE_ID in $rootfs" + return 1 + fi + fi + return 0 +} + layer_check_build() { local kind=$1 local build_dir=$2 diff --git a/bt-layer b/bt-layer index 12c6908..48cfa03 100755 --- a/bt-layer +++ b/bt-layer @@ -186,6 +186,8 @@ fi layer_check_build "$kind" "$build_dir" "$BT/conf/layer-inert-keys" \ || fatal "nothing to export for $layer" +layer_reset_machine_id "$build_dir/root.patched" \ + || fatal "cannot reset the machine-id of $layer" rootfs_copy=$layers_dir/$layer.rootfs info "copying root.patched to $rootfs_copy" diff --git a/tests/layer b/tests/layer index ec44b45..4e0e0d1 100755 --- a/tests/layer +++ b/tests/layer @@ -30,6 +30,9 @@ # named, a quoted marker, a binary, a symlink out of the tree, an example # unlisted, listed, its digest elsewhere, its path with another digest, # grep failing, grep missing, the committed list well formed); +# layer_reset_machine_id (an id and a D-Bus copy, already reset and +# left untouched, missing, a symlink, a D-Bus link elsewhere, no D-Bus +# copy, no /etc, a directory in the way of either); # layer_check_build (ok, no stamp, not mounted, no upper, a private key, # no key list); # layer_hash_size (ok, missing); layer_write_sha256; layer_umount_targets @@ -37,7 +40,9 @@ # layer_parse_args (help, name, parent, use-existing, no name, two # names, unknown option, parent without value, self parent). # bt-layer: --help; usage error; missing config; missing FAB_PATH; missing -# product; bad epoch; rootfs build with warning on BT_GPGKEY; RELEASE and +# product; bad epoch; rootfs build with warning on BT_GPGKEY, exported +# with an empty machine-id whatever the build left, refused when it +# cannot be reset; RELEASE and # FAB_ARCH fallbacks; unmount guard; delta build with parent (overrides, # altstrap created and removed, hash file with BT_GPGKEY); repeated # export with -u is byte identical; -u without a build; unknown parent; @@ -126,6 +131,10 @@ build/stamps/root.patched: printf '#!/bin/sh\\n' > build/root.patched/bin/bash chmod 755 build/root.patched/bin/bash echo hostname > build/root.patched/etc/hostname + mkdir -p build/root.patched/var/lib/dbus + echo f0e97605ab594989b4d78f4a126b5b37 > build/root.patched/etc/machine-id + cp build/root.patched/etc/machine-id build/root.patched/var/lib/dbus/machine-id + if [ -f IDDIR ]; then rm build/root.patched/etc/machine-id && mkdir -p build/root.patched/etc/machine-id/x; fi if [ -f LEAK ]; then mkdir -p build/root.patched/etc/ssh && cp LEAK build/root.patched/etc/ssh/ssh_host_rsa_key; fi echo lib > build/.deck/root.build/upper/usr/lib/x.so echo deb > build/.deck/root.build/upper/var/cache/apt/archives/x.deb @@ -468,6 +477,61 @@ LAYER_GREP=$tmp/no-such-grep expect_fail keys-no-grep layer_audit_keys "$keys_ro awk '!/^[[:space:]]*(#|$)/ && !($1 ~ /^[0-9a-f]{64}$/ && $2 ~ /^\// && NF == 2) { bad = 1; print "bad line: " $0 } END { exit bad }' \ "$BT/conf/layer-inert-keys" || fatal "conf/layer-inert-keys has a malformed line" +info "10d: layer_reset_machine_id" +# the machine-id every image carried on 2026-09-30, and the D-Bus copy of it +# dbus-daemon's postinst wrote as a file of its own +mid_root=$tmp/midfs +mkdir -p "$mid_root/etc" "$mid_root/var/lib/dbus" +echo f0e97605ab594989b4d78f4a126b5b37 > "$mid_root/etc/machine-id" +chmod 0444 "$mid_root/etc/machine-id" +cp "$mid_root/etc/machine-id" "$mid_root/var/lib/dbus/machine-id" +layer_reset_machine_id "$mid_root" +[[ -f "$mid_root/etc/machine-id" && ! -L "$mid_root/etc/machine-id" ]] || fatal "mid-reset: not a regular file" +[[ ! -s "$mid_root/etc/machine-id" ]] || fatal "mid-reset: the id is still there" +assert_eq mid-mode "$(stat -c %a "$mid_root/etc/machine-id")" 444 +[[ -L "$mid_root/var/lib/dbus/machine-id" ]] || fatal "mid-dbus: not a symlink" +assert_eq mid-dbus-target "$(readlink "$mid_root/var/lib/dbus/machine-id")" /etc/machine-id +# a tree already reset is left as it is, so a child layer does not copy the +# parent's empty file up into its own tarball +touch -d @1234567890 "$mid_root/etc/machine-id" +chmod 0644 "$mid_root/etc/machine-id" +touch -h -d @1234567890 "$mid_root/var/lib/dbus/machine-id" +layer_reset_machine_id "$mid_root" +assert_eq mid-untouched "$(stat -c '%a %Y' "$mid_root/etc/machine-id")" "644 1234567890" +assert_eq mid-dbus-untouched "$(stat -c %Y "$mid_root/var/lib/dbus/machine-id")" 1234567890 +# missing means a first boot to systemd, which presets every unit: an empty +# file is made +rm "$mid_root/etc/machine-id" +layer_reset_machine_id "$mid_root" +[[ -f "$mid_root/etc/machine-id" && ! -s "$mid_root/etc/machine-id" ]] || fatal "mid-missing: no empty file" +# a symlink, even to an empty file, is replaced by a file of the tree's own +rm "$mid_root/etc/machine-id" +: > "$tmp/elsewhere-id" +ln -s "$tmp/elsewhere-id" "$mid_root/etc/machine-id" +layer_reset_machine_id "$mid_root" +[[ -f "$mid_root/etc/machine-id" && ! -L "$mid_root/etc/machine-id" ]] || fatal "mid-symlink: still a link" +# a D-Bus link to anything but /etc/machine-id is replaced +ln -sfn /var/lib/dbus/other "$mid_root/var/lib/dbus/machine-id" +layer_reset_machine_id "$mid_root" +assert_eq mid-dbus-relink "$(readlink "$mid_root/var/lib/dbus/machine-id")" /etc/machine-id +# no D-Bus copy stays none: tmpfiles.d/dbus.conf makes the link at boot +rm "$mid_root/var/lib/dbus/machine-id" +layer_reset_machine_id "$mid_root" +[[ ! -e "$mid_root/var/lib/dbus/machine-id" && ! -L "$mid_root/var/lib/dbus/machine-id" ]] \ + || fatal "mid-no-dbus: a D-Bus copy was made" +expect_fail mid-no-etc layer_reset_machine_id "$tmp/no-such-root" +out=$(layer_reset_machine_id "$tmp/no-such-root" 2>&1) || true +[[ "$out" == *"no /etc"* ]] || fatal "mid-no-etc: unexpected message: $out" +rm "$mid_root/etc/machine-id" +mkdir "$mid_root/etc/machine-id" +expect_fail mid-directory layer_reset_machine_id "$mid_root" +rmdir "$mid_root/etc/machine-id" +mkdir -p "$mid_root/var/lib/dbus/machine-id/x" +expect_fail mid-dbus-directory layer_reset_machine_id "$mid_root" +out=$(layer_reset_machine_id "$mid_root" 2>&1) || true +[[ "$out" == *"cannot link /var/lib/dbus/machine-id"* ]] || fatal "mid-dbus-directory: unexpected message: $out" +rm -r "$mid_root/var/lib/dbus/machine-id" + info "11: layer_check_build" build=$tmp/build expect_fail check-nostamp layer_check_build rootfs "$build" "$keys_list" @@ -606,7 +670,13 @@ assert_eq core-size "$(layer_manifest_get "$L/core.manifest" size)" "$(stat -c % assert_eq core-sha256-file "$(cut -d ' ' -f 1 "$L/core.tar.zst.sha256")" \ "$(layer_manifest_get "$L/core.manifest" sha256)" assert_eq core-members "$(zstd -dc "$L/core.tar.zst" | tar -t | sort | tr '\n' ' ')" \ - "./ ./bin/ ./bin/bash ./etc/ ./etc/hostname " + "./ ./bin/ ./bin/bash ./etc/ ./etc/hostname ./etc/machine-id ./var/ ./var/lib/ ./var/lib/dbus/ ./var/lib/dbus/machine-id " +# the build wrote an id; the layer, and the rootfs its children start from, +# carry an empty file and a D-Bus link to it +assert_eq core-machine-id "$(zstd -dc "$L/core.tar.zst" | tar -xO ./etc/machine-id | wc -c)" 0 +assert_eq core-dbus-link "$(zstd -dc "$L/core.tar.zst" | tar -tv ./var/lib/dbus/machine-id | awk '{print $NF}')" \ + /etc/machine-id +[[ ! -s "$L/core.rootfs/etc/machine-id" ]] || fatal "core.rootfs: the machine-id is still there" assert_eq core-mtime "$(zstd -dc "$L/core.tar.zst" | tar -tv --full-time ./etc/hostname | awk '{print $4}')" \ "$(date -u -d @1700000000 +%F)" @@ -660,6 +730,12 @@ out=$("$sbt/bt-layer" leaky --parent core 2>&1) && fatal "leaky: a layer carryin [[ "$out" == *"/etc/ssh/ssh_host_rsa_key"* ]] || fatal "leaky: the key is not named: $out" [[ ! -e "$L/leaky.tar.zst" ]] || fatal "leaky: a tarball was written" [[ ! -e "$L/leaky.manifest" ]] || fatal "leaky: a manifest was written" +# a machine-id that cannot be reset stops the export the same way +write_product "$tmp/products/idless" "turnkey.d" "turnkey.d" +touch "$tmp/products/idless/IDDIR" +out=$("$sbt/bt-layer" idless --parent core 2>&1) && fatal "idless: a layer whose machine-id cannot be reset was exported" +[[ "$out" == *"cannot reset the machine-id of idless"* ]] || fatal "idless: unexpected message: $out" +[[ ! -e "$L/idless.tar.zst" ]] || fatal "idless: a tarball was written" info "22: bt-layer with units (a component supplied as a fab unit)" # add_unit PRODUCT NAME VERSION: a unit fab would apply, pinned by a version