aboutsummaryrefslogtreecommitdiff
path: root/alpine-qemu-install
diff options
context:
space:
mode:
Diffstat (limited to 'alpine-qemu-install')
-rwxr-xr-xalpine-qemu-install252
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