From 29b53a8f56814499490c96169464103b89174365 Mon Sep 17 00:00:00 2001 From: Lena Date: Thu, 1 Jan 2026 00:00:00 +0000 Subject: alpine-avf: replace the Android Linux Terminal VM with Alpine The Terminal app ships Debian and offers no supported way to change the guest. Android root is not available, so the only writable surface is the payload directory the app exposes to the guest over virtiofs, and the app has to keep working unmodified against whatever replaces its image. Those constraints force the two-stage install. A live root cannot be overwritten in place, and crosvm only exposes the partitions listed in vm_config.json when the VM starts, so a second partition has to be staged and the VM rebooted before there is a block device to write the new root through. The app also inspects the payload in undocumented ways. The README records what it expects and why the image is built to match. --- build-alpine-avf | 274 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 274 insertions(+) create mode 100755 build-alpine-avf (limited to 'build-alpine-avf') 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' + + + + ttyd + _http._tcp7681 + +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" <"$out/build_id" +echo "done: $out" -- cgit v1.2.3