aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.gitignore2
-rw-r--r--README99
-rwxr-xr-xbuild-alpine-avf274
-rwxr-xr-xinstall-alpine-avf117
4 files changed, 492 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..e9bcd35
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,2 @@
+/alpine-avf
+/alpine-avf-install
diff --git a/README b/README
new file mode 100644
index 0000000..8c6f603
--- /dev/null
+++ b/README
@@ -0,0 +1,99 @@
+alpine-avf
+==========
+Replace the Debian VM behind Android's official "Linux Terminal" app (AVF) with
+Alpine Linux, from inside the stock VM, with no Android root. The unmodified
+Terminal app then boots Alpine and connects to a "user" shell.
+
+
+How it works
+------------
+The Terminal app boots a crosvm VM from a payload directory it exposes to the
+guest over virtiofs at /mnt/internal/linux (root-writable). build-alpine-avf
+produces that payload; install-alpine-avf swaps it in from inside the running VM.
+
+The app finds the terminal purely over mDNS: it resolves the Avahi service named
+"ttyd" (_http._tcp), connects to https://<guest-ip>:7681, presents a client cert
+(which ttyd verifies against /mnt/internal/ca.crt), and shows ttyd's web UI.
+
+
+Usage
+-----
+In the stock Debian VM, as root:
+
+ sudo ./build-alpine-avf # writes ./alpine-avf
+ sudo ./install-alpine-avf # stage 1: stages the image, reboots
+ # reopen Terminal, then:
+ sudo ./install-alpine-avf # stage 2: installs it, reboots into Alpine
+
+Run both install stages from the same directory: stage 1 records the image path
+in an alpine-avf-install marker file there, and stage 2 reads it back.
+
+The login is "user" (no password, passwordless doas); root is also
+passwordless on the VM console, for debugging. build-alpine-avf takes
+two optional env vars: ALPINE_BRANCH (default v3.23; "latest-stable" tracks the
+newest) and ROOT_SIZE (default 4G). ROOT_SIZE is only the INITIAL filesystem
+size: on first boot Alpine grows the root to fill whatever disk the Terminal app
+provisions (see the "Disk size" note), matching what the stock Debian VM did.
+
+
+Testing
+-------
+build-alpine-avf cross-builds an aarch64 image on an x86 host that has the
+qemu-user-static binfmt handler for aarch64 registered. The result boots under
+qemu-system-aarch64, which smoke-tests the boot chain (kernel extraction,
+initramfs, root mount, OpenRC, DHCP) but not the takeover or ttyd: qemu has
+no AVF virtiofs, so avf-mounts fails loudly and ttyd, which needs it and the
+app's CA under /mnt/internal, stays down.
+
+ sudo ./build-alpine-avf
+ qemu-system-aarch64 -M virt -cpu cortex-a57 -m 3072 -smp 2 \
+ -kernel alpine-avf/vmlinuz -initrd alpine-avf/initrd.img \
+ -append "root=/dev/vda rootfstype=ext4 rw console=ttyAMA0" \
+ -drive file=alpine-avf/root_part,format=raw,if=virtio \
+ -device virtio-gpu-pci -netdev user,id=n -device virtio-net-pci,netdev=n \
+ -nographic
+
+A clean run reaches the default runlevel on the serial console. There is no
+login prompt there: the image's getty is on ttyS0 (crosvm's serial), and the
+qemu virt machine provides ttyAMA0. For a shell inside the image, add
+init=/bin/sh to -append (bypasses OpenRC). Exit qemu with C-a x. Note
+root=/dev/vda here (the raw ext4 image is the whole disk) versus /dev/vda1
+under crosvm (which presents a composite disk).
+
+
+Notes
+-----
+- Kernel: Alpine's vmlinuz is an EFI zboot PE (gzip-wrapped); crosvm's direct
+ boot needs the raw arm64 image, so the build extracts it.
+- Runlevels: a minirootfs ships them empty (no setup-alpine), so the build
+ seeds the standard services explicitly.
+- DNS: the stock Debian VM's /etc/resolv.conf is a stale GCP build-env file, so
+ the build gives the chroot its own resolvers for apk and drops them before
+ mkfs; DHCP supplies the guest's.
+- Mountpoints: /mnt/internal and /mnt/shared are pre-created in the image,
+ since OpenRC mounts them before the root fs is remounted read-write.
+- Clock: The build installs an early avf-clock OpenRC service that sets the
+ clock from /mnt/internal/ca.crt's mtime before networking and ttyd start.
+- Image writes: dd the root image through the new block device, not over
+ virtiofs (a file write over virtiofs corrupts); kernel/initrd/config go over
+ virtiofs but are verified, before the destructive root swap.
+- build_id: the Terminal app parses the last space-separated token of
+ /mnt/internal/linux/build_id as a 4-digit year and, if it is below the app's
+ release year (InstalledImage.isOlderThanCurrentVersion), declares the image
+ outdated and initiates recovery of the stock image, so build_id must
+ therefore end in a current year.
+- Disk size: the app sizes the root disk host-side, not the guest. With storage
+ ballooning on (the usual case) it grows the root_part backing file to ~95% of
+ total phone storage on every boot (VmLauncherService.calculateSparseDiskSize)
+ but leaves the guest filesystem alone, expecting the guest to grow it online;
+ an avf-resize OpenRC service runs resize2fs on the root device at boot to
+ fill it (idempotent; a no-op once filled). install-alpine-avf also sizes the
+ staged partition to the larger of the image and the outgoing root_part, so
+ the device is already large even without ballooning. ext4 online-grows
+ because mkfs keeps resize_inode.
+
+
+Recovery
+--------
+If a VM is left unbootable, reinstall stock Debian from Android Settings >
+Developer options > Linux development environment > reset.
diff --git a/build-alpine-avf b/build-alpine-avf
new file mode 100755
index 0000000..65a3241
--- /dev/null
+++ b/build-alpine-avf
@@ -0,0 +1,274 @@
+#!/bin/sh
+set -eu
+
+out="alpine-avf"
+rootfs="$out/rootfs"
+branch="${ALPINE_BRANCH:-v3.23}"
+arch=aarch64
+mirror="https://dl-cdn.alpinelinux.org/alpine"
+size="${ROOT_SIZE:-4G}"
+tmp=
+
+packages="alpine-base linux-virt mkinitfs e2fsprogs e2fsprogs-extra doas ifupdown-ng ttyd \
+avahi avahi-openrc dbus dbus-openrc"
+
+die() { echo "$*" >&2; exit 1; }
+
+cleanup() {
+ if [ -n "$tmp" ] && [ -d "$tmp" ]; then
+ rm -rf "$tmp" || true
+ fi
+ for m in dev/pts dev sys proc; do
+ if mountpoint -q "$rootfs/$m"; then
+ umount "$rootfs/$m" || true
+ fi
+ done
+}
+
+# cleanup unmounts best-effort only; rm -rf or mkfs -d on a rootfs that still
+# has /dev bind-mounted would eat the host's /dev, so verify before either.
+assert_unmounted() {
+ for m in dev/pts dev sys proc; do
+ ! mountpoint -q "$rootfs/$m" \
+ || die "$rootfs/$m still mounted; unmount it and rerun"
+ done
+}
+
+trap cleanup EXIT
+trap 'exit 129' HUP
+trap 'exit 130' INT
+trap 'exit 143' TERM
+
+[ "$(id -u)" = 0 ] || die "run as root"
+[ "$(uname -m)" = "$arch" ] || [ -e "/proc/sys/fs/binfmt_misc/qemu-$arch" ] \
+ || die "need a $arch host or qemu-user-static binfmt"
+for c in curl grep head mktemp sha256sum tar sed gzip od tr dd basename mkfs.ext4 openssl mount umount mountpoint chroot; do
+ command -v "$c" >/dev/null 2>&1 || die "need $c"
+done
+
+# Validate inputs before the destructive cleanup below, so a typo fails loudly
+# here instead of after curl wipes the old output or deep inside mkfs.ext4.
+case "$branch" in
+ "" | *[!a-zA-Z0-9.-]*) die "ALPINE_BRANCH must look like v3.23, latest-stable, or edge (got '$branch')" ;;
+esac
+case "${size%[sSkKmMgGtT]}" in
+ "" | *[!0-9]*) die "ROOT_SIZE must be digits with an optional s/K/M/G/T suffix (got '$size')" ;;
+esac
+
+echo "cleaning $out"
+cleanup
+assert_unmounted
+rm -rf "$out"; mkdir -p "$rootfs"
+
+echo "fetching alpine $branch minirootfs"
+rel="$mirror/$branch/releases/$arch"
+file="$(curl -fsSL "$rel/latest-releases.yaml" | grep -oE "alpine-minirootfs-[0-9.]+-$arch\.tar\.gz" | head -1)"
+[ -n "$file" ] || die "cannot determine minirootfs filename"
+tmp="$(mktemp -d)"
+curl -fsSL -o "$tmp/$file" "$rel/$file"
+curl -fsSL -o "$tmp/$file.sha256" "$rel/$file.sha256"
+( cd "$tmp" && sha256sum -c "$file.sha256" )
+tar -xzf "$tmp/$file" -C "$rootfs"
+rm -rf "$tmp"
+
+echo "installing packages"
+printf 'nameserver 8.8.8.8\nnameserver 1.1.1.1\n' >"$rootfs/etc/resolv.conf"
+printf '%s\n%s\n' "$mirror/$branch/main" "$mirror/$branch/community" >"$rootfs/etc/apk/repositories"
+mount -t proc none "$rootfs/proc"
+mount -t sysfs none "$rootfs/sys"
+mount --bind /dev "$rootfs/dev"
+mount --bind /dev/pts "$rootfs/dev/pts"
+chroot "$rootfs" /bin/sh -c "apk update && apk add $packages"
+
+echo "configuring"
+echo alpine >"$rootfs/etc/hostname"
+mkdir -p "$rootfs/mnt/internal" "$rootfs/mnt/shared"
+
+cat >"$rootfs/etc/modules" <<'EOF'
+virtio_blk
+virtio_pci
+virtio_net
+virtio_console
+virtiofs
+vmw_vsock_virtio_transport
+EOF
+
+chroot "$rootfs" passwd -d root
+chroot "$rootfs" adduser -D -u 1000 -s /bin/ash user
+chroot "$rootfs" addgroup user video
+chroot "$rootfs" passwd -d user
+echo 'permit nopass user' >"$rootfs/etc/doas.conf"
+
+grep -q '^ttyS0::' "$rootfs/etc/inittab" \
+ || echo 'ttyS0::respawn:/sbin/getty -L 0 ttyS0 vt100' >>"$rootfs/etc/inittab"
+
+cat >"$rootfs/etc/network/interfaces" <<'EOF'
+auto lo
+iface lo inet loopback
+auto eth0
+iface eth0 inet dhcp
+EOF
+
+mkdir -p "$rootfs/etc/ttyd"
+openssl req -x509 -newkey rsa:2048 -nodes -days 3650 -subj /CN=localhost \
+ -keyout "$rootfs/etc/ttyd/server.key" -out "$rootfs/etc/ttyd/server.crt" 2>/dev/null
+chmod 0600 "$rootfs/etc/ttyd/server.key"
+
+cat >"$rootfs/etc/init.d/ttyd" <<'EOF'
+#!/sbin/openrc-run
+description="ttyd terminal for the AVF Terminal app"
+supervisor=supervise-daemon
+command="/usr/bin/ttyd"
+command_args="--ssl --ssl-cert /etc/ttyd/server.crt --ssl-key /etc/ttyd/server.key --ssl-ca /mnt/internal/ca.crt -t disableLeaveAlert=true -W login -f user"
+respawn_delay=2
+depend() { need avf-mounts; after net avahi-daemon; }
+EOF
+chmod +x "$rootfs/etc/init.d/ttyd"
+
+rm -f "$rootfs"/etc/avahi/services/*.service
+cat >"$rootfs/etc/avahi/services/ttyd.service" <<'EOF'
+<?xml version="1.0" standalone='no'?>
+<!DOCTYPE service-group SYSTEM "avahi-service.dtd">
+<service-group>
+ <name>ttyd</name>
+ <service><type>_http._tcp</type><port>7681</port></service>
+</service-group>
+EOF
+sed -i 's/^#*use-ipv4=.*/use-ipv4=yes/; s/^#*use-ipv6=.*/use-ipv6=no/' "$rootfs/etc/avahi/avahi-daemon.conf"
+
+cat >"$rootfs/etc/init.d/avf-mounts" <<'EOF'
+#!/sbin/openrc-run
+description="Load AVF virtio modules and mount virtiofs shares"
+depend() { after modules; before net; }
+start() {
+ ebegin "Mounting AVF virtiofs shares"
+ for m in virtio_net virtiofs vmw_vsock_virtio_transport; do modprobe "$m" 2>/dev/null || true; done
+ mkdir -p /mnt/internal /mnt/shared
+ # ownership only shows through if a mount fails and the bare dir is used
+ chown 1000:1000 /mnt/internal /mnt/shared 2>/dev/null || true
+ if ! mountpoint -q /mnt/internal && ! mount -t virtiofs internal /mnt/internal; then
+ eend 1 "failed to mount /mnt/internal"
+ return 1
+ fi
+ if ! mountpoint -q /mnt/shared && ! mount -t virtiofs android /mnt/shared; then
+ ewarn "failed to mount /mnt/shared"
+ fi
+ eend 0
+}
+EOF
+chmod +x "$rootfs/etc/init.d/avf-mounts"
+
+cat >"$rootfs/etc/init.d/avf-clock" <<'EOF'
+#!/sbin/openrc-run
+description="Set the AVF guest clock from the Terminal app CA certificate"
+depend() { need avf-mounts; before net; }
+start() {
+ ebegin "Setting clock from /mnt/internal/ca.crt"
+ n=0
+ while [ ! -e /mnt/internal/ca.crt ] && [ "$n" -lt 10 ]; do
+ sleep 1
+ n=$((n + 1))
+ done
+ if [ ! -e /mnt/internal/ca.crt ]; then
+ eend 1 "/mnt/internal/ca.crt not found"
+ return 1
+ fi
+ ts="$(date -u -r /mnt/internal/ca.crt '+%Y-%m-%d %H:%M:%S' 2>/dev/null)" || {
+ eend 1 "could not read /mnt/internal/ca.crt mtime"
+ return 1
+ }
+ date -u -s "$ts" >/dev/null || {
+ eend 1 "could not set clock"
+ return 1
+ }
+ eend 0
+}
+EOF
+chmod +x "$rootfs/etc/init.d/avf-clock"
+
+cat >"$rootfs/etc/init.d/avf-resize" <<'EOF'
+#!/sbin/openrc-run
+description="Grow the root filesystem to fill its block device"
+depend() { after root; before localmount; }
+start() {
+ ebegin "Growing root filesystem to fill the disk"
+ dev="$(awk '$2 == "/" { print $1; exit }' /proc/mounts)"
+ [ -b "$dev" ] || { ewarn "root device not found"; return 0; }
+ resize2fs "$dev"
+ eend $?
+}
+EOF
+chmod +x "$rootfs/etc/init.d/avf-resize"
+
+chroot "$rootfs" /bin/sh <<'EOF'
+set -e
+for s in devfs dmesg mdev hwdrivers cgroups; do rc-update add "$s" sysinit || true; done
+for s in modules sysctl hostname bootmisc syslog seedrng localmount networking; do rc-update add "$s" boot || true; done
+for s in killprocs mount-ro savecache; do rc-update add "$s" shutdown || true; done
+rc-update add avf-mounts boot
+rc-update add avf-clock boot
+rc-update add avf-resize boot
+rc-update add dbus default
+rc-update add avahi-daemon default
+rc-update add ttyd default
+EOF
+
+echo "building image"
+echo 'features="base virtio ext4"' >"$rootfs/etc/mkinitfs/mkinitfs.conf"
+kver="$(basename "$(ls -d "$rootfs"/lib/modules/*-virt | head -1)")"
+[ -n "$kver" ] || die "no -virt kernel modules under $rootfs/lib/modules"
+chroot "$rootfs" mkinitfs -o /boot/initramfs-avf "$kver"
+cp "$rootfs/boot/initramfs-avf" "$out/initrd.img"
+
+# Alpine ships vmlinuz as an EFI zboot PE wrapping a gzip Image; crosvm needs the
+# raw arm64 Image (magic "ARMd" / 41524d64 at offset 0x38).
+vmlinuz="$rootfs/boot/vmlinuz-virt"
+if [ "$(od -An -c -j4 -N4 "$vmlinuz" | tr -d ' ')" = "zimg" ]; then
+ off="$(od -An -tu4 -j8 -N4 "$vmlinuz" | tr -d ' ')"
+ sz="$(od -An -tu4 -j12 -N4 "$vmlinuz" | tr -d ' ')"
+ dd if="$vmlinuz" bs=1M iflag=skip_bytes,count_bytes skip="$off" count="$sz" 2>/dev/null | gzip -dc >"$out/vmlinuz"
+else
+ cp "$vmlinuz" "$out/vmlinuz"
+fi
+[ "$(od -An -tx1 -j56 -N4 "$out/vmlinuz" | tr -d ' \n')" = "41524d64" ] \
+ || die "extracted kernel is not a raw arm64 Image"
+
+cleanup
+assert_unmounted
+rm -f "$rootfs/etc/resolv.conf"
+# ^orphan_file: Alpine's mke2fs enables it by default; the image must still
+# pass the stock VM's e2fsck (the stage 2 gate in install-alpine-avf) and
+# online resize2fs under crosvm, so keep the feature set conservative.
+mkfs.ext4 -q -F -L ROOT -O ^orphan_file -d "$rootfs" "$out/root_part" "$size"
+
+# keep the stock layout; "name" stays "debian" (anything else is untested
+# against the Terminal app).
+cat >"$out/vm_config.json" <<EOF
+{
+ "name": "debian",
+ "disks": [ { "partitions": [
+ { "label": "ROOT", "path": "\$PAYLOAD_DIR/root_part", "writable": true, "guid": "$(cat /proc/sys/kernel/random/uuid)" }
+ ], "writable": true } ],
+ "sharedPath": [ { "sharedPath": "/storage/emulated" }, { "sharedPath": "\$APP_DATA_DIR/files" } ],
+ "kernel": "\$PAYLOAD_DIR/vmlinuz",
+ "initrd": "\$PAYLOAD_DIR/initrd.img",
+ "params": "root=/dev/vda1 rootfstype=ext4 rw console=ttyS0",
+ "protected": false,
+ "cpu_topology": "match_host",
+ "platform_version": "~1.0",
+ "memory_mib": 4096,
+ "debuggable": true,
+ "connect_console": true,
+ "console_out": true,
+ "console_input_device": "ttyS0",
+ "network": true,
+ "auto_memory_balloon": false,
+ "gpu": { "backend": "2d" }
+}
+EOF
+
+# The Terminal app parses the LAST space-separated token of build_id as a 4-digit
+# year and reinstalls the stock Debian (InstalledImage.isOlderThanCurrentVersion)
+# if it is below the app's release year. Keep the trailing year; do not drop it.
+echo "alpine $(date -u +%Y%m%dT%H%M%SZ) $(date -u +%Y)" >"$out/build_id"
+echo "done: $out"
diff --git a/install-alpine-avf b/install-alpine-avf
new file mode 100755
index 0000000..f5b1dea
--- /dev/null
+++ b/install-alpine-avf
@@ -0,0 +1,117 @@
+#!/bin/sh
+set -eu
+
+vm="/mnt/internal/linux"
+marker="alpine-avf-install"
+extras="vmlinuz initrd.img build_id vm_config.json" # everything but root_part
+
+die() { echo "$*" >&2; exit 1; }
+
+[ "$(id -u)" = 0 ] || die "run as root"
+[ -e /sdcard ] && die "run this INSIDE the Terminal VM, not Android"
+[ -d "$vm" ] || die "$vm not found; is this the Terminal VM?"
+for c in python3 truncate stat blockdev findmnt e2fsck cmp dd sync reboot; do
+ command -v "$c" >/dev/null 2>&1 || die "need $c"
+done
+
+if [ -f "$marker" ]; then
+ stage=2; img="$(cat "$marker")"
+else
+ stage=1; img="$(CDPATH='' cd -- alpine-avf && pwd)"
+fi
+
+for f in root_part $extras; do
+ [ -f "$img/$f" ] || die "missing $img/$f"
+done
+
+if [ "$stage" = 1 ]; then
+ echo "stage 1: staging $img"
+ # Size the new partition to the larger of our image and the outgoing Debian
+ # root_part, so Alpine inherits the full disk. The file is sparse, so the
+ # larger apparent size costs no real space; avf-resize grows ext4 into it.
+ size="$(stat -c %s "$img/root_part")"
+ cur="$(stat -c %s "$vm/root_part" 2>/dev/null || echo 0)"
+ [ "$cur" -gt "$size" ] && size="$cur"
+ guid="$(cat /proc/sys/kernel/random/uuid)"
+ # Hide the stock backup while the install is in flight, so the app cannot
+ # restore it over the staged root; stage 2 puts it back after the swap.
+ [ -e "$vm/root_part_backup" ] && mv -f "$vm/root_part_backup" "$vm/root_part_backup.aside"
+ truncate -s "$size" "$vm/alpine_root"
+ # Append a temporary ALPINE partition; crosvm exposes it as a new /dev/vdaN.
+ python3 - "$vm/vm_config.json" "$guid" <<'PY'
+import json, os, sys
+p, guid = sys.argv[1], sys.argv[2]
+c = json.load(open(p)); parts = c["disks"][0]["partitions"]
+if not any(str(x.get("path", "")).endswith("/alpine_root") for x in parts):
+ parts.append({"label": "ALPINE", "path": "$PAYLOAD_DIR/alpine_root", "writable": True, "guid": guid})
+t = p + ".new"
+with open(t, "w") as f:
+ json.dump(c, f, indent=4)
+ f.write("\n")
+os.replace(t, p)
+PY
+ printf '%s\n' "$img" > "$marker"
+ echo "staged; rebooting. Reopen Terminal, then run this again."
+ sync; reboot
+ exit 0
+fi
+
+echo "stage 2: installing $img"
+target="$(python3 - "$vm/vm_config.json" <<'PY'
+import json, sys
+parts = json.load(open(sys.argv[1]))["disks"][0]["partitions"]
+i = next((n for n, x in enumerate(parts, 1) if str(x.get("path", "")).endswith("/alpine_root")), None)
+if i is None:
+ sys.exit("no ALPINE partition in vm_config.json; rm ./alpine-avf-install and rerun stage 1")
+print("/dev/vda%d" % i)
+PY
+)"
+echo "target: $target"
+n=0; while [ ! -b "$target" ] && [ "$n" -lt 20 ]; do sleep 1; n=$((n + 1)); done
+[ -b "$target" ] || die "$target absent; reboot and rerun, or rm ./$marker and rerun stage 1"
+[ "$target" != "$(findmnt -no SOURCE /)" ] || die "$target is the live root; refusing"
+want="$(stat -c %s "$img/root_part")"
+[ "$(blockdev --getsize64 "$target")" -ge "$want" ] || die "target smaller than source"
+
+# dd through the block device (writing the file over virtiofs corrupts).
+# Chunked with a sync per chunk to bound dirty page cache in the VM's RAM.
+echo "copying root_part -> $target"
+chunk=$((250 * 1024 * 1024)); n=0
+while [ $((n * chunk)) -lt "$want" ]; do
+ dd if="$img/root_part" of="$target" bs="$chunk" count=1 skip="$n" seek="$n" conv=notrunc iflag=fullblock status=none
+ sync; n=$((n + 1))
+done
+# The copy of a freshly built image must check out clean; even "errors
+# corrected" (rc 1/2) means the copy diverged from the verified source.
+e2fsck -fy "$target" || die "e2fsck rc $? on $target: copy diverged; rerun stage 2"
+
+# Stage kernel/initrd/config over virtiofs, verified; fail before the swap.
+for f in $extras; do
+ rm -f "$vm/$f.new"
+ n=0
+ until cmp -s "$img/$f" "$vm/$f.new"; do
+ [ "$n" -lt 3 ] || die "could not write $vm/$f.new reliably; aborted before swap"
+ dd if="$img/$f" of="$vm/$f.new" bs=1M conv=fsync status=none
+ sync
+ n=$((n + 1))
+ done
+done
+
+echo "swapping root"
+rm -f "$vm/root_part.previous"
+mv -f "$vm/root_part" "$vm/root_part.previous"
+mv -f "$vm/alpine_root" "$vm/root_part" || {
+ mv -f "$vm/root_part.previous" "$vm/root_part"
+ die "could not replace root_part"
+}
+for f in $extras; do
+ mv -f "$vm/$f.new" "$vm/$f"
+done
+rm -f "$vm/root_part.previous"
+# stock Debian's extra-modules image; the new vm_config.json does not use it
+rm -f "$vm/kernel_extras_part"
+[ -e "$vm/root_part_backup.aside" ] && mv -f "$vm/root_part_backup.aside" "$vm/root_part_backup"
+rm -f "$marker"
+echo "installed; rebooting into Alpine. Reopen the Terminal app."
+# the pause lets virtiofs settle the renames host-side before the VM dies
+sync; sleep 2; reboot