#!/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-10-08 # 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. don't rely on the PID to check if the VM is running, query socket? # 10. shellsheck? # === 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.0}" 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}" # Disable some less document user configuration loading. -no-user-config # 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}",aio=io_uring,cache=none,cache-size=16M,discard=on ) 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=off ) 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 # e.g. as 'spicy --uri="spice+unix://${SPICE_SOCKET}"' # or any other client like remmina. Note that _that_ # one doesn't support unix sockets yet (as of 2021-09-30): # https://gitlab.com/Remmina/Remmina/-/issues/1677 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