diff options
Diffstat (limited to 'alpine-qemu-install')
| -rwxr-xr-x | alpine-qemu-install | 252 |
1 files changed, 252 insertions, 0 deletions
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 |