diff options
| author | Lena <lena@omega> | 2026-01-01 00:00:00 +0000 |
|---|---|---|
| committer | Lena <lena@omega> | 2026-01-01 00:00:00 +0000 |
| commit | 29b53a8f56814499490c96169464103b89174365 (patch) | |
| tree | cc728c9a5c65a6246acd043358d2d094c9b358b0 | |
| download | alpine-avf-master.tar.gz | |
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.
| -rw-r--r-- | .gitignore | 2 | ||||
| -rw-r--r-- | README | 99 | ||||
| -rwxr-xr-x | build-alpine-avf | 274 | ||||
| -rwxr-xr-x | install-alpine-avf | 117 |
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 @@ -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 |