diff --git a/build/service-scripts/boot-success.sh b/build/service-scripts/boot-success.sh index cd7c7cb9..5252071c 100755 --- a/build/service-scripts/boot-success.sh +++ b/build/service-scripts/boot-success.sh @@ -25,6 +25,8 @@ ident() { sed -n "s/^$1=//p" "$RUN/boot-identity" 2>/dev/null | head -1; } other_slot() { case "$1" in a) echo b ;; b) echo a ;; esac; } slot="$(ident slot)"; media="$(ident media)"; state="$(ident state)" now() { date -Iseconds 2>/dev/null || date; } +# Drop FILE's clean pages, so the next read is what the device holds; best effort. +uncache() { dd if="$1" iflag=nocache count=0 status=none 2>/dev/null || :; } # uncache FILE result() { printf '%s %s\n' "$*" "$(now)" > "$B/last-result.new" && mv -f "$B/last-result.new" "$B/last-result"; } if [ -n "$media" ]; then @@ -79,12 +81,18 @@ commit_slot() { # commit_slot : make BOOTX64.EFI this slot's kernel if cmp -s "$src" "$dst"; then say "BOOTX64.EFI already is slot $1"; rc=0 else - # Copy, fsync, then rename: on FAT only the rename is not atomic. - cp "$src" "$dst.new" && sync -f "$dst.new" && mv -f "$dst.new" "$dst" && sync -f "$dst" && rc=0 + # Copy, fsync, compare as the device holds it, then rename: on FAT only the + # rename is not atomic, and a copy that landed wrong never becomes the boot file. + cp "$src" "$dst.new" && sync -f "$dst.new" && uncache "$dst.new" && cmp -s "$src" "$dst.new" && mv -f "$dst.new" "$dst" && sync -f "$dst" && rc=0 [ "$rc" -eq 0 ] && say "committed: BOOTX64.EFI is now slot $1" fi - printf '%s\n' "$1" > "$ESP_MNT/kryptik/committed-slot.new" && sync -f "$ESP_MNT/kryptik/committed-slot.new" && \ - mv -f "$ESP_MNT/kryptik/committed-slot.new" "$ESP_MNT/kryptik/committed-slot" + # The record is part of the commit, and follows the boot file: kryptik-update + # applies only from the slot it names, so it must not name one that does not boot. + if [ "$rc" -eq 0 ]; then + { printf '%s\n' "$1" > "$ESP_MNT/kryptik/committed-slot.new" && sync -f "$ESP_MNT/kryptik/committed-slot.new" && \ + mv -f "$ESP_MNT/kryptik/committed-slot.new" "$ESP_MNT/kryptik/committed-slot"; } \ + || { say "the committed-slot record could not be written; the trial stays, and the next boot commits again"; rc=1; } + fi else say "no kernel for slot $1 on the ESP" fi @@ -149,7 +157,7 @@ if [ -n "$trial" ]; then say "$(kryptikd time committed "$B/release-$slot" 2>&1)" else result "commit-failed $slot" - say "slot $slot is healthy but the commit failed; the committed slot is unchanged" + say "slot $slot is healthy but the commit failed; the trial stays for the next boot" fi else say "trial slot $slot came up UNHEALTHY:" diff --git a/docs/design/boot-and-updates.md b/docs/design/boot-and-updates.md index fe35d3ae..e1496bc1 100644 --- a/docs/design/boot-and-updates.md +++ b/docs/design/boot-and-updates.md @@ -150,19 +150,19 @@ payloads come from [the update channel](update-channel.md) or by hand. 5. `boot-success` judges the trial slot: state persistent; eudev, seatd, the launch daemon, the net zone and the login getty up; kryptikd finding kernel support and the zones; an unambiguous ESP. Healthy: it copies the kernel - over `BOOTX64.EFI` (`.new`, fsync, rename), updates `committed-slot` alike, - clears the trial and forgets the entries, and the slot's kept manifest - raises the clock's floor if it is the newest. Unhealthy: it records that, - forgets the entries and reboots into the committed slot, `BootNext` being - spent. On a degraded state the trial record is out of reach, so - `committed-slot` says whether the boot is a trial. A trial that never comes - up also lands on the committed slot; the fallback records `trial.failed` - and forgets the entries, and the updater will not re-arm that payload - without `--retry`, which root gives (`su` from the administration login - on tty2; the refusal prints the command). Only a trial reboots; an - unhealthy committed slot is reported and left running, and so is a trial - on a degraded state whose ESP cannot be read, since nothing then says it - is one. + over `BOOTX64.EFI` (`.new`, fsync, compare, rename), updates + `committed-slot` alike, clears the trial and forgets the entries, and the + slot's kept manifest raises the clock's floor if it is the newest. + Unhealthy: it records that, forgets the entries and reboots into the + committed slot, `BootNext` being spent. On a degraded state the trial + record is out of reach, so `committed-slot` says whether the boot is a + trial. A trial that never comes up also lands on the committed slot; the + fallback records `trial.failed` and forgets the entries, and the updater + will not re-arm that payload without `--retry`, which root gives (`su` from + the administration login on tty2; the refusal prints the command). Only a + trial reboots; an unhealthy committed slot is reported and left running, + and so is a trial on a degraded state whose ESP cannot be read, since + nothing then says it is one. The net zone is in the check on purpose: a release whose net zone cannot come up could never fetch the release that fixes it. Whoever can crash diff --git a/tools/tests/boot-success.sh b/tools/tests/boot-success.sh index 898b6174..4bee30d6 100755 --- a/tools/tests/boot-success.sh +++ b/tools/tests/boot-success.sh @@ -67,6 +67,17 @@ EOF cat > "$T/bin/sync" <<'EOF' #!/bin/sh echo "sync $*" >> "$KTEST/syncs" +case "$*" in + *committed-slot.new) [ ! -e "$KTEST/sync_fails" ] ;; + *BOOTX64.EFI.new) [ ! -e "$KTEST/sync_fails_boot" ] ;; +esac +EOF +# cmp: the real one, but a copy of a kernel to BOOTX64.EFI.new can be made to read back wrong. +REAL_CMP="$(command -v cmp)" +cat > "$T/bin/cmp" </dev/null)" "1" check "the trial record is gone" "$([[ -e "$KTEST/boot/trial" ]] && echo present || echo gone)" "gone" check "the trial's firmware entries and BootNext are forgotten after the commit, the committed slot gets its own, and its release goes to the clock's floor" "$CALLS" "mount -t vfat -o rw,nosuid,nodev,noexec /dev/vda1 $KTEST/run/esp umount $KTEST/run/esp efiboot forget efiboot ensure b kryptikd time committed $KTEST/boot/release-b " +run_case recfail b "" persistent 'b\narmed=1\n' $ALL; : > "$KTEST/sync_fails"; go +check "a committed-slot record that cannot be written fails the commit: the trial stays, for the next boot to commit again" \ + "$RESULT|$(cat "$KTEST/esp/kryptik/committed-slot")|$([[ -e "$KTEST/boot/trial" ]] && echo kept || echo gone)|$(grep -c 'efiboot forget' "$KTEST/calls" 2>/dev/null)" "commit-failed b|a|kept|0" +run_case bootfail b "" persistent 'b\narmed=1\n' $ALL; : > "$KTEST/sync_fails_boot"; go +check "a boot file that cannot be replaced fails the commit, and the record goes on naming the slot BOOTX64.EFI boots" \ + "$RESULT|$(cat "$KTEST/esp/EFI/BOOT/BOOTX64.EFI")|$(cat "$KTEST/esp/kryptik/committed-slot")|$([[ -e "$KTEST/boot/trial" ]] && echo kept || echo gone)" "commit-failed b|kernel-a|a|kept" +run_case copybad b "" persistent 'b\narmed=1\n' $ALL; : > "$KTEST/copy_bad"; go +check "a boot file whose copy reads back wrong is not renamed into place: the commit fails, the trial stays" \ + "$RESULT|$(cat "$KTEST/esp/EFI/BOOT/BOOTX64.EFI")|$(cat "$KTEST/esp/kryptik/committed-slot")|$([[ -e "$KTEST/boot/trial" ]] && echo kept || echo gone)" "commit-failed b|kernel-a|a|kept" run_case commit0 b "" persistent 'b\narmed=0\n' $ALL; go check "a trial that booted before its armed=1 line was written is still a trial: committed" "$RESULT" "commit b" diff --git a/tools/update/kryptik-recover b/tools/update/kryptik-recover index 205a855b..83843912 100755 --- a/tools/update/kryptik-recover +++ b/tools/update/kryptik-recover @@ -59,6 +59,8 @@ slot_dev() { case "$1" in a) echo "$SA" ;; b) echo "$SB" ;; *) die "slot must be has_fs() { [ "$(dd if="$1" bs=1 skip=1080 count=2 status=none | od -An -tx1 | tr -d ' \n')" = "53ef" ]; } # Written, fsynced, renamed, as the kernels are: a power cut leaves the old file whole, not empty. put() { printf '%s\n' "$2" > "$1.new" && sync -f "$1.new" && mv -f "$1.new" "$1"; } # put FILE LINE +# Drop FILE's clean pages, so the next read is what the device holds; best effort. +uncache() { dd if="$1" iflag=nocache count=0 status=none 2>/dev/null || :; } # uncache FILE if [ "$STATUS" = 1 ]; then mount -t vfat -o ro "$ESP" /run/kryptik-recover || die "cannot mount the ESP" @@ -91,6 +93,8 @@ commit_slot() { # commit_slot SLOT (ESP mounted rw at /run/kryptik-recover) has_fs "$(slot_dev "$s")" || die "slot $s holds no filesystem; use --restore-slot $s" cp "$k" /run/kryptik-recover/EFI/BOOT/BOOTX64.EFI.new sync -f /run/kryptik-recover/EFI/BOOT/BOOTX64.EFI.new + uncache /run/kryptik-recover/EFI/BOOT/BOOTX64.EFI.new + cmp -s "$k" /run/kryptik-recover/EFI/BOOT/BOOTX64.EFI.new || die "slot $s's kernel did not copy whole; BOOTX64.EFI is unchanged" mv -f /run/kryptik-recover/EFI/BOOT/BOOTX64.EFI.new /run/kryptik-recover/EFI/BOOT/BOOTX64.EFI put /run/kryptik-recover/kryptik/committed-slot "$s" || die "cannot record slot $s as committed" sync @@ -148,6 +152,9 @@ if [ -n "$RESTORE" ]; then mount -t vfat -o rw "$ESP" /run/kryptik-recover || die "cannot mount the target ESP" cp "$kdir/kryptik-$RESTORE.efi" "/run/kryptik-recover/EFI/kryptik/kryptik-$RESTORE.efi.new" sync -f "/run/kryptik-recover/EFI/kryptik/kryptik-$RESTORE.efi.new" + uncache "/run/kryptik-recover/EFI/kryptik/kryptik-$RESTORE.efi.new" + cmp -s "$kdir/kryptik-$RESTORE.efi" "/run/kryptik-recover/EFI/kryptik/kryptik-$RESTORE.efi.new" \ + || die "the medium's kernel for slot $RESTORE did not copy whole; nothing was committed" mv -f "/run/kryptik-recover/EFI/kryptik/kryptik-$RESTORE.efi.new" "/run/kryptik-recover/EFI/kryptik/kryptik-$RESTORE.efi" put "/run/kryptik-recover/kryptik/version-$RESTORE" "$ver" || die "cannot record slot ${RESTORE}'s version" commit_slot "$RESTORE" diff --git a/tools/update/kryptik-update b/tools/update/kryptik-update index 8c9390d7..2508c005 100755 --- a/tools/update/kryptik-update +++ b/tools/update/kryptik-update @@ -76,6 +76,9 @@ esp_dev() { own_part kryptik-esp; } slot_dev() { own_part "kryptik-$1"; } hdr() { awk -F': ' -v k="$2" '$1==k {print $2; exit}' "$1"; } +# Drop FILE's clean pages, so the next read is what the device holds; best effort. +uncache() { dd if="$1" iflag=nocache count=0 status=none 2>/dev/null || :; } # uncache FILE + mount_esp() { dev="$(esp_dev)" || exit 1 mkdir -p "$ESP_MNT" @@ -363,17 +366,20 @@ cmd_apply() { mount_esp mkdir -p "$ESP_MNT/EFI/kryptik" "$ESP_MNT/kryptik" cp "$(payload_path "kryptik-$target.efi")" "$ESP_MNT/EFI/kryptik/kryptik-$target.efi.new" || die "staging the kernel on the ESP failed" - sync -f "$ESP_MNT/EFI/kryptik/kryptik-$target.efi.new" - # Check the staged kernel before it gets the name rollback and recovery use. + sync -f "$ESP_MNT/EFI/kryptik/kryptik-$target.efi.new" || die "the kernel staged on the ESP could not be flushed" + # Check the staged kernel, as the device holds it, before it gets the name + # rollback and recovery use. + uncache "$ESP_MNT/EFI/kryptik/kryptik-$target.efi.new" want_k="$H_KA"; [ "$target" = b ] && want_k="$H_KB" if [ "$(sha256sum "$ESP_MNT/EFI/kryptik/kryptik-$target.efi.new" | cut -c1-64)" != "$want_k" ]; then rm -f "$ESP_MNT/EFI/kryptik/kryptik-$target.efi.new" die "the kernel staged on the ESP does not hash to the manifest's kryptik-$target.efi" fi mv -f "$ESP_MNT/EFI/kryptik/kryptik-$target.efi.new" "$ESP_MNT/EFI/kryptik/kryptik-$target.efi" - printf '%s\n' "$VERSION" > "$ESP_MNT/kryptik/version-$target.new" - sync -f "$ESP_MNT/kryptik/version-$target.new" - mv -f "$ESP_MNT/kryptik/version-$target.new" "$ESP_MNT/kryptik/version-$target" + { printf '%s\n' "$VERSION" > "$ESP_MNT/kryptik/version-$target.new" \ + && sync -f "$ESP_MNT/kryptik/version-$target.new" \ + && mv -f "$ESP_MNT/kryptik/version-$target.new" "$ESP_MNT/kryptik/version-$target"; } \ + || die "slot $target's version could not be recorded on the ESP" sync umount_esp say "kernel for slot $target installed on the ESP (version $VERSION)"