diff --git a/control-vm.sh b/control-vm.sh new file mode 100755 index 0000000..bec5628 --- /dev/null +++ b/control-vm.sh @@ -0,0 +1,303 @@ +#!/bin/bash +# SPDX-License-Identifier: MIT + +# Virtual machine control script for QEMU/KVM VMs. +# Tested on Gentoo (~amd64/hardened) with qemu-6.0. + +# Version: 2021-09-28 +# Author: Nils Freydank +# License: MIT + +# TODO (not in specific order): +# 1. Fix different ARCHes (-cpu host won't work for non-x86) +# 2. qemu-img commit +# 3. clipboard support via spice +# 4. Support boot from CD for first installations. +# 5. query status +# 6. save and stop in a single command? +# same for restore: stop, restore, cont(inue) +# 7. shared dir without smb, but via FUSE +# 8. bash and zsh-completion +# 9. set up a git repo + +# === Installation === +# The following packages are necessary on Gentoo/Linux: +# app-emulation/qemu net-misc/socat app-emulation/virt-viewer +# To use an EFI for the clients you will need also sys-firmware/edk2-ovmf +# which is pulled into the depgraph by USE="binary" on qemu. + +# === How to use this script === +# Create a directory to store your VM images and metadata. +# Let it be readable and writeable for the user who executes QEMU. +# Copy this script into that directory. +# For each Guest do the following: +# Create a directory named exactly as the guest (case sensitive). +# Create an qcow2 image named virtual-machine.img inside +# the newly created directory. +# Create a configuration file named exactly as the guest plus a suffix '.sh'. +# Fill in the data from the example below into the configuration file. +# Control the vm by calling: ./ , +# e.g. ./control-vm.sh Gentoo-guest start +# or ./control-vm.sh Gentoo-guest connect +# or ./control-vm.sh Gentoo-guest shudown +# +# You should now have a setup like: +# ${HOME}/vm/ +# ${HOME}/vm/control-vm.sh +# ${HOME}/vm/Gentoo-guest/virtual-machine.img +# ${HOME}/vm/Gentoo-guest.sh + +# === Configuration of the guest === +# Create a file with the guest's parameters and name it same as the "GUEST_NAME" +# plus a "sh" suffix, so we can easily assume the name later. +# +# cat << _EOF > "${GUEST_NAME}.sh" +# # Provide the GUEST_NAME which is used in further configuration. +# GUEST_NAME="Guest-OS" +# # Configure the number of CPUs. +# CPUS="2" +# CPU_CORES="8" +# MEMORY="16384" +# NET="True" +# SOUND="True" +# SSH_PORT="" +# # Configure any of the arches QEMU supports in QEMU's notation. +# Leave it empty or omit it for x86_64. +# ARCH="" +# # Disable EFI and enable BIOS fallback mode with unsetting EFI_MACHINE. +# #EFI="" +# Enable the SPICE socket. +# UI="True" +# CLIPBOARD="True" +# _EOF + +# Provide a sanity check first. +[[ -z "${1}" ]] && echo "Please provide a guest name!" && exit 1 + +# === Define default values === +CLIPBOARD="${CLIPBOARD:-}" +CPUS="${CPUS:-16}" +CPU_CORES="${CPU_CORES:-1}" +EFI="${EFI:-True}" +GUEST_NAME="${1}" +LIVE_ONLY="${LIVE_ONLY:-}" +MACHINE="${MACHINE:-pc-q35-6.1}" +MEMORY="${MEMORY:-16384}" +NET="${NET:-}" +NETDEV_NAME="vmnic-${GUEST_NAME}" +SOUND="${SOUND:-}" +SSH_PORT="${SSH_PORT:-}" +UI="${UI:-}" +# Define paths. +# Note: Sort in alphabetical order as long as inheritance is respected. +BASE_PATH="${BASE_PATH:-${HOME}/vm/${GUEST_NAME}}" +GUEST_CONFIG_FILE="${HOME}/vm/${GUEST_NAME}.sh" +IMAGE_PATH="${IMAGE_PATH:-${BASE_PATH}/virtual-machine.img}" +ISO_PATH="${ISO_PATH:-${BASE_PATH}/../installer/${GUEST_NAME}.iso}" +MONITOR_SOCKET="${MONITOR_SOCKET:-${BASE_PATH}/monitor.socket}" +PID_FILE="${PID_FILE:-${BASE_PATH}/qemu.pid}" +QMP_SOCKET="${QMP_SOCKET:-${BASE_PATH}/qmp.socket}" +SNAPSHOT_INFO_FILE="${SNAPSHOT_INFO_FILE:-${BASE_PATH}/last-snapshot-info}" +SPICE_SOCKET="${SPICE_SOCKET:-${BASE_PATH}/spice.socket}" + +# === Source the guest configuration to overwrite defaults if necessary. === +if [[ -f "${GUEST_CONFIG_FILE}" ]]; then + source "${GUEST_CONFIG_FILE}" +else + echo "No configuration found. Aborting." + exit 2 +fi + +# === Define a base and glue everything together. === +QEMU_COMMON_ARGS=( + # Provide a pretty name for the guest. + -name "${GUEST_NAME}" + # Configure some seccomp mode 2 filters. + -sandbox on,obsolete=deny,elevateprivileges=allow,spawn=allow,resourcecontrol=allow + # Enable KVM and hardware acceleration. + -enable-kvm + # Use the same CPU as the host has for maximum performoance and configure + # the amount of memory and CPU cores as configured per-host. + -cpu host + -smp cpus="${CPUS}",maxcpus="${CPUS}",cores="${CPU_CORES}" + -m "${MEMORY}" + # Detach from the current shell and monitor by pid file and a unix socket. + -daemonize + -pidfile "${PID_FILE}" + -monitor unix:"${MONITOR_SOCKET}",server,nowait + -qmp-pretty unix:"${QMP_SOCKET}",server,nowait +) +QEMU_DISK_ARGS=( -drive id=hd0,media=disk,if=virtio,file="${IMAGE_PATH}",discard=on,aio=io_uring,cache=none ) +QEMU_DISPLAY_NONE_ARGS=( -vga none -display none ) +QEMU_DISPLAY_VIRTIO_ARGS=( -vga virtio ) +QEMU_DISPLAY_STD_ARGS=( -vga std ) +QEMU_DISPLAY_SPICE_ARGS=( -spice unix=on,addr="${SPICE_SOCKET}",disable-ticketing=on,gl=on ) +QEMU_CLIPBOARD_SPICE_ARGS=( + -device virtio-serial-pci + -device virtserialport,chardev=spicechannel0,name=com.redhat.spice.0 + -chardev spicevmc,id=spicechannel0,name=vdagent +) +QEMU_EFI_ARGS=( + -M "${MACHINE}",accel=kvm + -drive file=/usr/share/edk2-ovmf/OVMF_CODE.fd,if=pflash,format=raw,unit=0,readonly=on +) +QEMU_BIOS_ARGS=( + -M "${MACHINE}",accel=kvm +) +QEMU_ISO_ARGS=( -cdrom "${ISO_PATH}" ) +QEMU_NET_ARGS=( + -device virtio-net,netdev="${NETDEV_NAME}" + -netdev user,id="${NETDEV_NAME}" +) +QEMU_SOUND_ARGS=( -device ich9-intel-hda -device hda-duplex ) +QEMU_SSH_ARGS=( + -device virtio-net,netdev="${NETDEV_NAME}" + -netdev user,id="${NETDEV_NAME}",hostfwd=tcp:127.0.0.1:"${SSH_PORT}"-:22 +) + +# === Start the final QEMU parameter array. === +QEMU_ARGS=( "${QEMU_COMMON_ARGS[@]}" ) +if [[ "${EFI}" ]]; then + QEMU_ARGS+=( "${QEMU_EFI_ARGS[@]}" ) +else + QEMU_ARGS+=( "${QEMU_BIOS_ARGS[@]}" ) +fi +# The following block checks only agains live vs non-live. +if [[ "${LIVE_ONLY}" ]]; then + QEMU_ARGS+=( + "${QEMU_ISO_ARGS[@]}" + # d: CD-ROM + -boot d + ) +else + QEMU_ARGS+=( + "${QEMU_DISK_ARGS[@]}" + # c: hard disk + -boot c + ) +fi +# +[[ -n "${NET}" && -z "${SSH_PORT}" ]] && QEMU_ARGS+=( "${QEMU_NET_ARGS[@]}" ) +[[ -n "${SOUND}" ]] && QEMU_ARGS+=( "${QEMU_SOUND_ARGS[@]}" ) +[[ -n "${SSH_PORT}" ]] && QEMU_ARGS+=( "${QEMU_SSH_ARGS[@]}" ) +if [[ -n "${UI}" ]]; then + case "${UI}" in + std|stdvga ) QEMU_ARGS+=( "${QEMU_DISPLAY_STD_ARGS[@]}" );; + *) QEMU_ARGS+=( "${QEMU_DISPLAY_VIRTIO_ARGS[@]}" );; + esac + QEMU_ARGS+=( "${QEMU_DISPLAY_SPICE_ARGS[@]}" ) + # Only check for clipboard support here as clipoard w/o UI doesn't make + # sense in this context. + if [[ -n "${CLIPBOARD}" ]]; then + QEMU_ARGS+=( "${QEMU_CLIPBOARD_SPICE_ARGS[@]}" ) + fi +else + QEMU_ARGS+=( "${QEMU_DISPLAY_NONE_ARGS[@]}" ) +fi + +# === Define functions for the actual QEMU controlling. === +start_qemu(){ + if [[ ! -f "${PID_FILE}" ]]; then + echo "Starting VM ${GUEST_NAME}." + # Define the default architecture. + [[ -z "${ARCH}" ]] && ARCH="x86_64" + # Check the existance of the QEMU binary. + if [[ -x "/usr/bin/qemu-system-${ARCH}" ]]; then + /usr/bin/qemu-system-"${ARCH}" "${QEMU_ARGS[@]}" + else + echo "QEMU has target ${ARCH} not installed." + echo "Aborting." + exit 2 + fi + else + echo "VM ${GUEST_NAME} seems to be running." + echo "See ${PID_FILE} for the process ID." + fi +} +shutdown_qemu(){ + if [[ -f "${PID_FILE}" ]]; then + echo "Requesting shutdown for VM ${GUEST_NAME}." + echo "system_powerdown" | socat - unix-connect:"${MONITOR_SOCKET}" + else + echo "Looks as VM ${GUEST_NAME} is already stopped." + fi +} +stop_qemu(){ + if [[ -f "${PID_FILE}" ]]; then + echo "Forcing shutdown of VM ${GUEST_NAME}." + echo "quit" | socat - unix-connect:"${MONITOR_SOCKET}" + else + echo "Looks as VM ${GUEST_NAME} is already stopped." + fi +} +connect_to_vm(){ + if [[ -f "${PID_FILE}" ]]; then + echo "Opening a GUI for ${GUEST_NAME}." + local REMOTE_VIEWER_OPTS=( + --title "${GUEST_NAME}" + --auto-resize=always + "spice+unix://${SPICE_SOCKET}" + ) + # Send the viewer into background and drop + # all annoying GTK error messages to /dev/null. + ( remote-viewer "${REMOTE_VIEWER_OPTS[@]}" &> /dev/null ) & + # Alternativly run spicy which is part of spice-gtk. + # ( spicy --uri="spice+unix:///${SPICE_SOCKET}" &> /dev/null ) & + else + echo "Looks as VM ${GUEST_NAME} is not running." + fi +} +save_qemu(){ + if [[ -f "${PID_FILE}" ]]; then + SNAPSHOT_NAME="snap$(date --utc +%s)" + echo "Saving state of VM ${GUEST_NAME}." + echo "savevm ${SNAPSHOT_NAME}" | socat - unix-connect:"${MONITOR_SOCKET}" + echo "${SNAPSHOT_NAME}" > "${SNAPSHOT_INFO_FILE}" + else + echo "Guest ${GUEST_NAME} is not running." + fi +} +pause_qemu(){ + if [[ -f "${PID_FILE}" ]]; then + echo "Pausing the VM ${GUEST_NAME}." + echo "stop" | socat - unix-connect:"${MONITOR_SOCKET}" + else + echo "Guest ${GUEST_NAME} not running, nothing to pause." + fi +} +restore_qemu(){ + if [[ -f "${PID_FILE}" ]]; then + SNAPSHOT_NAME="$(cat "${SNAPSHOT_INFO_FILE}")" + echo "Restore state of VM ${GUEST_NAME}." + pause_qemu + echo "loadvm ${SNAPSHOT_NAME}" | socat - unix-connect:"${MONITOR_SOCKET}" + resume_qemu + else + echo "Guest ${GUEST_NAME} is not running, nothing to restore." + fi +} +resume_qemu(){ + if [[ -f "${PID_FILE}" ]]; then + echo "Resuming the VM ${GUEST_NAME}." + echo "cont" | socat - unix-connect:"${MONITOR_SOCKET}" + else + echo "Guest ${GUEST_NAME} is not running, nothing to resume." + fi +} + +# === Parse the user input. === +# "$1 != nothing" is already checked earlier. +case "${2}" in + start) start_qemu;; + shutdown) shutdown_qemu;; + stop) stop_qemu;; + connect) connect_to_vm;; + save) save_qemu;; + restore) restore_qemu;; + pause) pause_qemu;; + resume) resume_qemu;; + *) echo "Unknown Operation!" + echo "Use one of: start | shutdown | stop | connect | save | restore | pause | resume" + echo "Note that stop means a forced stop.";; +esac +# vim:fileencoding=utf-8:ts=4:syntax=bash:expandtab