303 lines
10 KiB
Bash
Executable File
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-6.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 '.sh'.
|
|
# 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/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
|