diff --git a/tools/image/install-test.sh b/tools/image/install-test.sh index 53a0981c..94f3ad1c 100755 --- a/tools/image/install-test.sh +++ b/tools/image/install-test.sh @@ -1,12 +1,10 @@ #!/usr/bin/env bash -# Install from the medium, boot the result from firmware alone (medium gone, -# variables reset), and check that the installer's refusals refuse. +# Install from the medium, boot the result from firmware alone, and check the installer's refusals. # # tools/image/install-test.sh --usb IMG [--disk FILE] [--size 12G] # [--vars clean|enrolled] [--timeout N] [--quick] # -# step 1 install unattended onto a blank disk; check the transcript and, -# from the host, the partition table +# step 1 install onto a blank disk; check the transcript and the partition table # step 2 boot the disk alone: first boot, login, reboot, login, poweroff # step 3 cold boot it again (not with --quick) # step 4 a disk too small, a read-only disk, and an I/O error in the root @@ -208,11 +206,9 @@ EOF refusal_case ioerror "$SIZE" 'writing the root image failed' --blkdebug "${VMDIR}/blkdebug.conf" fi -# The runner always passes --yes, so none of these refusals is the ERASE -# prompt waiting. +# The runner always passes --yes, so no refusal here is the ERASE prompt waiting. -# The disk this system runs from: the medium, the one USB disk in the guest. -# No flag opens it. +# The medium, the guest's one USB disk, is the disk this system runs from: no flag opens it. ctl="${VMDIR}/testctl-medium.img" "${SELF}/mk-testctl.sh" --out "$ctl" --key "$TESTCTL_KEY" install_target=/dev/sda install_replace=1 smoke_poweroff=1 install_wait=5 > /dev/null d="${VMDIR}/refuse-medium.img"; rm -f "$d"; truncate -s "$SIZE" "$d" @@ -222,9 +218,7 @@ want "$t" 'KRYPTIK_INSTALL: BEGIN target=/dev/sda' "medium: want "$t" 'KRYPTIK_INSTALL: .*is the disk this system is running from' "medium: refused as the disk this system runs from, even with --replace-kryptik" deny "$t" 'KRYPTIK_INSTALL: rc=0' "medium: never reported success" -# A control disk signed by some other key: the medium honours the -# kryptik-testctl key its anchor lists and no other, so nothing is armed. The -# disk's poweroff is ignored with the rest, so this boot runs to its timeout. +# The medium ignores a control disk its testctl key did not sign, poweroff too: this boot times out. ssh-keygen -q -t ed25519 -N '' -C stranger -f "${VMDIR}/stranger-key" < /dev/null ctl="${VMDIR}/testctl-stranger.img" "${SELF}/mk-testctl.sh" --out "$ctl" --key "${VMDIR}/stranger-key" install_target=/dev/vda smoke_poweroff=1 install_wait=5 > /dev/null @@ -241,7 +235,7 @@ state_uuid() { [[ -n "$start" ]] && blkid -p -O "$(( start * 512 ))" -s UUID -o value "$1" 2>/dev/null } -# An old installation: refused without the flag, and left exactly as it was. +# An old installation is refused without the flag, and left as it was. old="${VMDIR}/old-install.img"; rm -f "$old"; cp --sparse=always "$DISK" "$old" ctl="${VMDIR}/testctl-oldinstall.img" "${SELF}/mk-testctl.sh" --out "$ctl" --key "$TESTCTL_KEY" install_target=/dev/vda smoke_poweroff=1 install_wait=5 > /dev/null diff --git a/tools/image/state-test.sh b/tools/image/state-test.sh index cec7b316..8ee2b1fb 100755 --- a/tools/image/state-test.sh +++ b/tools/image/state-test.sh @@ -1,7 +1,5 @@ #!/usr/bin/env bash -# The installed system's state partition: found on the system's own disk, and -# a boot that says so when it cannot use it (docs/design/boot-and-updates.md, -# sysinit.sh). +# The installed system's state partition: found on its own disk, and a degraded boot when unusable. # # tools/image/state-test.sh --usb IMG [--disk FILE] [--timeout N] # @@ -16,8 +14,7 @@ # step 8 root changes the passphrase with `kryptik state passphrase`: the # old one no longer unlocks, the new one does # -# A degraded boot has no accounts, so only its console is checked; after each -# repair a login must find the user's file. Every disk is a file made here. +# After each repair a login must find the user's file. Every disk is a file made here. set -uo pipefail SELF="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # shellcheck source=/dev/null @@ -46,9 +43,7 @@ source "${SELF}/suite-lib.sh" VARSF="${VMDIR}/state-vars.fd"; cp /usr/share/OVMF/OVMF_VARS_4M.fd "$VARSF" -# A boot that must come up degraded. Nobody can log in to power it off, so it -# is stopped once its report ends, and only its transcript is checked: every -# line below comes before the report's END. +# Nobody can log in to a degraded boot: stop it at its report's END and check the transcript. degraded_boot() { # degraded_boot NAME REASON-REGEX start_vm "$1" DRIVE_TIMEOUT=150 drive "expect:KRYPTIK_SMOKE: END" > /dev/null @@ -144,10 +139,9 @@ sfdisk --part-label "$DISK" 4 kryptik-state >/dev/null 2>&1 || die "relabel back normal_boot state-p5b # ----------------------------------------------------------------- step 6 -- -# Stop the feeder rather than kill it (s6 would restart it): to the timer that -# is a hung userspace, and only the watchdog's reset can bring a second boot. step "step 6: nothing feeds the watchdog: the machine resets itself and comes back with its data" start_vm state-p6 +# The feeder stopped, not killed (s6 would restart it): a hung userspace only the watchdog resets. p6=( "expect:KRYPTIK_SMOKE: END" "login:${TUSER}:${TPASS}" "$(ROOTSH 's6-svc -p /run/service/watchdog && echo FEEDER-STOPPED')" "expect:FEEDER-STOPPED" "expect:KRYPTIK_SMOKE: BEGIN" "expect:KRYPTIK_SMOKE: END" diff --git a/tools/image/suite-lib.sh b/tools/image/suite-lib.sh index 63815322..e1f7e1f5 100755 --- a/tools/image/suite-lib.sh +++ b/tools/image/suite-lib.sh @@ -1,7 +1,6 @@ #!/usr/bin/env bash -# Shared by the installed-system suites: the verdict, the preseeded accounts, -# and wrappers for run-ovmf.sh and vm-drive.py. Source after common.sh with -# SELF set; start_vm reads DISK and VARSF, drive reads DRIVE_TIMEOUT. +# Shared by the installed-system suites; source it after common.sh, with SELF set. +# start_vm reads DISK and VARSF, install_disk TIMEOUT and VMDIR, drive DRIVE_TIMEOUT. # shellcheck disable=SC2034 # read by the suite that sources this PASS=0; FAIL=0 @@ -12,21 +11,17 @@ step() { printf '\n==> %s\n' "$*"; } # The plaintext exists only in the harness; the hashes are what lands on disk. TUSER=tester; TPASS=tester-pw; RPASS=root-pw TUSER_HASH="$(openssl passwd -6 "$TPASS")"; ROOT_HASH="$(openssl passwd -6 "$RPASS")" -# The state passphrase: the installer reads it from the control disk, and -# vm-drive.py (hence the export) answers sysinit with it at every boot. +# The installer reads it from the control disk; vm-drive.py answers sysinit with it (hence export). export KRYPTIK_STATE_PASSPHRASE=state-pw PRESEED=( "preseed_user=${TUSER}" "preseed_password_hash=${TUSER_HASH}" "preseed_root_hash=${ROOT_HASH}" "state_passphrase=${KRYPTIK_STATE_PASSPHRASE}" ) -# The control disks are signed with the kryptik-testctl key the medium's -# anchor lists: the developer keys' for a development medium, and for the -# production pair the one tools/production-pair.sh keeps beside it. +# The testctl key the medium's anchor lists signs the control disks: the developer one, or the +# production pair's (KRYPTIK_TESTCTL_KEY, tools/production-pair.sh). TESTCTL_KEY="${KRYPTIK_TESTCTL_KEY:-${KRYPTIK_WORK}/keys/release/kryptik-testctl}" DRV="${SELF}/vm-drive.py" -# A smoke boot with a transcript of its own. run-ovmf.sh repoints the -# ovmf-serial.latest.log link at every boot on this host, another suite's -# included, so a suite never reads through it. +# A smoke boot with its own transcript: ovmf-serial.latest.log may be another suite's boot. BOOTS=0 smoke() { # smoke NAME [run-ovmf args] -> BOOTLOG; run-ovmf.sh's status BOOTS=$((BOOTS + 1)) @@ -35,9 +30,7 @@ smoke() { # smoke NAME [run-ovmf args] -> BOOTLOG; run-ovmf.sh's status } boot_txt() { tr -d '\r' < "$BOOTLOG"; } -# Every suite starts from a fresh install: a DISK sized from the medium, not a -# constant (see test-disk-size.sh), then the medium's installer run onto it -# with the preseeded accounts. +# Every suite starts from a fresh install onto a DISK sized from the medium (test-disk-size.sh). fresh_disk() { # fresh_disk MEDIUM [test-disk-size.sh args] local size; size="$("${SELF}/test-disk-size.sh" --medium "$@")" || die "could not size the test disk from the medium" rm -f "$DISK"; truncate -s "$size" "$DISK" diff --git a/tools/image/update-test.sh b/tools/image/update-test.sh index dc19d168..28059205 100755 --- a/tools/image/update-test.sh +++ b/tools/image/update-test.sh @@ -1,20 +1,16 @@ #!/usr/bin/env bash -# OS updates on an installed system: install release A, update to B, reboot -# into it, roll back, and check refusals, interruptions and a broken trial. +# OS updates on an installed system: install A, update to B, roll back; refusals, interruptions +# and a broken trial. # # tools/image/update-test.sh --usb-a IMG_A --payload-a DIR_A --payload-b DIR_B # [--foreign DIR] [--disk FILE] [--timeout N] # [--vars clean|enrolled | --vars-file FILE] # -# --foreign DIR a payload signed by a key B does not trust, which B must -# refuse: the other role's build, or another medium's; -# required when B is a production release -# --vars-file FILE the firmware variable store to start from, for media -# signed with a key other than this build's own +# --foreign DIR a payload signed by a key B does not trust (the other role's build, +# or another medium's); required when B is a production release +# --vars-file FILE the variable store to start from, for media another key signed # -# A and B are two stage 06 releases of this tree (make media KRYPTIK_VERSION=... -# twice). Steps 2-7 give the guest the payload on an ext4 disk image; step 8 -# has the net zone fetch it (docs/design/update-channel.md). +# A and B: stage 06 releases (make media KRYPTIK_VERSION=... twice); B's manifest says the role. # # step 1 install A, boot, create a zone volume and a home file # step 2 apply B, reboot: slot b committed, data intact @@ -24,8 +20,8 @@ # manifest for the other role, signed by B's own key # step 4 apply A with --recovery, reboot: slot a # step 5 rollback: slot b again -# step 6 the VM killed mid-write (the slot then named by nothing on the -# ESP, so rollback refuses it), then after arming: both recover +# step 6 the VM killed mid-write (rollback then refuses the unnamed slot), +# then after arming: both recover # step 7 a corrupt trial falls back to slot a, is recorded, needs --retry # step 8 automatic fetching brings B from a loopback release host; applied. # A production image fetches nothing over plain http, and says so @@ -76,11 +72,8 @@ elif [[ "$B_ROLE" == production ]]; then die "a production B must refuse a build it does not trust: name one with --foreign DIR" fi [[ -z "$VARS_FILE" || -f "$VARS_FILE" ]] || die "--vars-file ${VARS_FILE} is not a file" -# Stage 06 publishes B into a channel beside its payload with -# tools/release-channel.sh (this job has no private key): the signed statement -# that B is current, and B under ${VB}/. not-a-pointer, its control, is the -# same text signed by the release key in the manifest's namespace; only a -# development build makes it. +# Stage 06's channel beside B (tools/release-channel.sh). Its control, not-a-pointer, is the +# statement signed in the manifest's namespace, which only a development build makes. CHAN_B="$(dirname "$PAY_B")/channel-${VB}" CHAN_FILES=(latest latest.sig "${VB}/manifest") [[ "$B_ROLE" == development ]] && CHAN_FILES+=(not-a-pointer not-a-pointer.sig) @@ -102,8 +95,7 @@ if [[ -n "$VARS_FILE" ]]; then elif [[ "$VARS" == "enrolled" ]]; then cp "${KRYPTIK_WORK}/keys/sb/vars/enrolled.fd" "$VARSF" else cp /usr/share/OVMF/OVMF_VARS_4M.fd "$VARSF"; fi -# A payload as a plain ext4 disk image, which the guest mounts read-only under -# /run (its root is read-only, so no mount point can be made under /mnt). +# A payload as an ext4 disk image, mounted under /run: the read-only root takes no new mount point. payload_disk() { # payload_disk OUT DIR rm -f "$1"; local bytes; bytes="$(du -sb "$2" | cut -f1)" truncate -s $(( bytes + bytes / 10 + 64 * 1024 * 1024 )) "$1" @@ -112,12 +104,9 @@ payload_disk() { # payload_disk OUT DIR PA="${VMDIR}/payload-a.img"; PB="${VMDIR}/payload-b.img" payload_disk "$PA" "$PAY_A"; payload_disk "$PB" "$PAY_B" -# Variants of A for the refusals, applied on B with --recovery, which admits -# the older version so the check under test is reached (a variant of the -# running version would stop at "nothing to apply"). +# Variants of A, applied with --recovery so the older version reaches the check under test. BAD="${VMDIR}/bad"; rm -rf "$BAD"; mkdir -p "$BAD" -# Payload A hard-linked, and a real copy of each FILE the variant changes in -# place: a root image is gigabytes, and mkfs -d stores a link once. +# A hard-linked, with copies of the FILEs changed in place: mkfs -d stores a link once. mk_variant() { # mk_variant NAME [FILE...] rm -rf "${BAD:?}/$1" cp -al "$PAY_A" "$BAD/$1" 2>/dev/null || cp -a --sparse=always "$PAY_A" "$BAD/$1" @@ -125,15 +114,13 @@ mk_variant() { # mk_variant NAME [FILE...] rm -f "$BAD/$1/$f"; cp --sparse=always "$PAY_A/$f" "$BAD/$1/$f" done } -# The byte at OFFSET, inverted: a fixed value can land on a byte that already -# holds it, and the "modified" payload is then A itself. +# Inverted, not overwritten: a fixed value may already be there and leave the file as it was. flip() { # flip FILE OFFSET local b; b="$(od -An -tu1 -j "$2" -N1 "$1" 2>/dev/null | tr -d ' ')" || b="" [[ -n "$b" ]] || die "flip: ${1} has no byte at offset ${2}" printf '%b' "\\x$(printf '%02x' $(( b ^ 255 )))" | dd of="$1" bs=1 seek="$2" conv=notrunc status=none } -# A variant's FILE must not be the one A's manifest lists, or its refusal -# check could pass on a genuine payload. +# A variant's FILE must differ from A's, or its refusal check could pass on a genuine payload. changed() { # changed NAME FILE local want; want="$(awk -v f="$2" '$3 == f { print $1; exit }' "$PAY_A/manifest")" [[ -n "$want" && "$(sha256sum "$BAD/$1/$2" | cut -c1-64)" != "$want" ]] \ @@ -149,10 +136,7 @@ mk_variant hidden; mkdir -p "$BAD/hidden/lost+found"; echo "ride along" > "$BAD/ # The statement and its control, for step 3 to check offline against the real anchor. mkdir -p "$BAD/statement"; cp "$CHAN_B/latest" "$CHAN_B/latest.sig" "$BAD/statement/" [[ "$B_ROLE" == development ]] && cp "$CHAN_B/not-a-pointer" "$CHAN_B/not-a-pointer.sig" "$BAD/statement/" -# The signature and the role are checked before any file, so these two need -# only a manifest and its signature: the other role's build, and, beside a -# development B, stage 06's role control (B's manifest for production, signed -# by B's own key). +# Signature and role are checked before any file: the foreign and role cases need only a manifest. [[ -n "$FOREIGN" ]] && { mkdir -p "$BAD/foreign"; cp "$FOREIGN/manifest" "$FOREIGN/manifest.sig" "$BAD/foreign/"; } if [[ "$B_ROLE" == development ]]; then ROLECTL="$(dirname "$PAY_B")/role-control-${VB}" @@ -173,9 +157,7 @@ stop_unless_ok() { # stop_unless_ok RC WHAT # ----------------------------------------------------------------- step 1 -- step "step 1: install ${VA}, boot it, create zone data" -# Sized from the medium, with room for one payload: the release step 8 fetches -# is staged on kryptik-state, and every other step applies from the payload -# disk, mounted read-only. +# Room for one payload: the release step 8 fetches is staged on kryptik-state. fresh_disk "$USB_A" --payloads 1 install_disk update-install "$USB_A" "${INSTALL_VARS[@]}" && green "A installed" || { red "A did not install"; exit 1; } @@ -363,16 +345,14 @@ drive "expect:BdsDxe: starting Boot" \ rc=$?; stop_vm [[ "$rc" -eq 0 ]] && green "broken trial: verity panic, fallback to a, trial-failed recorded, refused without --retry, rewritten and committed with it; data intact" || red "step 7 drive failed" txt | grep -q 'boot-success: trial slot b did NOT boot' && green "boot-success named the failed trial" || red "boot-success did not record the failed trial" -# loglevel=4 keeps the kernel banner off the console (every "Linux version" is -# boot-smoke's, from userspace), so boots are counted by the firmware's line. +# loglevel=4 hides the kernel banner ("Linux version" is boot-smoke's): count the firmware's starts. starts="$(txt | grep -c 'BdsDxe: starting Boot')"; ups="$(txt | grep -c 'KRYPTIK_SMOKE: END')"; panics="$(txt | grep -c 'Kernel panic')" if [[ "$starts" -ge 3 && "$ups" -ge 2 && "$panics" -ge 1 ]]; then green "three boots in one session: the corrupt trial (panicked), the fallback and the retried trial (both reached userspace)"; else red "expected three boots: firmware starts=${starts}, userspace ends=${ups}, panics=${panics}"; fi # ----------------------------------------------------------------- step 8 -- if [[ "$B_ROLE" == development ]]; then step "step 8: ${VB} once more, fetched by the net zone and staged by zone 0" else step "step 8: ${VB}'s statement over plain http, by which a production image fetches nothing"; fi -# Step 7 leaves B committed with nothing newer to fetch, so roll back to A -# first. +# Step 7 leaves B committed with nothing newer to fetch, so roll back to A first. start_vm update-p8 drive "expect:KRYPTIK_SMOKE: END" "login:${TUSER}:${TPASS}" \ "$(ROOTSH 'kryptik-update rollback && echo RB8-OK')" "expect:armed: the next boot tries slot a" "expect:RB8-OK" \ @@ -384,9 +364,7 @@ rc=$?; stop_vm [[ "$rc" -eq 0 ]] && green "back on slot a (${VA}) by rollback, with room for one staged release" || red "step 8: the rollback to slot a failed" stop_unless_ok "$rc" "step 8 rollback" -# The release host serves the channel stage 06 published, as it stands, on -# loopback (10.0.2.2 to the guest); plain http is for development images only. -# The trap stops the host however the suite ends. +# The release host serves stage 06's channel on loopback; the trap stops it however the suite ends. CHAN_LOG="${VMDIR}/channel-requests.log"; : > "$CHAN_LOG"; rm -f "${VMDIR}/channel.port" python3 "${SELF}/release-host.py" "$CHAN_B" "${VMDIR}/channel.port" "$CHAN_LOG" > "${VMDIR}/channel-host.err" 2>&1 & CHAN_PID=$! @@ -395,21 +373,13 @@ for _ in $(seq 50); do [[ -s "${VMDIR}/channel.port" ]] && break; sleep 0.1; don CHAN_PORT="$(cat "${VMDIR}/channel.port" 2>/dev/null)" [[ -n "$CHAN_PORT" ]] || die "the release host did not start: $(cat "${VMDIR}/channel-host.err")" -# Restart the net zone so it reads the new update.conf, as zones-check.sh -# does, and wait for a new "netzone: READY" line. A ROOTSH command may hold no -# single quote (su -c wraps it in them) and must not exit the shell (the -# driver's marker must still print), hence the subshell. +# A ROOTSH command holds no single quote (su -c adds them) and must not exit: hence the subshell. RESTART_NET='before=$(grep -hc "netzone: READY" /run/uncaught-logs/current 2>/dev/null); before=${before:-0}; s6-svc -d /run/service/net-zone; sleep 3; s6-svc -u /run/service/net-zone; (i=0; until [ "$(grep -hc "netzone: READY" /run/uncaught-logs/current 2>/dev/null || true)" -gt "$before" ]; do i=$((i+1)); [ $i -lt 90 ] || exit 1; sleep 1; done) && echo NET-RESTARTED || echo NET-NOT-READY' -# Waits on progress, not a clock: done at "complete", failed after 100 s with -# no change (the net zone polls once a minute), and after six minutes of -# arrival it passes, for the next wait to take over. +# Ends at "complete"; fails after 100 s unchanged (polls are a minute apart); hands on after 6 min. wait_arrival() { printf '%s' '(prev=; same=0; i=0; while [ $i -lt 72 ]; do s="$(kryptik update status | sed -n "s/^staged *//p")"; case "$s" in *"bytes, complete"*) echo ARRIVED-WHOLE; exit 0 ;; esac; if [ "$s" = "$prev" ]; then same=$((same+1)); else same=0; prev="$s"; fi; [ $same -lt 20 ] || { echo "STALLED at: $s"; exit 1; }; i=$((i+1)); sleep 5; done; echo "still arriving: $s")'; } -# Waits until status shows $1, then prints $2 for the driver to expect (echo is -# off, so only the output shows it). Giving up fails the run: step. +# Prints $2 once status shows $1 (echo is off, so only output shows it); giving up fails the step. wait_status() { printf '(i=0; until kryptik update status | grep -q "%s"; do i=$((i+1)); [ $i -lt 72 ] || exit 1; sleep 5; done) && echo %s || { kryptik update status; false; }' "$1" "$2"; } -# The step assumes slot a; check, as the firmware's own boot order can still -# name the last slot tried. The statement is signed, so it is taken over plain -# http on either role. +# The firmware may boot the last slot tried, so check for a; a signed statement is safe over http. UP_TO_STATEMENT=("expect:KRYPTIK_SMOKE: END" "login:${TUSER}:${TPASS}" \ "$(ROOTSH 'echo P8B-BOOTED-$(sed -n "s/^slot=//p" /run/kryptik/boot-identity | head -1)')" "expect:P8B-BOOTED-a" \ "$(ROOTSH "mkdir -p /etc/kryptik && printf \"channel = http://10.0.2.2:${CHAN_PORT}/\\n\" > /etc/kryptik/update.conf && echo CONF-OK")" "expect:CONF-OK" \ @@ -462,8 +432,6 @@ else last_boot="$(txt | sed -n 's/^KRYPTIK_SMOKE: os_id=.* version_id=//p' | tail -1)" [[ "$first_boot" == "$VA" && "$last_boot" == "$VB" ]] && green "the guest booted ${VA} and, after the fetched update, reports ${VB}" \ || red "the guest's boots in this step: first ${first_boot:-none}, last ${last_boot:-none}; wanted ${VA} then ${VB}" - # What the release host was asked for: the statement, then the manifest and - # its signature before anything large. first="$(awk '{sub("^/", "", $1); if (!seen[$1]++) print $1}' "$CHAN_LOG" | head -5 | tr '\n' ' ')" if [[ "$first" == "latest latest.sig ${VB}/manifest ${VB}/manifest.sig "* ]]; then green "the release host was asked for the statement, then the manifest and its signature, before any image"; else red "the release host was asked in another order: ${first}"; fi fi diff --git a/tools/image/vm-drive.py b/tools/image/vm-drive.py index 78d866b9..5f1017c6 100755 --- a/tools/image/vm-drive.py +++ b/tools/image/vm-drive.py @@ -70,8 +70,7 @@ def _read(self): if self.log: self.log.write(d); self.log.flush() if self.passphrase: - # From the last answer, or just before this read: a prompt may - # straddle two reads, and none is answered twice. + # A prompt may straddle reads: search back a little, but never before the last answer. for m in UNLOCK.finditer(self.all, max(self.answered, len(self.all) - len(d) - 80)): self.answered = m.end() self.send_secret(self.passphrase) @@ -155,9 +154,7 @@ def knock(self, regex, timeout=None, every=5): self._read() def login(self, user, password): - # agetty (built with AGETTY_RELOAD) prints "login:" only after input, - # and flushes input that arrives within a second of starting or waking, - # so one Enter can be lost: knock until the prompt appears. + # agetty prints "login:" only after input and may drop an early Enter, so keep knocking. self.drain(1) self.knock(r"login: ?$", self.timeout) self.send(user) @@ -192,10 +189,7 @@ def su(self, password, cmd): return self.finish(tag, cmd) def finish(self, tag, cmd): - # Wait for the exit marker but leave the command's output for the - # steps that follow; only the marker is dropped. A shutdown message - # that matches instead stays, and is returned in place of a status. - # The status is read once its line has ended, as line_value's is. + # Only the marker goes, once its line ends; a shutdown message stays, returned in its place. rx = re.compile(rf"{tag}=(\d+)(?=\r?\n)|Power down|reboot: Restarting|Restarting system".encode(), re.M) deadline = time.time() + self.timeout while True: