ProvmoxVE USB passthrough and LUKS unlocking
This post started as an attempt to use a USB drive to unlock LUKS encrypted LVM containing, amongst others, the root filesystem. My idea was to be able to use a USB key to unlock the disks of my Proxmox Virtual Environment cluster, falling back to passphrase if the key was missing. This proved to be more complicated than anticipated, although solvable through the use of a custom script I worried about the fragility of this so I abandoned the idea.
Passing USB devices through to a VM in ProxmoxVE
In order to test this setup, I built a basic VM using my established automated install process in my Proxmox Virtual Environment. To test USB key unlocking, I needed to pass a USB key device through to the VM - I will also need to pass through a USB device when I migrate my BackupPC system from its current VM, which uses an external Icy Box IB-RD3620SU3 enclosure that I bought in March 2023 in anticipation of this migration.
Proxmox has a comprehensive wiki page on using USB Devices in Virtual Machines.
Passing through the USB device can be done using the qm
command and, at the time of writing, is not supported by Ansible’s community.general.proxmox_kvm
module:
--usb[n] [[host=]<HOSTUSBDEVICE|spice>] [,mapping=<mapping-id>] [,usb3=<1|0>]
Configure an USB device (n is 0 to 4, for machine version >= 7.1 and ostype l26 or windows > 7, n can be up to 14).
Locating the VM
The qm
command needs to be executed on the host the VM is on. Looking up which host a VM is actually on can be done with a pvesh
command I found on the Proxmox forum:
It would be absolutely monumental fantastic if there were a way to export all VM’s and Containers running in a ProxMox cluster. Export to CSV, or even print on screen in console a list of VMs. I think there was a console command in the past but I can’t figure it out. … Hey,
pvesh get /cluster/resources --type vm --output-format [text, json, json-pretty, yaml]
should be what you’re looking for.
I began by using whoami
(for reasons that will become apparent) on each ProxmoxVE host in order to identify those which are up and contactable and then selected one at random for the pvesh
command execution:
- name: Identify a working Proxmox Host to use
delegate_to: '{{ item }}'
ansible.builtin.command: /usr/bin/whoami
register: proxmox_host_test
loop: '{{ groups.proxmox_virtual_environment_hosts }}'
loop_control:
label: '{{ item }}'
ignore_unreachable: true
changed_when: false # Read-only operation
check_mode: false # Run in checkmode
- name: Select random Proxmox Host from selection
ansible.builtin.set_fact:
proxmox_host_result: >-
{{
proxmox_host_test.results
| selectattr('unreachable', 'undefined')
| ansible.builtin.random
}}
Once a random ProxmoxVE host has been chosen, I use that to get the list of VMs in the cluster which includes the nodes they are on then filter the list for the current target VM, ensure that only one matching VM is found (as a VM’s name is not guaranteed to be unique) before promoting the one result in the list to be directly assigned to the fact proxmox_vm_info
. Using whoami
as the test command allows the output to be used to lookup the appropriate “become” (sudo
) password for that host form the vault:
# XXX There seems to be a race condition with this on VM creation
- name: VM resources are known
delegate_to: '{{ hostvars[inventory_hostname].proxmox_host_result.item }}'
run_once: true
become: true
ansible.builtin.command: /usr/bin/pvesh get /cluster/resources --type vm --output-format=json
register: pvesh_get_vms_output
vars:
ansible_become_password: >-
{{
lookup(
'community.hashi_vault.vault_read',
'kv/hosts/' + hostvars[inventory_hostname].proxmox_host_result.item + '/users/' + hostvars[inventory_hostname].proxmox_host_result.stdout
).data.password
}}
changed_when: false # Read-only operation
check_mode: false # Run in checkmode
- name: Proxmox VM info is known
ansible.builtin.set_fact:
proxmox_vm_info: >-
{{
pvesh_get_vms_output.stdout
| from_json
| selectattr('name', 'eq', inventory_hostname)
}}
- name: One VM has been found
ansible.builtin.assert:
that: proxmox_vm_info | length == 1
- name: Flatten Proxmox VM info
ansible.builtin.set_fact:
proxmox_vm_info: '{{ proxmox_vm_info | first }}'
Current USB configuration
Once the VM host is available, the VM config can be retrieved and the current USB devices extracted in to a dictionary mapping usb device name to config:
- name: VM config is known
delegate_to: '{{ proxmox_vm_info.node }}'
become: true
ansible.builtin.command: "/usr/sbin/qm config {{ hostvars[inventory_hostname].proxmox_vm_info.vmid | quote }}"
vars:
ansible_become_password: >-
{{
lookup(
'community.hashi_vault.vault_read',
'kv/hosts/' + hostvars[inventory_hostname].proxmox_vm_info.node + '/users/' + hostvars[inventory_hostname].proxmox_host_test.results | selectattr('item', 'eq', hostvars[inventory_hostname].proxmox_vm_info.node) | map(attribute='stdout') | first
).data.password
}}
register: qm_config_out
changed_when: false # Read-only operation
check_mode: false # Run in checkmode
- name: USB devices are known
ansible.builtin.set_fact:
proxmox_vm_usb_devices: >-
{{
qm_config_out.stdout_lines
| select('match', 'usb[0-9]+: ')
| map('split', ': ', 1)
| community.general.dict
}}
Working out free USB devices
To identify which slots are currently available, I loop over the sequence of all possible device names (0 to 15) and check which are configured (i.e. in use). I originally tried to use Ansible’s ansible.builtin.sequence lookup but was struggling to make it work, which led me to a bug report, which included a comment, that said one should just use Jinja’s range function instead:
While this should probably be fully fixed for consistency’s sake, the sequence lookup is somewhat of a relic from the bad old days and Jinja’s standard range should generally be preferred in modern Ansible content.
# In case none are available, make sure initialised.
- name: USB devices is initialised to empty list
ansible.builtin.set_fact:
proxmox_vm_usb_slots: []
# From the `qm` man page:
# > --usb[n] [[host=]<HOSTUSBDEVICE|spice>] [,mapping=<mapping-id>] [,usb3=<1|0>]
# > Configure an USB device (n is 0 to 4, for machine version >= 7.1 and ostype l26 or windows > 7, n can be up to 14).
- name: List of available USB devices is known
ansible.builtin.set_fact:
proxmox_vm_usb_slots: >-
{{
proxmox_vm_usb_slots
+
[usb_slot_name]
}}
vars:
usb_slot_name: usb{{ item }}
loop: "{{ range(0, 15) }}"
when: "usb_slot_name not in proxmox_vm_usb_devices.keys()"
Removing devices not specified in inventory
I decided that these VMs USB devices will be fully managed by Ansible, so any USB devices not in the inventory will be assumed to have been removed (as opposed to manually added directly in ProxmoxVE) and therefore removed. I used the qm
command to do this, and also added any freed up USB device slots to the start of the list of available devices for reuse when adding new/changed devices:
- name: USB devices not specified in inventory are removed
delegate_to: '{{ proxmox_vm_info.node }}'
become: true
ansible.builtin.command: "/usr/sbin/qm set {{ hostvars[inventory_hostname].proxmox_vm_info.vmid | quote }} --delete {{ item.key }}"
vars:
ansible_become_password: >-
{{
lookup(
'community.hashi_vault.vault_read',
'kv/hosts/' + hostvars[inventory_hostname].proxmox_vm_info.node + '/users/' + hostvars[inventory_hostname].proxmox_host_test.results | selectattr('item', 'eq', hostvars[inventory_hostname].proxmox_vm_info.node) | map(attribute='stdout') | first
).data.password
}}
loop: '{{ proxmox_vm_usb_devices | dict2items }}'
when: item.value not in proxmox_vm.usb_devices | default([])
- name: Freed device slots are prepended to available slot list
ansible.builtin.set_fact:
proxmox_vm_usb_slots: >-
{{
[item.key]
+
proxmox_vm_usb_slots
}}
loop: '{{ proxmox_vm_usb_devices | dict2items }}'
when: item.value not in proxmox_vm.usb_devices | default([])
Add new USB devices
Finally, I built a list of USB devices specified in the inventory but not configured according qm
then add them (after verifying that there are enough USB device slots available for them):
# Ensure initialised, in case there are none
- name: List of missing USB devices is initialised to empty list
ansible.builtin.set_fact:
proxmox_vm_missing_usb_devices: []
- name: List of missing USB devices is known
ansible.builtin.set_fact:
proxmox_vm_missing_usb_devices: >-
{{
proxmox_vm_missing_usb_devices
+
[item]
}}
loop: '{{ proxmox_vm.usb_devices | default([]) }}'
when: item not in proxmox_vm_usb_devices.values()
- name: There are enough USB slots available
ansible.builtin.assert:
that: proxmox_vm_usb_slots | length >= proxmox_vm_missing_usb_devices | length
- name: Missing USB devices specified in inventory are added
delegate_to: '{{ proxmox_vm_info.node }}'
become: true
ansible.builtin.command: "/usr/sbin/qm set {{ hostvars[inventory_hostname].proxmox_vm_info.vmid | quote }} --{{ item[1] }} {{ item[0] }}"
vars:
ansible_become_password: >-
{{
lookup(
'community.hashi_vault.vault_read',
'kv/hosts/' + hostvars[inventory_hostname].proxmox_vm_info.node + '/users/' + hostvars[inventory_hostname].proxmox_host_test.results | selectattr('item', 'eq', hostvars[inventory_hostname].proxmox_vm_info.node) | map(attribute='stdout') | first
).data.password
}}
# Shortest list determines length with zip, so this is fine
loop: '{{ proxmox_vm_missing_usb_devices | zip(proxmox_vm_usb_slots) }}'
USB unlocking of LUKS
The idea I envisaged was to be able to unlock my LUKS encrypted ProxmoxVE hosts with the current per-host passphrase (stored in HashiCorp Vault) or a USB key physically connected to the host. My thinking was that I might move the Vault onto the ProxmoxVE hosts in the future, increasing resilience from the current single-host deployment. However, this will create a catch-22 if all of the hosts are ever turned off as the passphrase for unlocking the encryption would only exist in a vault that can only be accessed by unlocking the hosts - hence the need for an alternative unlock method.
I tested this by creating a VM in my ProxmoxVE cluster, passing through a USB key and setting up crypttab to use the supplied passdev script to unlock. The USB key’s device ID can be found by running lsusb
and it happened that my freebie USB memory stick, which I was using, was the same as the example on the Proxmox Wiki (058f:6387
).
Creating a key then adding it to be allowed to unlock the encryption was straight forward:
$ mkfs -t ext4 /dev/sdb1
$ mount /dev/sdb1 /mnt
$ dd if=/dev/urandom of=/mnt/luks.key bs=1M count=4
$ cryptsetup luksAddKey /dev/sda3 /mnt/luks.key
$ umount /mnt
I then modified /etc/crypttab
, replacing none
with the device path of the USB drive by UUID (determined by running blkid
), the path of the key file and adding the passdev
key script:
sda3_crypt UUID=.... /dev/disk/by-uuid/...:/luks.key luks,discard,keyscript=passdev
Finally, the kernel’s initial ram filesystem needed updating with the new settings:
$ update-initramfs -u
This worked to unlock the system when the USB key is attached but the passdev script does not fall back to passphrase unlocking if the disk is not attached, so with the keyscript set to passdev the only way to unlock the system is with the USB key - which is not what I wanted (if the vault is available, I want to be able to utilise my existing remote unlock script (e.g. for system updates)).
Researching online, I found various suggestions utilising custom scripts for USB drive unlocking and falling back to asking for a passphrase. One of the ones I liked the best was an unlock script that uses underlying OS-provided scripts, which I thought about combining with detecting if the USB key is present as some of the alternatives do. However, I worried about the fragility of using a custom script with something that might not be regularly tested (e.g. if cryptsetup gets updated, I might not detect if USB unlock became broken until it was too late). So, instead, I added a static additional unlock passphrase that I stored in the vault (for the purpose of provisioning) and my Keepass password safe (for the purpose of remote unlocking without the vault).
Adding a static passphrase
I randomly generated a >30 character passphrase and stored it in the vault. I then added a group called static_luks_passphrase
, for hosts that would be configured with the static passphrase and added my ProxmoxVE host group (and test VM) to it:
static_luks_passphrase:
hosts:
secureboot-test:
children:
proxmox_virtual_environment_hosts:
The tasks to add the static passphrase is modelled on my existing playbook for setting the per-host unlock passphrase during bootstrap:
- name: Static LUKS unlock passphrase
hosts: static_luks_passphrase
tasks:
- name: Block device and filesystem types are known
ansible.builtin.command: /usr/bin/lsblk -o PATH,FSTYPE -J
register: block_path_type_json
# Read only operation - never changes anything
changed_when: false
- name: Encrypted block devices are known
ansible.builtin.set_fact:
encrypted_block_devices: >-
{{
(block_path_type_json.stdout | from_json).blockdevices
|
selectattr('fstype', 'eq', 'crypto_LUKS')
|
map(attribute='path')
}}
- name: Only one encrypted device exists
ansible.builtin.assert:
that:
- encrypted_block_devices | length == 1
- name: Encrypted device name is stored
ansible.builtin.set_fact:
encrypted_block_device: "{{ encrypted_block_devices | first }}"
- name: Static passphrase is set
become: true
community.crypto.luks_device:
new_passphrase: "{{ lookup('community.hashi_vault.vault_read', 'kv/luks/static_passphrase').data.passphrase }}"
passphrase: "{{ lookup('community.hashi_vault.vault_read', 'kv/hosts/' + inventory_hostname + '/luks/passphrase').data.passphrase }}"
device: "{{ encrypted_block_device }}"