From ddf0fc7fbe70f9d2d49ffee039e95a8014bde188 Mon Sep 17 00:00:00 2001 From: navigator Date: Wed, 30 Sep 2026 10:25:21 +0000 Subject: [PATCH] layer: export every layer with an empty machine-id Every image made from a Keel layer shared one /etc/machine-id, f0e97605ab594989b4d78f4a126b5b37: the fab bootstrap carries it, systemd keeps a populated file, and systemd's postinst runs systemd-machine-id-setup on every configure, so a child layer that upgrades systemd writes its own. Two machines from one image then share their DHCP DUID and IAID, their journal and networkd identity, and keel's MariaDB server_id, which stopped a replica test. bt-layer now runs layer_reset_machine_id on root.patched after layer_check_build, before the rootfs copy children start from and before the tarball: /etc/machine-id becomes an empty file, as Debian's cloud images ship it, and /var/lib/dbus/machine-id, when present, the link to it that tmpfiles.d/dbus.conf makes. The link matters because systemd falls back to the D-Bus file when /etc/machine-id is empty and would bring the old id back. Empty rather than missing or "uninitialized", which would make the first boot a first boot to systemd and preset every unit. 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; a reset that fails stops the export. Verified on the test VM: on an overlay deck, a child that wrote an id ends with an empty 0444 file and the link in its upper directory, and one that did not leaves the upper empty. The templateB2 rootfs, reset this way and booted twice in LXC, came up with two different ids, each kept across a reboot, no first boot detected and the same 41 enabled units as the image as published, which boots with f0e97605ab594989b4d78f4a126b5b37. --- COVERAGE.md | 15 ++++++++++ bin/layer-lib | 44 ++++++++++++++++++++++++++++ bt-layer | 2 ++ tests/layer | 80 +++++++++++++++++++++++++++++++++++++++++++++++++-- 4 files changed, 139 insertions(+), 2 deletions(-) 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