aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--README115
-rwxr-xr-xalpine-qemu-install252
2 files changed, 367 insertions, 0 deletions
diff --git a/README b/README
new file mode 100644
index 0000000..fa29a0b
--- /dev/null
+++ b/README
@@ -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