diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9016f3f --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +*.iso +*.img +*.cow +*.cow2 +*.log +mnt diff --git a/README.md b/README.md index fb99dfa..1d22359 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,73 @@ # qemu-arch-linux-automation -Scripts and playbooks to automate the installation, configuration and management of Arch Linux VMs in qemu + +Scripts and playbooks to automate the installation, configuration and +management of Arch Linux VMs in qemu. + +## Dependencies + +- `qemu` +- `curl` +- `expect` + +## Installation + +- Add the path to the `src` directory to your `PATH` environment variable +- Create a symbolic link to `src/` directory to your `PATH` environment variable + +## Usage + +### Install an Arch Linux virtual machine + +```text +Usage: qemu-arch install [-o <output-disk-image>] [-a <architecture>] [-s <disk-size>] + [-m <memory>] [-h <hostname>] [-P <root-password>] [-u <non-root-username>] + [-p <non-root-user-password>] [-z <timezone>] [-l <locale>] [-M <arch-mirror-url>] + +-o <output-disk-image> Path of the output disk image (default: ./arch.img) +-a <architecture> Target architecture (default: x86_64) +-s <disk-size> Disk size (default: 8G) +-m <memory> RAM size in KB (default: 2048) +-h <hostname> VM hostname (default: qemu) +-P <root-password> Root password. If not specified it will be prompted +-u <non-root-username> Username for the main non-root user +-p <non-root-user-password> Password for the non-root user. If not specified + it will be prompted +-z <timezone> System timezone (default: UTC) +-l <locale> System locale (default: en_US.UTF-8) +-M <arch-mirror-url> Arch Linux download mirror URL + (default: http://mirror.cj2.nl/archlinux/iso/latest/) + Consult https://archlinux.org/download/ for a + full list of the available download mirrors. +``` + +If you want to install an extra list of packages besides the default ones, then +specify them in a file named `PKGLIST` in the same directory as the disk image file. + +If you want to run a custom post-installation script after the core system has been +installed, then create a custom script named `post-install.sh` in the same directory +as the disk image file. + +#### Notes + +The keyring population process may currently (as of March 2022) take a long time. +This is a [known issue](https://www.reddit.com/r/archlinux/comments/rbjbcr/pacman_keyring_update_taking_too_long/). + +As a workaround, if you want to speed up the OS installation process, you can +temporarily disable pacman keyring checks upon package installation by +uncommenting the relevant lines in `src/helpers/install.sh` (function: +`install_os`). + +### Resizing an existing image + +```bash +qemu-img resize "$imgfile" +10G +``` + +### Create a COW (Copy-On-Write) image on top of a disk image + +```bash +qemu-img create -o backing_file="$imgfile",backing_fmt=raw -f qcow2 img1.cow +``` + +This is particularly useful if you want to have a "base" image and several customized +images built on it. diff --git a/src/actions/install.sh b/src/actions/install.sh new file mode 100755 index 0000000..753fef7 --- /dev/null +++ b/src/actions/install.sh @@ -0,0 +1,109 @@ +export default_imgfile=arch.img +export default_architecture=x86_64 +export default_disk_size=8G +export default_memory=2048 +export default_hostname=qemu +export default_root_password=root +export default_username=user +export default_user_password=password +export default_timezone=UTC +export default_locale=en_US.UTF-8 +export default_img_download_page='http://mirror.cj2.nl/archlinux/iso/latest/' +export isofile=archlinux-latest.iso + +imgfile= +architecture= +disk_size= +memory= +hostname= +root_password= +username= +user_password= +timezone= +locale="$default_locale" +img_download_page="$default_img_download_page" + +function usage() { + echo "Install an Arch Linux system on a QEMU disk image" + echo + echo "Usage: $(basename "$0") install [-o <output-disk-image>] [-a <architecture>] [-s <disk-size>]" + echo -e "\t[-m <memory>] [-h <hostname>] [-P <root-password>] [-u <non-root-username>]" + echo -e "\t[-p <non-root-user-password>] [-z <timezone>] [-l <locale>] [-M <arch-mirror-url>]" + echo + echo -e "-o <output-disk-image>\t\tPath of the output disk image (default: ./arch.img)" + echo -e "-a <architecture>\t\tTarget architecture (default: x86_64)" + echo -e "-s <disk-size>\t\t\tDisk size (default: 8G)" + echo -e "-m <memory>\t\t\tRAM size in KB (default: 2048)" + echo -e "-h <hostname>\t\t\tVM hostname (default: qemu)" + echo -e "-P <root-password>\t\tRoot password. If not specified it will be prompted" + echo -e "-u <non-root-username>\t\tUsername for the main non-root user" + echo -e "-p <non-root-user-password>\tPassword for the non-root user. If not specified it will be prompted" + echo -e "-z <timezone>\t\t\tSystem timezone (default: UTC)" + echo -e "-l <locale>\t\t\tSystem locale (default: en_US.UTF-8)" + echo -e "-M <arch-mirror-url>\t\tArch Linux download mirror URL (default: http://mirror.cj2.nl/archlinux/iso/latest/)" + echo -e "\t\t\t\tConsult https://archlinux.org/download/ for a full list of the available download mirrors." + echo + echo "If you want to install an extra list of packages besides the default ones, then" + echo "specify them in a file named PKGLIST in the same directory as the disk image file." + echo + echo "If you want to run a custom post-installation script after the core system has been" + echo "installed, then create a custom script named post-install.sh in the same directory as" + echo "the disk image file". + exit 1 +} + + +optstring=':o:a:s:m:h:P:u:p:z:l:M:' +[[ "$1" == '--help' ]] && usage + +while getopts ${optstring} arg; do + case ${arg} in + o) imgfile="${OPTARG}";; + a) architecture="${OPTARG}";; + s) disk_size="${OPTARG}";; + m) memory="${OPTARG}";; + h) hostname="${OPTARG}";; + P) root_password="${OPTARG}";; + u) username="${OPTARG}";; + p) user_password="${OPTARG}";; + z) timezone="${OPTARG}";; + l) locale="${OPTARG}";; + M) img_download_page="${OPTARG}";; + ?) + echo "Invalid option: -${OPTARG}" >&2 + usage;; + esac +done + +[ -z "$imgfile" ] && read -p "Output disk image file [$default_imgfile]: " imgfile +[ -z "$architecture" ] && read -p "Architecture [$default_architecture]: " architecture +[ -z "$disk_size" ] && read -p "Disk size [$default_disk_size]: " disk_size +[ -z "$memory" ] && read -p "Memory in KB [$default_memory]: " memory +[ -z "$hostname" ] && read -p "Hostname [$default_hostname]: " hostname +[ -z "$root_password" ] && read -sp "Root password [$default_root_password]: " root_password && echo +[ -z "$username" ] && read -p "Non-admin username [$default_username]: " username +[ -z "$user_password" ] && read -sp "Non-admin user password [$default_user_password]: " user_password && echo +[ -z "$timezone" ] && read -p "Timezone [$default_timezone]: " timezone +[ -z "$locale" ] && read -p "Locale [$default_locale]: " locale + +for var in imgfile \ + architecture \ + disk_size \ + memory \ + hostname \ + root_password \ + username \ + user_password \ + timezone \ + locale + do + default_var=default_$var + [ -z "${!var}" ] && declare ${var}=${!default_var} + export $var + done + +source "$srcdir/helpers/install.sh" +download_latest_arch_iso +create_disk_image +install_os + diff --git a/src/helpers/common.sh b/src/helpers/common.sh new file mode 100644 index 0000000..5b49080 --- /dev/null +++ b/src/helpers/common.sh @@ -0,0 +1,7 @@ +#!/bin/bash + +export GREEN='\033[0;32m' +export RED='\033[0;31m' +export WHITE='\033[0;37m' +export NORMAL='\033[0m' + diff --git a/src/helpers/install.sh b/src/helpers/install.sh new file mode 100644 index 0000000..eefbf6b --- /dev/null +++ b/src/helpers/install.sh @@ -0,0 +1,285 @@ +function download_latest_arch_iso() { + latest_iso=$( + curl -s "$img_download_page" | + grep -e '<a href="archlinux-.*\.iso">' | + head -1 | + sed -r -e 's/^.*<a href="(archlinux-.*\.iso)">.*/\1/' + ) + + if [ ! -f "$latest_iso" ]; then + echo -e "${GREEN}Downloading the latest Arch Linux ISO image${NORMAL}" + rm -f archlinux*.iso + curl -o "${latest_iso}" "${img_download_page}/${latest_iso}" + ln -sf "$latest_iso" "$isofile" + else + echo -e "${GREEN}Latest Arch Linux image already downloaded${NORMAL}" + fi +} + +function create_disk_image() { + # Create a backing COW image + # qemu-img create -o backing_file="$imgfile",backing_fmt=raw -f qcow2 img1.cow + + if [ ! -f "$imgfile" ]; then + echo -e "${GREEN}Creating base disk image${NORMAL}" + qemu-img create -f raw "$imgfile" $disk_size + else + echo -e "${RED}The base disk image file $imgfile already exists.${NORMAL}" + echo -e "${RED}Remove it and re-run this script if you intend to run a new fresh installation.${NORMAL}" + fi +} + +function _get_ssh_key_name() { + keyfiles=$(cat <<EOF +id_rsa +id_dsa +id_ecdsa +id_ed25519 +id_xmss +EOF +) + + while IFS= read -r keyfile; do + k="$HOME/.ssh/$keyfile" + if [ -f "$k" ]; then + echo "$k" + return + fi + done <<< "$keyfiles" + + echo -e "${RED}Can't find a valid SSH key under $HOME/.ssh${NORMAL}" + exit 1 +} + +function install_os() { + ssh_keyfile="$(_get_ssh_key_name)" + ssh_pubkey="$(cat "$ssh_keyfile.pub")" + ssh_privkey="$(cat "$ssh_keyfile")" + imgdir="$(cd "$(dirname "$imgfile")" && pwd)" + logfile="$imgdir/install.log" + pkgfile="$imgdir/PKGLIST" + post_install_script="$imgdir/post-install.sh" + post_install= + packages=$(cat <<EOF +sudo +curl +wget +dhcpcd +net-tools +netctl +openssh +EOF +) + + if [ -f "$pkgfile" ]; then + packages="$packages +$(cat "$pkgfile")" + fi + + [ -f "$post_install_script" ] && post_install="$(cat "$post_install_script")" + echo -e "${GREEN}Installing base operating system${NORMAL}" + echo -e "\tISO image: $isofile" + echo -e "\tDisk file: $imgfile" + echo -e "\tLog file: $logfile" + + echo "--- Log started at $(date)" > "$logfile" + expect <<EOF | tee -a "$logfile" + set prompt "*@archiso*~*#* " + set chroot_prompt "*root@archiso* " + set timeout -1 + spawn qemu-system-$architecture \ + -cdrom "$isofile" \ + -boot d \ + -cpu host \ + -enable-kvm \ + -m $memory \ + -smp 2 \ + -nographic \ + -drive file=$imgfile,format=raw + + match_max 100000 + + # Pass the console boot options + # and wait for the system to boot + expect "*Arch Linux install*" + send -- "\t" + expect "*archisobasedir*" + send -- " console=ttyS0,38400\r" + expect "archiso login: " + send -- "root\r" + expect \$prompt + + # Partition the disk + send -- "fdisk /dev/sda\r" + expect "Command (m for help): " + send -- "n\r" + expect "Select (default p): " + send -- "p\r" + expect "Partition number (1-4, default 1): " + send -- "\r" + expect "First sector*: " + send -- "\r" + expect "Last sector*: " + send -- "\r" + expect "Command (m for help): " + send -- "a\r" + expect "Command (m for help): " + send -- "w\r" + expect \$prompt + + # Create and mount the filesystem + send -- "mkfs.ext4 /dev/sda1\r" + expect \$prompt + send -- "mount /dev/sda1 /mnt\r" + expect \$prompt + + # Install the system + send -- "pacstrap /mnt base linux linux-firmware archlinux-keyring\r" + expect \$prompt + + # Generate the fstab file + send -- "genfstab -U /mnt >> /mnt/etc/fstab\r" + expect \$prompt + + # chroot to the newly installed system + send -- "arch-chroot /mnt\r" + expect \$chroot_prompt + + # Set the timezone and sync the clock + send -- "ln -sf /usr/share/zoneinfo/$timezone /etc/localtime\r" + expect \$chroot_prompt + send -- "hwclock --systohc\r" + expect \$chroot_prompt + + # Configure localization + send -- "echo $locale UTF-8 >> /etc/locale.gen\r" + expect \$chroot_prompt + send -- "locale-gen\r" + expect \$chroot_prompt + send -- "echo LANG=en_US.UTF-8 > /etc/locale.conf\r" + expect \$chroot_prompt + + # Set the hostname + send -- "echo $hostname > /etc/hostname\r" + expect \$chroot_prompt + + # Generate /etc/hosts + send -- "echo -e '127.0.0.1 localhost\\n::1 localhost' >> /etc/hosts\r" + expect \$chroot_prompt + + # Generate the initpcio + send -- "mkinitcpio -P\r" + expect \$chroot_prompt + + # Update the keyring + # This may currently currently take a long time + # see https://www.reddit.com/r/archlinux/comments/rbjbcr/pacman_keyring_update_taking_too_long/ + send -- "pacman-key --init\r" + expect \$chroot_prompt + send -- "pacman-key --populate archlinux\r" + expect \$chroot_prompt + send -- "pacman-key --refresh-keys\r" + expect \$chroot_prompt + + # As a workaround, you can temporarily disable signature check on pacman.conf + #send -- "cp /etc/pacman.conf /etc/pacman.conf.orig\r" + #expect \$chroot_prompt + #send -- "sed -i /etc/pacman.conf -r -e 's/^(SigLevel\\\\s*=\\\\s*).*$/\\\\1 Never/g'\r" + #expect \$chroot_prompt + + # Install extra packages + send -- {echo "$packages" | pacman -S --noconfirm -} + send -- "\r" + expect \$chroot_prompt + + # Install syslinux + send -- "pacman -S --noconfirm syslinux\r" + expect \$chroot_prompt + send -- "syslinux-install_update -i -a -m\r" + #expect \$chroot_prompt + #send -- "dd bs=440 count=1 conv=notrunc if=/usr/lib/syslinux/bios/mbr.bin of=/dev/sda\r" + expect \$chroot_prompt + send -- "sed -i /boot/syslinux/syslinux.cfg -e '1i SERIAL 0 115200'\r" + expect \$chroot_prompt + send -- "sed -i /boot/syslinux/syslinux.cfg -e 's|APPEND root=/dev/sda3|APPEND console=tty0 console=ttyS0,115200 root=/dev/sda1|g'\r" + expect \$chroot_prompt + + # Restore the original pacman.conf + send -- "mv /etc/pacman.conf.orig /etc/pacman.conf\r" + expect \$chroot_prompt + + # Set the root password + send -- "passwd\r" + expect "New password: " + send -- "$root_password\r" + expect "Retype new password: " + send -- "$root_password\r" + expect \$chroot_prompt + + # Create a non-admin user + send -- "useradd -d /home/$username -m $username\r" + expect \$chroot_prompt + send -- "passwd $username\r" + expect "New password: " + send -- "$user_password\r" + expect "Retype new password: " + send -- "$user_password\r" + expect \$chroot_prompt + + # Enable the dhcpcd service + send -- "ln -sf /usr/lib/systemd/system/dhcpcd.service /etc/systemd/system/multi-user.target.wants/dhcpcd.service\r" + expect \$chroot_prompt + + # Enable and configure the SSH daemon + send -- "ln -sf /usr/lib/systemd/system/sshd.service /etc/systemd/system/multi-user.target.wants/sshd.service\r" + expect \$chroot_prompt + send -- "cat <<_EOF_ >> /etc/ssh/sshd_config +RSAAuthentication yes +PubkeyAuthentication yes +_EOF_ +" + + expect \$chroot_prompt + + # Copy the user SSH key + send -- "mkdir -p /home/$username/.ssh\r" + expect \$chroot_prompt + send -- {echo "$ssh_pubkey" >> "/home/$username/.ssh/authorized_keys"} + send -- "\r" + expect \$chroot_prompt + send -- {echo "$ssh_privkey" > "/home/$username/.ssh/$(basename $ssh_keyfile)"} + send -- "\r" + expect \$chroot_prompt + send -- {chmod 0600 "/home/$username/.ssh/$(basename $ssh_keyfile)"} + send -- "\r" + expect \$chroot_prompt + send -- {echo "$ssh_pubkey" > "/home/$username/.ssh/$(basename $ssh_keyfile).pub"} + send -- "\r" + expect \$chroot_prompt + send -- {chown -R $username "/home/$username/.ssh"} + send -- "\r" + expect \$chroot_prompt + + # Run any post-install scripts + send -- {$post_install} + send -- "\r" + expect \$chroot_prompt + + # Clear the pacman cache + send -- "rm -rf /var/cache/pacman/pkg/*\r" + expect \$chroot_prompt + + # Exit and shutdown + send -- "exit\r" + expect \$prompt + send -- "umount /mnt\r" + expect \$prompt + send -- "shutdown -h now\r" + expect eof + + system {echo -e "\n${GREEN}Arch Linux system installed under ${imgfile}${NORMAL}"} +EOF + + echo "--- Log closed at $(date)" >> "$logfile" +} + diff --git a/src/qemu-arch b/src/qemu-arch new file mode 100755 index 0000000..1d664dd --- /dev/null +++ b/src/qemu-arch @@ -0,0 +1,31 @@ +#!/bin/bash + +function usage() { + actions=$(find "$srcdir/actions" -maxdepth 1 -name '*.sh' | xargs basename | cut -d. -f1) + cat <<EOF +Usage: $(basename "$0") <action> + +Run $(basename "$0") <action> --help for more details. +Available actions: + +$actions +EOF + + exit 1 +} + +if [ -L "$0" ]; then + export srcdir="$(dirname "$(readlink -f "$0")")" +else + export srcdir="$(cd "$(dirname "$0")" && pwd)" +fi + +source "$srcdir/helpers/common.sh" +action=$1 +shift + +case "$action" in + install) source "$srcdir/actions/install.sh";; + *) usage;; +esac +