Add USB device passthrough implementation plan
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
a5582f4b29
commit
d4f4f71ed7
1 changed files with 751 additions and 0 deletions
751
docs/plans/2026-03-17-usb-passthrough-plan.md
Normal file
751
docs/plans/2026-03-17-usb-passthrough-plan.md
Normal file
|
|
@ -0,0 +1,751 @@
|
|||
# USB Device Passthrough Implementation Plan
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Add USB device passthrough to vmsilo VMs via crosvm's USB hotplug interface, with persistent config-based attachment and runtime `vmsilo-usb` CLI.
|
||||
|
||||
**Architecture:** USB devices are attached/detached at runtime via `crosvm usb attach/detach` over the VM's control socket. A global state file (`/run/vmsilo/usb-state.json`) tracks which physical device is in which VM. The `vmsilo-usb` CLI dispatches attach/detach through polkit-authorized systemd oneshot services. See `docs/plans/2026-03-17-usb-passthrough-design.md` for full design.
|
||||
|
||||
**Tech Stack:** Nix/NixOS modules, bash scripts, jq for JSON state, crosvm USB hotplug, systemd, polkit
|
||||
|
||||
**Key crosvm details:**
|
||||
- `crosvm usb attach BUS_ID:ADDR:VID:PID DEV_PATH SOCKET_PATH` — first arg is dead code (pass `1:1:0000:0000`), only DEV_PATH and SOCKET_PATH matter. Opens device fd client-side, sends over socket. Outputs `ok <port>` on success.
|
||||
- `crosvm usb detach PORT SOCKET_PATH` — outputs `ok <port>` on success.
|
||||
- `crosvm usb list SOCKET_PATH` — outputs `devices <port> <vid_hex> <pid_hex> ...` for each attached device.
|
||||
- Control socket: `/run/vmsilo/<vm>-crosvm-control.socket`
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Add `usbDevices` option to `modules/options.nix`
|
||||
|
||||
**Files:**
|
||||
- Modify: `modules/options.nix:181-680` (inside `vmSubmodule` options block)
|
||||
|
||||
**Step 1: Add the usbDevices option**
|
||||
|
||||
Add after the `pciDevices` option (after line 603), before `guestPrograms`:
|
||||
|
||||
```nix
|
||||
usbDevices = lib.mkOption {
|
||||
type = lib.types.listOf (
|
||||
lib.types.submodule {
|
||||
options = {
|
||||
vendorId = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
description = "USB vendor ID (hex, e.g., '17ef').";
|
||||
example = "17ef";
|
||||
};
|
||||
productId = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
description = "USB product ID (hex, e.g., '60e0').";
|
||||
example = "60e0";
|
||||
};
|
||||
serial = lib.mkOption {
|
||||
type = lib.types.nullOr lib.types.str;
|
||||
default = null;
|
||||
description = "USB serial number to match a specific device. If null, matches all devices with this VID:PID.";
|
||||
example = "A02019100900";
|
||||
};
|
||||
};
|
||||
}
|
||||
);
|
||||
default = [ ];
|
||||
description = ''
|
||||
USB devices to pass through to this VM. Devices are attached when the VM starts
|
||||
and detached when it stops. Matched by VID:PID, optionally narrowed by serial number.
|
||||
If multiple physical devices match a VID:PID (and no serial filter), all are attached.
|
||||
'';
|
||||
example = lib.literalExpression ''
|
||||
[
|
||||
{ vendorId = "17ef"; productId = "60e0"; }
|
||||
{ vendorId = "046d"; productId = "c52b"; serial = "A02019100900"; }
|
||||
]
|
||||
'';
|
||||
};
|
||||
```
|
||||
|
||||
**Step 2: Verify syntax**
|
||||
|
||||
Run: `nix eval .#nixosModules.default --apply 'x: "ok"'`
|
||||
Expected: `"ok"` (module loads without errors)
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add modules/options.nix
|
||||
git commit -m "Add usbDevices option for USB device passthrough"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Add USB duplicate assertion to `modules/assertions.nix`
|
||||
|
||||
**Files:**
|
||||
- Modify: `modules/assertions.nix:52-226` (inside assertions list)
|
||||
|
||||
**Step 1: Add the assertion**
|
||||
|
||||
Add after the shared directory assertions block (after line 184), before the netvm assertions. Build a list of all `(vid, pid, serial)` tuples across all VMs and assert no duplicates:
|
||||
|
||||
```nix
|
||||
# USB device assertions: no duplicate VID:PID+serial across VMs
|
||||
++ (
|
||||
let
|
||||
allUsbDeviceKeys = lib.concatMap (
|
||||
vm:
|
||||
map (
|
||||
dev:
|
||||
"${dev.vendorId}:${dev.productId}:${toString (dev.serial or "")}"
|
||||
) vm.usbDevices
|
||||
) (lib.attrValues cfg.nixosVms);
|
||||
in
|
||||
[
|
||||
{
|
||||
assertion = lib.length allUsbDeviceKeys == lib.length (lib.unique allUsbDeviceKeys);
|
||||
message = "USB devices (VID:PID+serial) cannot be assigned to multiple VMs";
|
||||
}
|
||||
]
|
||||
)
|
||||
```
|
||||
|
||||
**Step 2: Verify syntax**
|
||||
|
||||
Run: `nix eval .#nixosModules.default --apply 'x: "ok"'`
|
||||
Expected: `"ok"`
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add modules/assertions.nix
|
||||
git commit -m "Add assertion for duplicate USB device assignments across VMs"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Add USB helper script to `modules/scripts.nix`
|
||||
|
||||
This is the core USB logic: sysfs enumeration, state file management with flock, and crosvm attach/detach wrappers. Written as a single reusable shell library sourced by all USB-related scripts.
|
||||
|
||||
**Files:**
|
||||
- Modify: `modules/scripts.nix:10-27` (let block, add USB helper)
|
||||
- Modify: `modules/scripts.nix:581-598` (config block, add to userScripts)
|
||||
|
||||
**Step 1: Add the USB helper library script**
|
||||
|
||||
Add in the `let` block (after `vms = assignVmIds cfg.nixosVms;` around line 29):
|
||||
|
||||
```nix
|
||||
# USB helper library — sourced by vmsilo-usb and USB service scripts
|
||||
usbHelperLib = pkgs.writeShellScript "vmsilo-usb-lib" ''
|
||||
USB_STATE_FILE="/run/vmsilo/usb-state.json"
|
||||
USB_STATE_LOCK="/run/vmsilo/usb-state.lock"
|
||||
CROSVM="${cfg._internal.crosvm}/bin/crosvm"
|
||||
|
||||
# Acquire exclusive lock on state file (fd 9)
|
||||
usb_lock() {
|
||||
exec 9>"$USB_STATE_LOCK"
|
||||
${pkgs.util-linux}/bin/flock 9
|
||||
}
|
||||
|
||||
# Release lock
|
||||
usb_unlock() {
|
||||
exec 9>&-
|
||||
}
|
||||
|
||||
# Read state file (returns {} if missing)
|
||||
usb_read_state() {
|
||||
if [ -f "$USB_STATE_FILE" ]; then
|
||||
${pkgs.jq}/bin/jq '.' "$USB_STATE_FILE"
|
||||
else
|
||||
echo '{}'
|
||||
fi
|
||||
}
|
||||
|
||||
# Write state file
|
||||
usb_write_state() {
|
||||
local state="$1"
|
||||
echo "$state" > "$USB_STATE_FILE.tmp"
|
||||
mv "$USB_STATE_FILE.tmp" "$USB_STATE_FILE"
|
||||
}
|
||||
|
||||
# Enumerate host USB devices from sysfs
|
||||
# Output: JSON array of {devpath, vid, pid, serial, busnum, devnum, manufacturer, product, dev_file}
|
||||
usb_enumerate() {
|
||||
local devices="[]"
|
||||
for dev in /sys/bus/usb/devices/[0-9]*-[0-9]*; do
|
||||
[ -f "$dev/idVendor" ] || continue
|
||||
# Skip interface entries (contain :)
|
||||
case "$(basename "$dev")" in
|
||||
*:*) continue ;;
|
||||
esac
|
||||
local devpath busnum devnum vid pid serial manufacturer product
|
||||
devpath=$(basename "$dev")
|
||||
vid=$(cat "$dev/idVendor" 2>/dev/null || echo "")
|
||||
pid=$(cat "$dev/idProduct" 2>/dev/null || echo "")
|
||||
serial=$(cat "$dev/serial" 2>/dev/null || echo "")
|
||||
busnum=$(cat "$dev/busnum" 2>/dev/null || echo "0")
|
||||
devnum=$(cat "$dev/devnum" 2>/dev/null || echo "0")
|
||||
manufacturer=$(cat "$dev/manufacturer" 2>/dev/null || echo "")
|
||||
product=$(cat "$dev/product" 2>/dev/null || echo "")
|
||||
dev_file="/dev/bus/usb/$(printf '%03d' "$busnum")/$(printf '%03d' "$devnum")"
|
||||
devices=$(echo "$devices" | ${pkgs.jq}/bin/jq \
|
||||
--arg dp "$devpath" --arg v "$vid" --arg p "$pid" \
|
||||
--arg s "$serial" --arg bn "$busnum" --arg dn "$devnum" \
|
||||
--arg mfg "$manufacturer" --arg prod "$product" --arg df "$dev_file" \
|
||||
'. + [{devpath: $dp, vid: $v, pid: $p, serial: $s, busnum: ($bn|tonumber), devnum: ($dn|tonumber), manufacturer: $mfg, product: $prod, dev_file: $df}]')
|
||||
done
|
||||
echo "$devices"
|
||||
}
|
||||
|
||||
# Find devices matching a VID:PID
|
||||
# Args: $1=vid, $2=pid, $3=serial (optional, "" for any)
|
||||
usb_find_by_vidpid() {
|
||||
local vid="$1" pid="$2" serial="''${3:-}"
|
||||
local devices
|
||||
devices=$(usb_enumerate)
|
||||
if [ -n "$serial" ]; then
|
||||
echo "$devices" | ${pkgs.jq}/bin/jq --arg v "$vid" --arg p "$pid" --arg s "$serial" \
|
||||
'[.[] | select(.vid == $v and .pid == $p and .serial == $s)]'
|
||||
else
|
||||
echo "$devices" | ${pkgs.jq}/bin/jq --arg v "$vid" --arg p "$pid" \
|
||||
'[.[] | select(.vid == $v and .pid == $p)]'
|
||||
fi
|
||||
}
|
||||
|
||||
# Find device by devpath
|
||||
usb_find_by_devpath() {
|
||||
local devpath="$1"
|
||||
usb_enumerate | ${pkgs.jq}/bin/jq --arg dp "$devpath" '[.[] | select(.devpath == $dp)]'
|
||||
}
|
||||
|
||||
# Attach a single device to a VM
|
||||
# Args: $1=vm_name, $2=devpath, $3=dev_file, $4=vid, $5=pid, $6=serial, $7=busnum, $8=devnum
|
||||
# State file must already be locked
|
||||
usb_do_attach() {
|
||||
local vm_name="$1" devpath="$2" dev_file="$3" vid="$4" pid="$5" serial="$6" busnum="$7" devnum="$8"
|
||||
local socket="/run/vmsilo/''${vm_name}-crosvm-control.socket"
|
||||
local state result port
|
||||
|
||||
state=$(usb_read_state)
|
||||
|
||||
# Check if already attached somewhere — detach first
|
||||
local existing_vm existing_port
|
||||
existing_vm=$(echo "$state" | ${pkgs.jq}/bin/jq -r --arg dp "$devpath" '.[$dp].vm // empty')
|
||||
if [ -n "$existing_vm" ]; then
|
||||
existing_port=$(echo "$state" | ${pkgs.jq}/bin/jq -r --arg dp "$devpath" '.[$dp].port')
|
||||
local existing_socket="/run/vmsilo/''${existing_vm}-crosvm-control.socket"
|
||||
if [ -S "$existing_socket" ]; then
|
||||
$CROSVM usb detach "$existing_port" "$existing_socket" >/dev/null 2>&1 || true
|
||||
fi
|
||||
state=$(echo "$state" | ${pkgs.jq}/bin/jq --arg dp "$devpath" 'del(.[$dp])')
|
||||
fi
|
||||
|
||||
# Attach to target VM
|
||||
result=$($CROSVM usb attach "1:1:0000:0000" "$dev_file" "$socket" 2>&1) || {
|
||||
echo "Failed to attach $devpath ($vid:$pid) to $vm_name: $result" >&2
|
||||
usb_write_state "$state"
|
||||
return 1
|
||||
}
|
||||
|
||||
# Parse port from "ok <port>"
|
||||
port=$(echo "$result" | ${pkgs.sed}/bin/sed -n 's/^ok //p')
|
||||
if [ -z "$port" ]; then
|
||||
echo "Unexpected crosvm response: $result" >&2
|
||||
usb_write_state "$state"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Update state
|
||||
state=$(echo "$state" | ${pkgs.jq}/bin/jq \
|
||||
--arg dp "$devpath" --arg v "$vid" --arg p "$pid" --arg s "$serial" \
|
||||
--arg bn "$busnum" --arg dn "$devnum" --arg vm "$vm_name" --argjson pt "$port" \
|
||||
'.[$dp] = {vid: $v, pid: $p, serial: $s, busnum: ($bn|tonumber), devnum: ($dn|tonumber), vm: $vm, port: $pt}')
|
||||
usb_write_state "$state"
|
||||
echo "Attached $devpath ($vid:$pid) to $vm_name on port $port"
|
||||
}
|
||||
|
||||
# Detach a single device from a VM
|
||||
# Args: $1=vm_name, $2=devpath
|
||||
# State file must already be locked
|
||||
usb_do_detach() {
|
||||
local vm_name="$1" devpath="$2"
|
||||
local socket="/run/vmsilo/''${vm_name}-crosvm-control.socket"
|
||||
local state port
|
||||
|
||||
state=$(usb_read_state)
|
||||
port=$(echo "$state" | ${pkgs.jq}/bin/jq -r --arg dp "$devpath" '.[$dp].port // empty')
|
||||
|
||||
if [ -z "$port" ]; then
|
||||
echo "Device $devpath not attached to $vm_name" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
if [ -S "$socket" ]; then
|
||||
$CROSVM usb detach "$port" "$socket" >/dev/null 2>&1 || true
|
||||
fi
|
||||
|
||||
state=$(echo "$state" | ${pkgs.jq}/bin/jq --arg dp "$devpath" 'del(.[$dp])')
|
||||
usb_write_state "$state"
|
||||
echo "Detached $devpath from $vm_name"
|
||||
}
|
||||
|
||||
# Clean up all state entries for a VM (used on VM stop)
|
||||
usb_cleanup_vm() {
|
||||
local vm_name="$1"
|
||||
usb_lock
|
||||
local state
|
||||
state=$(usb_read_state)
|
||||
state=$(echo "$state" | ${pkgs.jq}/bin/jq --arg vm "$vm_name" 'with_entries(select(.value.vm != $vm))')
|
||||
usb_write_state "$state"
|
||||
usb_unlock
|
||||
}
|
||||
'';
|
||||
```
|
||||
|
||||
Note: `${pkgs.sed}` is used for the `sed` call above. Add `sed` from `pkgs.gnused` or just use `pkgs.gnused`. Actually, the `sed` in NixOS is already available. Use `${pkgs.gnused}/bin/sed` to be explicit in the script.
|
||||
|
||||
**Step 2: Verify syntax**
|
||||
|
||||
Run: `nix eval .#nixosModules.default --apply 'x: "ok"'`
|
||||
Expected: `"ok"`
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add modules/scripts.nix
|
||||
git commit -m "Add USB helper library for state management and crosvm interaction"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Add `vmsilo-usb` user-facing script to `modules/scripts.nix`
|
||||
|
||||
**Files:**
|
||||
- Modify: `modules/scripts.nix` (let block, add script; config block, add to userScripts)
|
||||
|
||||
**Step 1: Add the vmsilo-usb script**
|
||||
|
||||
Add in the `let` block, after `usbHelperLib`:
|
||||
|
||||
```nix
|
||||
# vmsilo-usb: User-facing USB management CLI
|
||||
vmsiloUsbScript = pkgs.writeShellScript "vmsilo-usb" ''
|
||||
source ${usbHelperLib}
|
||||
|
||||
usage() {
|
||||
echo "Usage: vmsilo-usb List USB devices" >&2
|
||||
echo " vmsilo-usb attach <vm> <vid:pid|devpath> Attach device to VM" >&2
|
||||
echo " vmsilo-usb detach <vm> <vid:pid|devpath> Detach device from VM" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
# List mode (no args)
|
||||
cmd_list() {
|
||||
local devices state
|
||||
devices=$(usb_enumerate)
|
||||
if [ -f "$USB_STATE_FILE" ]; then
|
||||
state=$(cat "$USB_STATE_FILE")
|
||||
else
|
||||
state='{}'
|
||||
fi
|
||||
|
||||
# Header
|
||||
printf "%-10s %-6s %-14s %-14s %-24s %s\n" "VID:PID" "Port" "Serial" "Manufacturer" "Product" "VM"
|
||||
|
||||
# Format each device
|
||||
echo "$devices" | ${pkgs.jq}/bin/jq -r '.[] | [.vid, .pid, .devpath, .serial, .manufacturer, .product] | @tsv' | \
|
||||
while IFS=$'\t' read -r vid pid devpath serial manufacturer product; do
|
||||
local vm
|
||||
vm=$(echo "$state" | ${pkgs.jq}/bin/jq -r --arg dp "$devpath" '.[$dp].vm // "-"')
|
||||
[ -z "$serial" ] && serial="-"
|
||||
[ -z "$manufacturer" ] && manufacturer="-"
|
||||
[ -z "$product" ] && product="-"
|
||||
printf "%-10s %-6s %-14s %-14s %-24s %s\n" "$vid:$pid" "$devpath" "$serial" "$manufacturer" "$product" "$vm"
|
||||
done
|
||||
}
|
||||
|
||||
# Parse vid:pid string
|
||||
parse_vidpid() {
|
||||
local arg="$1"
|
||||
case "$arg" in
|
||||
*:*)
|
||||
USB_VID="''${arg%%:*}"
|
||||
USB_PID="''${arg#*:}"
|
||||
;;
|
||||
*)
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
# Check if argument looks like a devpath (contains dash, no colon-only pattern)
|
||||
is_devpath() {
|
||||
case "$1" in
|
||||
[0-9]*-[0-9]*) return 0 ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
cmd_attach() {
|
||||
local vm_name="$1" identifier="$2"
|
||||
local devices
|
||||
|
||||
if is_devpath "$identifier"; then
|
||||
devices=$(usb_find_by_devpath "$identifier")
|
||||
elif parse_vidpid "$identifier"; then
|
||||
devices=$(usb_find_by_vidpid "$USB_VID" "$USB_PID")
|
||||
else
|
||||
echo "Invalid identifier: $identifier (expected VID:PID or devpath like 1-3)" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
local count
|
||||
count=$(echo "$devices" | ${pkgs.jq}/bin/jq 'length')
|
||||
if [ "$count" -eq 0 ]; then
|
||||
echo "No USB devices found matching $identifier" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Dispatch through systemd for polkit authorization
|
||||
echo "$devices" | ${pkgs.jq}/bin/jq -r '.[] | [.devpath, .dev_file, .vid, .pid, .serial, .busnum, .devnum] | @tsv' | \
|
||||
while IFS=$'\t' read -r devpath dev_file vid pid serial busnum devnum; do
|
||||
systemctl start "vmsilo-usb-attach@$(systemd-escape "''${vm_name}:''${devpath}:''${dev_file}:''${vid}:''${pid}:''${serial}:''${busnum}:''${devnum}").service"
|
||||
done
|
||||
}
|
||||
|
||||
cmd_detach() {
|
||||
local vm_name="$1" identifier="$2"
|
||||
|
||||
if is_devpath "$identifier"; then
|
||||
systemctl start "vmsilo-usb-detach@$(systemd-escape "''${vm_name}:''${identifier}").service"
|
||||
elif parse_vidpid "$identifier"; then
|
||||
# Find devpaths matching this VID:PID that are attached to this VM
|
||||
local state
|
||||
if [ -f "$USB_STATE_FILE" ]; then
|
||||
state=$(cat "$USB_STATE_FILE")
|
||||
else
|
||||
echo "No USB devices are attached" >&2
|
||||
exit 1
|
||||
fi
|
||||
local devpaths
|
||||
devpaths=$(echo "$state" | ${pkgs.jq}/bin/jq -r --arg vm "$vm_name" --arg v "$USB_VID" --arg p "$USB_PID" \
|
||||
'to_entries[] | select(.value.vm == $vm and .value.vid == $v and .value.pid == $p) | .key')
|
||||
if [ -z "$devpaths" ]; then
|
||||
echo "No devices matching $identifier attached to $vm_name" >&2
|
||||
exit 1
|
||||
fi
|
||||
for devpath in $devpaths; do
|
||||
systemctl start "vmsilo-usb-detach@$(systemd-escape "''${vm_name}:''${devpath}").service"
|
||||
done
|
||||
else
|
||||
echo "Invalid identifier: $identifier (expected VID:PID or devpath like 1-3)" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
case "''${1:-}" in
|
||||
"")
|
||||
cmd_list
|
||||
;;
|
||||
attach)
|
||||
[ $# -eq 3 ] || usage
|
||||
cmd_attach "$2" "$3"
|
||||
;;
|
||||
detach)
|
||||
[ $# -eq 3 ] || usage
|
||||
cmd_detach "$2" "$3"
|
||||
;;
|
||||
*)
|
||||
usage
|
||||
;;
|
||||
esac
|
||||
'';
|
||||
```
|
||||
|
||||
**Step 2: Register in userScripts**
|
||||
|
||||
In the `config` block (around line 590), add `vmsilo-usb` to the userScripts attrset:
|
||||
|
||||
```nix
|
||||
userScripts = {
|
||||
vm-run = vmRunScript;
|
||||
vm-start = vmStartScript;
|
||||
vm-start-debug = vmStartDebugScript;
|
||||
vm-stop = vmStopScript;
|
||||
vm-shell = vmShellScript;
|
||||
vmsilo-usb = vmsiloUsbScript;
|
||||
};
|
||||
```
|
||||
|
||||
**Step 3: Verify syntax**
|
||||
|
||||
Run: `nix eval .#nixosModules.default --apply 'x: "ok"'`
|
||||
Expected: `"ok"`
|
||||
|
||||
**Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add modules/scripts.nix
|
||||
git commit -m "Add vmsilo-usb CLI script for runtime USB attach/detach/list"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Add USB systemd services to `modules/services.nix`
|
||||
|
||||
Three additions: (a) USB attach/detach oneshot templates for polkit, (b) persistent USB attach service per VM, (c) state cleanup on VM stop.
|
||||
|
||||
**Files:**
|
||||
- Modify: `modules/services.nix:10-13` (let block, add imports)
|
||||
- Modify: `modules/services.nix:104-298` (systemd.services, add USB services)
|
||||
- Modify: `modules/services.nix:134-137` (VM service ExecStopPost, add USB cleanup)
|
||||
|
||||
**Step 1: Add usbHelperLib import**
|
||||
|
||||
In the `let` block at the top, we need access to the USB helper lib. Since it's defined in scripts.nix, we need to reference it through `_internal`. However, to keep things simple, we can define the USB service scripts inline in services.nix, or add a new `_internal` option. The cleanest approach: add `usbHelperLib` to `_internal` in scripts.nix, then consume it in services.nix.
|
||||
|
||||
First, add to `modules/options.nix` (inside `_internal`):
|
||||
|
||||
```nix
|
||||
usbHelperLib = lib.mkOption {
|
||||
type = lib.types.path;
|
||||
description = "USB helper library script.";
|
||||
internal = true;
|
||||
};
|
||||
```
|
||||
|
||||
Then in `modules/scripts.nix` config block, add:
|
||||
|
||||
```nix
|
||||
usbHelperLib = usbHelperLib;
|
||||
```
|
||||
|
||||
**Step 2: Add USB attach/detach oneshot template services**
|
||||
|
||||
Add to the `systemd.services` list (after the tray proxy services, before the closing `]`):
|
||||
|
||||
```nix
|
||||
++
|
||||
# USB attach oneshot template (invoked by vmsilo-usb attach via polkit/systemctl)
|
||||
# Instance string: "VM_NAME:DEVPATH:DEV_FILE:VID:PID:SERIAL:BUSNUM:DEVNUM"
|
||||
[
|
||||
(lib.nameValuePair "vmsilo-usb-attach@" {
|
||||
description = "vmsilo USB attach: %i";
|
||||
serviceConfig = {
|
||||
Type = "oneshot";
|
||||
ExecStart = pkgs.writeShellScript "vmsilo-usb-do-attach" ''
|
||||
source ${cfg._internal.usbHelperLib}
|
||||
# Parse instance string (systemd-escaped, already unescaped by systemd)
|
||||
IFS=':' read -r vm_name devpath dev_file vid pid serial busnum devnum <<< "$1"
|
||||
usb_lock
|
||||
usb_do_attach "$vm_name" "$devpath" "$dev_file" "$vid" "$pid" "$serial" "$busnum" "$devnum"
|
||||
usb_unlock
|
||||
'';
|
||||
};
|
||||
})
|
||||
(lib.nameValuePair "vmsilo-usb-detach@" {
|
||||
description = "vmsilo USB detach: %i";
|
||||
serviceConfig = {
|
||||
Type = "oneshot";
|
||||
ExecStart = pkgs.writeShellScript "vmsilo-usb-do-detach" ''
|
||||
source ${cfg._internal.usbHelperLib}
|
||||
IFS=':' read -r vm_name devpath <<< "$1"
|
||||
usb_lock
|
||||
usb_do_detach "$vm_name" "$devpath"
|
||||
usb_unlock
|
||||
'';
|
||||
};
|
||||
})
|
||||
]
|
||||
```
|
||||
|
||||
Wait — template services receive the instance string as `%i`, not as `$1`. We need to use `%i` in the ExecStart or pass it differently. Actually, for template services the instance is available as `%i` in systemd unit fields, but not directly as a shell argument. We need `ExecStart` to accept it.
|
||||
|
||||
Revised approach: use `%I` (unescaped instance) in the ExecStart command line:
|
||||
|
||||
```nix
|
||||
(lib.nameValuePair "vmsilo-usb-attach@" {
|
||||
description = "vmsilo USB attach: %I";
|
||||
serviceConfig = {
|
||||
Type = "oneshot";
|
||||
ExecStart = "${pkgs.writeShellScript "vmsilo-usb-do-attach" ''
|
||||
source ${cfg._internal.usbHelperLib}
|
||||
IFS=':' read -r vm_name devpath dev_file vid pid serial busnum devnum <<< "$1"
|
||||
usb_lock
|
||||
usb_do_attach "$vm_name" "$devpath" "$dev_file" "$vid" "$pid" "$serial" "$busnum" "$devnum"
|
||||
usb_unlock
|
||||
''} %I";
|
||||
};
|
||||
})
|
||||
```
|
||||
|
||||
This passes the unescaped instance string as `$1` to the script.
|
||||
|
||||
**Step 3: Add persistent USB attach service per VM**
|
||||
|
||||
Add to the `systemd.services` list:
|
||||
|
||||
```nix
|
||||
++
|
||||
# Persistent USB attach services (one per VM with usbDevices configured)
|
||||
lib.concatMap (
|
||||
vm:
|
||||
lib.optional (vm.usbDevices != []) (
|
||||
lib.nameValuePair "vmsilo-${vm.name}-usb-attach" {
|
||||
description = "USB device attach for VM ${vm.name}";
|
||||
requires = [ "vmsilo-${vm.name}-vm.service" ];
|
||||
after = [ "vmsilo-${vm.name}-vm.service" ];
|
||||
wantedBy = [ "vmsilo-${vm.name}-vm.service" ];
|
||||
bindsTo = [ "vmsilo-${vm.name}-vm.service" ];
|
||||
|
||||
serviceConfig = {
|
||||
Type = "oneshot";
|
||||
RemainAfterExit = true;
|
||||
ExecStart = pkgs.writeShellScript "vmsilo-usb-attach-${vm.name}" ''
|
||||
source ${cfg._internal.usbHelperLib}
|
||||
|
||||
SOCKET="/run/vmsilo/${vm.name}-crosvm-control.socket"
|
||||
|
||||
# Wait for control socket (up to 30s)
|
||||
ELAPSED=0
|
||||
while [ ! -S "$SOCKET" ] && [ $ELAPSED -lt 30 ]; do
|
||||
sleep 0.5
|
||||
ELAPSED=$((ELAPSED + 1))
|
||||
done
|
||||
if [ ! -S "$SOCKET" ]; then
|
||||
echo "Timeout waiting for control socket: $SOCKET" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
usb_lock
|
||||
|
||||
${lib.concatMapStringsSep "\n" (dev: ''
|
||||
# Attach ${dev.vendorId}:${dev.productId}${lib.optionalString (dev.serial != null) " serial=${dev.serial}"}
|
||||
devices=$(usb_find_by_vidpid "${dev.vendorId}" "${dev.productId}" "${toString (dev.serial or "")}")
|
||||
count=$(echo "$devices" | ${pkgs.jq}/bin/jq 'length')
|
||||
if [ "$count" -eq 0 ]; then
|
||||
echo "Warning: USB device ${dev.vendorId}:${dev.productId} not found" >&2
|
||||
else
|
||||
echo "$devices" | ${pkgs.jq}/bin/jq -r '.[] | [.devpath, .dev_file, .vid, .pid, .serial, .busnum, .devnum] | @tsv' | \
|
||||
while IFS=$'\t' read -r devpath dev_file vid pid serial busnum devnum; do
|
||||
usb_do_attach "${vm.name}" "$devpath" "$dev_file" "$vid" "$pid" "$serial" "$busnum" "$devnum" || true
|
||||
done
|
||||
fi
|
||||
'') vm.usbDevices}
|
||||
|
||||
usb_unlock
|
||||
'';
|
||||
};
|
||||
}
|
||||
)
|
||||
) vms
|
||||
```
|
||||
|
||||
**Step 4: Add USB state cleanup to VM ExecStopPost**
|
||||
|
||||
In the VM service definition (around line 134), add a USB cleanup script to `stopPostScripts`:
|
||||
|
||||
```nix
|
||||
usbCleanup = pkgs.writeShellScript "usb-cleanup-${vm.name}" ''
|
||||
source ${cfg._internal.usbHelperLib}
|
||||
usb_cleanup_vm "${vm.name}"
|
||||
'';
|
||||
```
|
||||
|
||||
And add `"${usbCleanup}"` to the `stopPostScripts` list.
|
||||
|
||||
**Step 5: Verify syntax**
|
||||
|
||||
Run: `nix eval .#nixosModules.default --apply 'x: "ok"'`
|
||||
Expected: `"ok"`
|
||||
|
||||
**Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add modules/options.nix modules/scripts.nix modules/services.nix
|
||||
git commit -m "Add USB systemd services: persistent attach, runtime oneshot templates, cleanup"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Add `vmsilo-usb` to package and bash completion
|
||||
|
||||
**Files:**
|
||||
- Modify: `modules/package.nix:14-37` (vmPackage)
|
||||
- Modify: `modules/package.nix:32-36` (bash completion section)
|
||||
|
||||
**Step 1: Add vmsilo-usb to bash completion list**
|
||||
|
||||
In `package.nix`, the bash completion loop (line 33) currently lists:
|
||||
```
|
||||
for cmd in vm-run vm-start vm-start-debug vm-stop vm-shell; do
|
||||
```
|
||||
|
||||
Add `vmsilo-usb`:
|
||||
```
|
||||
for cmd in vm-run vm-start vm-start-debug vm-stop vm-shell vmsilo-usb; do
|
||||
```
|
||||
|
||||
(The `vmsilo-usb` script is already included via the `userScripts` loop on lines 24-26 — no additional symlink needed since we added it to `userScripts` in Task 4.)
|
||||
|
||||
**Step 2: Verify syntax**
|
||||
|
||||
Run: `nix eval .#nixosModules.default --apply 'x: "ok"'`
|
||||
Expected: `"ok"`
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add modules/package.nix
|
||||
git commit -m "Add vmsilo-usb to bash completion"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: Update README.md and CLAUDE.md
|
||||
|
||||
**Files:**
|
||||
- Modify: `README.md` (add USB passthrough section)
|
||||
- Modify: `CLAUDE.md` (update Generated Scripts section)
|
||||
|
||||
**Step 1: Update README.md**
|
||||
|
||||
Add a USB Passthrough section to README.md documenting:
|
||||
- The `usbDevices` option with examples
|
||||
- `vmsilo-usb` CLI usage (list, attach, detach)
|
||||
- How persistent vs runtime attachment works
|
||||
|
||||
**Step 2: Update CLAUDE.md**
|
||||
|
||||
Add `vmsilo-usb` to the Generated Scripts section:
|
||||
```
|
||||
- `vmsilo-usb` — list USB devices, attach/detach USB devices to VMs
|
||||
```
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add README.md CLAUDE.md
|
||||
git commit -m "Document USB device passthrough in README and CLAUDE.md"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 8: Format and final verification
|
||||
|
||||
**Step 1: Format**
|
||||
|
||||
Run: `nix fmt`
|
||||
|
||||
**Step 2: Commit formatting if needed**
|
||||
|
||||
```bash
|
||||
git add -u
|
||||
git commit -m "Format with nixfmt"
|
||||
```
|
||||
|
||||
**Step 3: Build**
|
||||
|
||||
Run: `nix build .#` to verify the full build succeeds.
|
||||
|
||||
Note: `nix build` uses git index — ensure all new files are `git add`'d first.
|
||||
Loading…
Add table
Add a link
Reference in a new issue