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://: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.