Since migrating BackupPC to a VM I have not been doing off-site backups (since I have been working from home full-time). Today I had to visit the office to retrieve some essential adaptors for my work laptop, and while I was there I grabbed my off-site backups disks. Now I have done this, I need to figure out how to pass the device through to my VM in order to update the oldest backup (which has not been updated since December 2019!).

At first I thought this might be easy to configure with KVM/QEMU so that it could be automatically passed through when the device is attached but it seems that devices are automatically attached on VM boot and not again. They can be hot-(un)plugged but only with explicit commands and an XML file - so my plan is to use UDEV to trigger running a script on attach and detach which will automatically pass it on to the VM.

The script

In order to attach/detach the device to the VM, I need to create a XML file with the correct device information which will then be used to (dis)connect it with the VM:

<hostdev mode 'subsystem' type='usb'>
  <source>
    <vendor id='0x0D7A'/>
    <product id='0x0001'/>
  </source>
</hostdev>

The <source></source> can contain either a <vendor />/<product /> combination (as in the example, describing a specific device by its product description) or <bus />/<device /> combination (describing a device by where it is attached to the system). In my case, I only care about the specific disks I use for the off-site backups and not which port they are plugged into so the former is my approach however I will write the script to support either approach.

The finished (version 1) script is as follows, and deployed to /usr/local/sbin/usb-device-to-vm on the host machine by SaltStack:

#!/bin/bash

usage() {
        [[ -z $1 ]] || echo $1 >&2
        cat - >&2 <<EOF
Usage: $0 -m vm [-a] [-r] [-v vendor_id -p product_id] [-b bus_id -d device_id]

Options to specify the VM and action:

-m vm: Specify the machine (VM) to attach/detach the device to/from (required)
-a: attach the device to the vm (-a or -r required)
-r: detach(remove) the device from the vm (-a or -r required)

One of the following pairs specifing the device (can be found from lsusb) is
required:

-v vendor_id -p product_id: Specify the device by vendor and product
-b bus_id -d device_id: Specify the device by bus and device number

EOF
        [[ -z $2 ]] || exit $2
}

while getopts "m:arv:p:b:d:" OPT
do
        case $OPT in
                m)
                        TARGET_VM=$OPTARG
                        ;;
                a)
                        [[ -z $MODE ]] || usage "Only specify one of -a or -r once" 1
                        MODE=attach
                        ;;
                r)
                        [[ -z $MODE ]] || usage "Only specify one of -a or -r once" 1
                        MODE=detach
                        ;;
                v)
                        VENDOR_ID=$OPTARG
                        ;;
                p)
                        PRODUCT_ID=$OPTARG
                        ;;
                b)
                        BUS_ID=$OPTARG
                        ;;
                d)
                        DEVICE_ID=$OPTARG
                        ;;
                *)
                        usage "Unknown option: $OPT" 1
                        ;;
        esac
done

# Required args - vm and mode
[[ -z $TARGET_VM ]] && usage "Must specify a VM (-m)" 1
[[ -z $MODE ]] && usage "Must specify -a or -r" 1

# Must have exact one pair of BUS/DEVICE or VENDOR/PRODUCT
if [[ -z $BUS_ID ]] && [[ -z $DEVICE_ID ]]
then
        # Bus and device blank, so require vendor & product
        if [[ -z $VENDOR_ID ]] || [[ -z $PRODUCT_ID ]]
        then
                # One (or both) or vendor and product not specified
                usage "Must specify one of bus & device OR vendor & product" 1
        fi
else
	# One (or both) of bus/device specified
        if [[ ! -z $VENDOR_ID ]] || [[ ! -z $PRODUCT_ID ]] || \
                [[ -z $BUS_ID ]] || [[ -z $DEVICE_ID ]]
        then
                # One of vendor/product also supplied OR *one* (since they are not both
                # empty, from above) of bus/device is missing
                usage "Must specify one of bus & device OR vendor & product" 1
        fi
fi

# Make the XML file
XML_FILE=$( mktemp )
cat >$XML_FILE <<EOF
<hostdev mode='subsystem' type='usb' managed='yes'>
  <source>
EOF
if [[ -n $VENDOR_ID ]]
then
        # vendor/product route
        echo "    <vendor id='$VENDOR_ID'/>" >>$XML_FILE
        echo "    <product id='$PRODUCT_ID'/>" >>$XML_FILE
else
        # bus/device route
        echo "    <bus id='$BUS_ID'/>" >>$XML_FILE
        echo "    <device id='$DEVICE_ID'/>" >>$XML_FILE
fi
cat >>$XML_FILE <<EOF
  </source>
</hostdev>
EOF

# Actually do the action
virsh $MODE-device $TARGET_VM $XML_FILE --live
exit_stat=$? # Save the virsh exit status - we'll use it as the script's status

# Clean-up
rm $XML_FILE
unset XML_FILE

exit $exit_stat

Which can then be used to attach and detach devices:

$ sudo usb-device-to-vm -m starfleet-archives -a -v 0x0bc2 -p 0x231a
Device attached successfully

$ sudo usb-device-to-vm -m starfleet-archives -r -v 0x0bc2 -p 0x231a
Device detached successfully

UDEV

The second piece of the puzzle is to get UDEV to attach and detach the device when it is attached and detached from the host. To do this we just need to create a UDEV rules file to do this - I created a rules file per VM that required USB devices with all of the rules for the USB devices for that VM (auto-generated by SaltStack from pre-vm configuration data). For example, /etc/udev/rules.d/90-libvirt-starfleet-archives-usb.rules:

ACTION=="add",SUBSYSTEM=="usb",ENV{PRODUCT}=="bc2/231a/*",RUN+="/usr/local/sbin/usb-device-to-vm -m starfleet-archives -a -v 0x0bc2 -p 0x231a"
ACTION=="remove",SUBSYSTEM=="usb",ENV{PRODUCT}=="bc2/231a/*",RUN+="/usr/local/sbin/usb-device-to-vm -m starfleet-archives -r -v 0x0bc2 -p 0x231a"

Use ENV{BUSNUM} and ENV{DEVNUM} if using bus and device instead of product and vendor.

After adding the rules, must tell udev to reload them:

$ sudo udevadm control --reload-rules

You can find out the properties you can access via ENV:

$ sudo udevadm monitor --property --udev

In the past people recommended matching on ENV{ID_VENDOR_ID}/ENV{ID_MODEL_ID} and in other cases ATTRS{idVendor} and ATTRS{idProduct} however the former are not available at remove and the latter were sysfs properties that are not available on more recent versions of UDEV - see Stack OverFlow and other people’s BLog entries for more background.

Create backup

Once this is all in place, plugging in the USB disk automatically passes it straight through to the VM and the existing off-site backup script works from the VM without modification.