diff options
| author | Lena <lena@omega> | 2026-02-01 00:00:00 +0000 |
|---|---|---|
| committer | Lena <lena@omega> | 2026-02-01 00:00:00 +0000 |
| commit | da0bb7fe3fd16b5d5b6269715d6e235d7f04b295 (patch) | |
| tree | a9241a1ec34c7e28905eab71d17a7cbd4dbaa633 | |
| download | alpine-qemu-install-da0bb7fe3fd16b5d5b6269715d6e235d7f04b295.tar.gz | |
Unattended Alpine install onto a disk image, driven over the serial
console: the virt ISO boots under QEMU with an injected apkovl that
runs setup-alpine from a pre-baked answer file.
| -rw-r--r-- | README | 115 | ||||
| -rwxr-xr-x | alpine-qemu-install | 252 |
2 files changed, 367 insertions, 0 deletions
@@ -0,0 +1,115 @@ +alpine-qemu-install +=================== +Unattended install of Alpine Linux onto a disk image, driven entirely over the +serial console. No keypresses, no graphics, no interaction. + + +What it does +------------ +Given an Alpine virt ISO and a disk image you created beforehand, the script: + + - extracts the kernel, initramfs, and modloop from the ISO + - builds an apkovl overlay containing the kernel modules and a first-boot + script + - boots that kernel under QEMU with the disk attached as /dev/vda + - runs setup-alpine non-interactively against a pre-baked answer file + - patches the installed root password, SSH key, and serial getty + - powers the guest off when finished + +The disk image is the target. It appears as /dev/vda inside the guest and is +erased. After install the layout is vda1=boot, vda2=swap, vda3=root. + + +Requirements +------------ +On the host: + + - 7z (7zip or p7zip-full; extracting the modloop squashfs needs it) + - GNU tar + - openssl + - qemu-system-x86_64 + +KVM is used automatically when /dev/kvm is writable, otherwise the install +runs under plain emulation (slower, but works). You also need an x86_64 +Alpine virt ISO and a disk image created up front with qemu-img. qcow2 is +recommended: it allocates sparsely and supports snapshots. Any other format +qemu-img writes also works, since QEMU detects it. + + +Usage +----- +Create a disk, then run the install: + + qemu-img create -f qcow2 disk.qcow2 2G + ROOT_PASSWORD=changeme \ + ./alpine-qemu-install alpine-virt-3.20.0-x86_64.iso disk.qcow2 + +The install log streams to your terminal. When it reaches the end the guest +powers off and the script exits. Boot the resulting image however you like, +for example: + + qemu-system-x86_64 -m 1024 -nographic -drive file=disk.qcow2,if=virtio + + +Configuration +------------- +Configuration is by environment variable. Only ROOT_PASSWORD is required. + + - ROOT_PASSWORD root password for the installed system (required) + - ROOT_PUBKEY an authorized_keys line; enables root SSH login + - SSHD openssh, dropbear, or none (default openssh) + - VM_HOSTNAME installed hostname (default alpine) + - APK_MIRROR apk mirror base URL + (default https://dl-cdn.alpinelinux.org/alpine) + - RAM_MB guest memory in MB during install (default 1024) + - SMP guest CPU count during install (default 2) + +The mirror must be reachable during install: setup-alpine fetches packages +before the disk is even partitioned. + + +How it works +------------ +QEMU's vvfat is unreliable for the ~150 MB modloop file, and a failed +modloop mount leaves the initramfs without /lib/modules, so setup-alpine +cannot modprobe, partition, or run post-install scripts. To avoid that path, +the script unpacks the modloop squashfs on the host and ships the modules +inside the apkovl overlay instead. Only one small file goes on the vvfat +drive: the apkovl tarball itself. + +The first-boot work runs from /etc/local.d/autoinstall.start via the default +runlevel's local service. It removes itself before setup-disk copies the +overlay onto the target, so the trigger never fires on the installed system. + + +Debugging +--------- +The install is fully visible. QEMU runs with -nographic, so everything the +first-boot script does is logged to ttyS0, which is your terminal. The script +runs under set -x, so each command is printed before it runs. + +QEMU runs with -no-reboot, so on success or failure the guest stays down and +the full log remains on screen. To leave QEMU manually, press Ctrl-a then x. + +The guest reports distinct success and failure values through qemu's +isa-debug-exit device. The script exits 0 only when the guest reported +completion, 3 when the first-boot script failed, and 1 when qemu stopped +without any result, including a manual Ctrl-a x. + +Common failures: + + - "need 7z" or "need qemu-system-x86_64": install the missing host tool. + - "modloop layout changed": install 7zip or p7zip-full, or the ISO changed + and the modloop extraction needs updating. + - "/dev/vda did not appear": the disk image was not attached or is missing. + - apk fetch errors: the mirror is unreachable or APK_MIRROR is wrong. + + +Security +-------- +ROOT_PASSWORD is hashed on the host; openssl reads it on stdin, so the +plaintext never reaches the guest, the apkovl, the set -x install log, or +a process listing. The hash is kept out of the log too, but any crypt hash +can be attacked offline, so pick a real password for images you keep. Root +SSH login is key-only: ROOT_PUBKEY installs the key, and Alpine's default +PermitRootLogin prohibit-password refuses password logins over SSH. diff --git a/alpine-qemu-install b/alpine-qemu-install new file mode 100755 index 0000000..2ef83b3 --- /dev/null +++ b/alpine-qemu-install @@ -0,0 +1,252 @@ +#!/bin/sh +set -eu + +# alpine-qemu-install ISO DISK +# +# Unattended install of Alpine onto DISK (a disk image pre-created with +# qemu-img) using the Alpine virt ISO. DISK appears as /dev/vda inside the +# guest and is erased. + +[ "$#" -eq 2 ] || { echo "usage: ROOT_PASSWORD=... $0 ISO DISK" >&2; exit 1; } +: "${ROOT_PASSWORD:?set ROOT_PASSWORD}" + +ISO=$1 +DISK=$2 +[ -f "$ISO" ] || { echo "error: ISO not found: $ISO" >&2; exit 1; } +[ -f "$DISK" ] || { echo "error: DISK not found: $DISK" >&2; exit 1; } + +ROOT_PUBKEY=${ROOT_PUBKEY:-} +SSHD=${SSHD:-openssh} +VM_HOSTNAME=${VM_HOSTNAME:-alpine} +APK_MIRROR=${APK_MIRROR:-https://dl-cdn.alpinelinux.org/alpine} +RAM_MB=${RAM_MB:-1024} +SMP=${SMP:-2} + +for t in 7z tar openssl qemu-system-x86_64; do + command -v "$t" >/dev/null || { echo "error: need $t" >&2; exit 1; } +done + +# Hash the password on the host so the plaintext never reaches the guest, +# the apkovl, or the set -x install log. openssl reads one line on stdin, +# so it never appears in /proc/*/cmdline and a newline would truncate it. +case $ROOT_PASSWORD in + *' +'*) echo "error: ROOT_PASSWORD must not contain a newline" >&2; exit 1 ;; +esac +ROOT_HASH=$(printf '%s\n' "$ROOT_PASSWORD" | openssl passwd -6 -stdin) +unset ROOT_PASSWORD + +# Shell-quote a value for embedding into generated shell source. +shq() { + printf "'" + printf '%s' "$1" | sed "s/'/'\\\\''/g" + printf "'" +} + +# 0755 overlay dirs; they are unpacked over / in the guest and their modes +# survive onto the installed /etc. The workdir itself is 0700 from mktemp. +umask 022 +WORK=$(mktemp -d "${TMPDIR:-/tmp}/alpine-qemu-install.XXXXXX") +trap 'rm -rf "$WORK"' EXIT +trap 'exit 1' HUP INT TERM + +7z x -y -o"$WORK" "$ISO" boot/vmlinuz-virt boot/initramfs-virt boot/modloop-virt >/dev/null +for f in boot/vmlinuz-virt boot/initramfs-virt boot/modloop-virt; do + [ -s "$WORK/$f" ] || { echo "error: $f missing from ISO" >&2; exit 1; } +done + +# Alpine's normal boot mounts modloop-virt to populate /lib/modules, but +# QEMU's vvfat is unreliable for a ~150 MB file and the initramfs silently +# fails to find it. Without /lib/modules setup-alpine can't modprobe, can't +# partition, can't run apk post-install scripts. Pre-populating /lib/modules +# from the modloop squashfs side-steps the modloop mount path entirely. +7z x -y -o"$WORK/mods" "$WORK/boot/modloop-virt" >/dev/null +[ -d "$WORK/mods/modules" ] || { + echo "error: 7z could not extract modules/ from modloop-virt" >&2 + echo " (install 7zip or p7zip-full, or the modloop layout changed)" >&2 + exit 1 +} + +OVL=$WORK/ovl +mkdir -p "$OVL/etc/local.d" \ + "$OVL/etc/runlevels/default" \ + "$OVL/lib" +mv "$WORK/mods/modules" "$OVL/lib/modules" +# The modloop nests firmware/ inside modules/. The virt kernel loads no +# firmware under QEMU, so drop it rather than ship it to a path the kernel +# never searches. +rm -rf "$OVL/lib/modules/firmware" + +# Run autoinstall.start on first boot via the default-runlevel "local" service. +ln -s ../../init.d/local "$OVL/etc/runlevels/default/local" + +cat >"$OVL/etc/local.d/autoinstall.start" <<EOF +#!/bin/sh +set -eu +PATH=/sbin:/bin:/usr/sbin:/usr/bin + +# Baked in at build time, assigned before set -x to keep the password hash +# out of the install log. +ROOT_HASH=$(shq "$ROOT_HASH") +ROOT_PUBKEY=$(shq "$ROOT_PUBKEY") +APK_MIRROR=$(shq "$APK_MIRROR") +VM_HOSTNAME=$(shq "$VM_HOSTNAME") +SSHD=$(shq "$SSHD") + +exec >/dev/ttyS0 2>&1 +set -x + +# Any exit path reports an explicit result through the isa-debug-exit port +# (0xf4 = 244) and powers off; QEMU has -no-reboot so the user sees the +# full log. Writing value v makes qemu exit 2v+1: 0x10 -> 33 for success, +# 0x11 -> 35 for failure, both away from qemu's own exit codes. A qemu exit +# of 0 therefore means the install never finished. +on_exit() { + rc=\$? + trap - EXIT INT TERM HUP + sync + if [ "\$rc" -eq 0 ]; then + printf '\\020' | dd of=/dev/port bs=1 seek=244 || true + else + printf '\\021' | dd of=/dev/port bs=1 seek=244 || true + fi + poweroff -f +} +trap on_exit EXIT +trap 'exit 1' INT TERM HUP + +# Don't re-trigger on the installed system's first boot. +rm -f /etc/local.d/autoinstall.start /etc/runlevels/default/local + +i=0 +while [ ! -b /dev/vda ]; do + i=\$((i+1)) + [ "\$i" -ge 30 ] && { echo 'fatal: /dev/vda did not appear within 30s'; exit 1; } + sleep 1 +done + +# Bring up eth0 before setup-alpine starts. setup-alpine's very first step +# (keymap) needs to apk-add kbd-bkeymaps even when KEYMAPOPTS=none, which +# means the mirror must already be reachable. setup-alpine's own interfaces +# step runs later and will bounce the link; the DHCP re-negotiation is fine. +ip link set lo up +ip link set eth0 up +udhcpc -i eth0 -q -n -T 3 -t 5 + +# Write /etc/apk/repositories with the live system's version so apk works +# for every setup-alpine sub-step. setup-disk copies this file into the +# target, so the installed system gets the same repos. +ALPINE_VER=\$(awk -F. '{print "v"\$1"."\$2; exit}' /etc/alpine-release) +cat >/etc/apk/repositories <<EOF2 +\$APK_MIRROR/\$ALPINE_VER/main +\$APK_MIRROR/\$ALPINE_VER/community +EOF2 +apk update + +# Generate setup-alpine's answer file here so APKREPOSOPTS can carry the +# version-qualified URLs directly. Passing full repo URLs bypasses +# setup-apkrepos's path-appending logic (which produced a bare URL) +# and avoids the "APKREPOSOPTS=none" footgun (it wrote the literal "none" +# into /etc/apk/repositories and broke every later apk fetch). +cat >/root/setup.answer <<EOF2 +KEYMAPOPTS=none +HOSTNAMEOPTS='\$VM_HOSTNAME' +DEVDOPTS=mdev +INTERFACESOPTS='auto lo +iface lo inet loopback + +auto eth0 +iface eth0 inet dhcp +' +DNSOPTS=none +TIMEZONEOPTS=none +PROXYOPTS=none +APKREPOSOPTS='\$APK_MIRROR/\$ALPINE_VER/main \$APK_MIRROR/\$ALPINE_VER/community' +USEROPTS=none +SSHDOPTS='\$SSHD' +ROOTSSHKEY=none +NTPOPTS=none +DISKOPTS='-m sys /dev/vda' +LBUOPTS=none +APKCACHEOPTS=none +EOF2 + +export KERNELOPTS='console=ttyS0,115200' +ERASE_DISKS=/dev/vda setup-alpine -e -f /root/setup.answer + +# setup-disk umounts /mnt when it finishes. Remount the root partition to +# patch /etc/shadow, /etc/inittab, /etc/securetty. With DISKOPTS='-m sys +# /dev/vda' the layout is vda1=boot, vda2=swap, vda3=root. +mount /dev/vda3 /mnt +[ -e /mnt/etc/shadow ] || { echo 'fatal: /mnt/etc/shadow missing after remount'; exit 1; } + +# Alpine's busybox has no chpasswd applet (it's in the separate shadow +# package), so the crypt hash goes straight into /etc/shadow. Keep it out +# of the set -x log; it is still crackable. +set +x +sed -i "s|^root:[^:]*:|root:\$ROOT_HASH:|" /mnt/etc/shadow +set -x + +# Alpine's default sshd_config has PermitRootLogin=prohibit-password, so just +# dropping the key in authorized_keys is enough to enable SSH root logins. +if [ -n "\$ROOT_PUBKEY" ]; then + mkdir -p /mnt/root/.ssh + chmod 0700 /mnt/root/.ssh + printf '%s\\n' "\$ROOT_PUBKEY" >/mnt/root/.ssh/authorized_keys + chmod 0600 /mnt/root/.ssh/authorized_keys +fi + +# Alpine's live initramfs adds its own ttyS0 getty to the live inittab +# (console=ttyS0), and setup-disk copies that file to the target. Append a +# getty only if no active ttyS0 entry exists; two gettys on one tty split +# the input between them and console login always fails. +grep -q '^ttyS0:' /mnt/etc/inittab \\ + || echo 'ttyS0::respawn:/sbin/getty -L 115200 ttyS0 vt100' >>/mnt/etc/inittab +grep -qxF ttyS0 /mnt/etc/securetty 2>/dev/null \\ + || echo ttyS0 >>/mnt/etc/securetty + +umount /mnt +EOF +chmod 0755 "$OVL/etc/local.d/autoinstall.start" + +# vvfat is reliable for a single small file like the apkovl. +mkdir -p "$WORK/fat" +tar -C "$OVL" --owner=0 --group=0 --numeric-owner \ + -czf "$WORK/fat/localhost.apkovl.tar.gz" etc lib + +CMDLINE='console=ttyS0,115200 modules=loop,squashfs,sd-mod,sr-mod,cdrom,iso9660,virtio_pci,virtio_blk,virtio_net,fat,vfat,nls_cp437,nls_ascii' + +# QEMU splits -drive options on commas, so a comma in a path must be doubled. +qesc() { + printf '%s' "$1" | sed 's/,/,,/g' +} + +set -- qemu-system-x86_64 \ + -m "$RAM_MB" \ + -smp "$SMP" \ + -nographic \ + -no-reboot \ + -device isa-debug-exit,iobase=0xf4,iosize=1 \ + -kernel "$WORK/boot/vmlinuz-virt" \ + -initrd "$WORK/boot/initramfs-virt" \ + -append "$CMDLINE" \ + -drive file="$(qesc "$ISO")",media=cdrom,readonly=on \ + -drive file="$(qesc "$DISK")",if=virtio \ + -drive file=fat:rw:"$(qesc "$WORK/fat")",format=raw,if=virtio \ + -nic user,model=virtio-net-pci + +# KVM when /dev/kvm is writable; plain emulation otherwise (slower, works). +[ -w /dev/kvm ] && set -- "$@" -enable-kvm -cpu host + +set +e +"$@" +rc=$? +set -e + +# qemu maps the guest's isa-debug-exit writes to 33 (ok) and 35 (failed). +case $rc in + 33) exit 0 ;; + 35) exit 3 ;; + 0) echo "error: qemu stopped without an install result" >&2; exit 1 ;; + *) exit "$rc" ;; +esac |