0
0
control-vm/control-vm.sh

303 lines
10 KiB
Bash
Executable File

#!/bin/bash
# SPDX-License-Identifier: MIT
# Virtual machine control script for QEMU/KVM VMs.
# Tested on Gentoo (~amd64/hardened) with qemu-9.0
#
# You can find the newest version probably on public git repo:
# https://git.holgersson.xyz/nfr/control-vm
# If that link does not work, feel free to drop me an email.
# Version: 2024-05-05
# Author: Nils Freydank <nils.freydank@datenschutz-ist-voll-doof.de>
# License: MIT
# === Setup ===
# On Gentoo/Linux the following packages are necessary:
# app-emulation/qemu net-misc/socat app-emulation/virt-viewer
# -- the latter one with USE="vnc spice" --
# plus optionally
# sys-firmware/edk2-ovmf via USE="binary" for qemu if EFI is used.
# Obviously, bash is also necessary ;)
# === 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 '.bash'.
# Fill in the data from the example below into the configuration file.
# Control the vm by calling: ./<name-of-this-script.sh> <guest> <operation>,
# 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-1/virtual-machine.img
# ${HOME}/vm/Gentoo-guest-1/virtual-machine.bash
# === Configuration of the guest ===
# Create a file with the guest's parameters and name it same as the "GUEST_NAME"
# plus a ".bash" suffix, so we can easily assume the name later.
#
# cat << _EOF > "${GUEST_NAME}/virtual-machine.bash"
# # 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-9.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}/virtual-machine.bash"
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