Compare commits

...

4 commits

Author SHA1 Message Date
bbb3029324 Remove netvmSettings, mostly automated by isNetvm now 2026-03-17 12:46:57 +00:00
d4f4f71ed7 Add USB device passthrough implementation plan
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-17 12:02:44 +00:00
a5582f4b29 Add USB device passthrough design document
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-17 11:59:06 +00:00
0f52236f80 Disable systemd-networkd-wait-online.service
systemd-networkd-wait-online interacts badly with non-routable interfaces. It
ends up slowing down system config activation waiting for routes that never
appear. Ignoring interfaces does not reliably fix this. Just disable the damn
thing until I find a better solution.
2026-03-17 11:20:26 +00:00
4 changed files with 877 additions and 26 deletions

View file

@ -0,0 +1,119 @@
# USB Device Passthrough Design
## Overview
USB device passthrough for vmsilo VMs via crosvm's USB hotplug interface. Supports both persistent attachment (declared in VM config, attached at VM start) and runtime attachment via `vmsilo-usb` CLI.
## Identifiers
- **Persistent config:** VID:PID (required) + optional serial number
- **Runtime CLI:** VID:PID or devpath (physical USB port path like `1-3`)
When VID:PID matches multiple physical devices, all are attached (unless narrowed by serial).
## NixOS Options
New per-VM option in `programs.vmsilo.nixosVms.<vm>`:
```nix
usbDevices = [
{ vendorId = "17ef"; productId = "60e0"; }
{ vendorId = "046d"; productId = "c52b"; serial = "A02019100900"; }
];
```
A NixOS assertion ensures no two VMs declare the same VID:PID+serial combination.
## State File
`/run/vmsilo/usb-state.json` — single global file tracking all USB attachments:
```json
{
"1-3": {
"vid": "17ef",
"pid": "60e0",
"serial": null,
"busnum": 1,
"devnum": 2,
"vm": "banking",
"port": 1
}
}
```
Keyed by devpath. All operations acquire an exclusive `flock` on `/run/vmsilo/usb-state.lock` for the full read-modify-crosvm call-write cycle.
Ephemeral (lives in `/run`), matching the VM lifecycle.
## Runtime CLI — `vmsilo-usb`
### `vmsilo-usb` (no args) — list devices
Displays all host USB devices with attachment status:
```
VID:PID Port Serial Manufacturer Product VM
17ef:60ee 1-3 - Lenovo TrackPoint Keyboard II -
05e3:0610 1-6 - - USB2.0 Hub -
26ce:01a2 9-1 A02019100900 ASRock LED Controller untrusted
```
Reads sysfs for device info, merges with state file for VM assignments. No root required.
### `vmsilo-usb attach <vm> <vid:pid|devpath>`
1. Resolve identifier to physical device(s) via sysfs
2. Acquire lock, read state file
3. For each matching device: if attached to another VM, detach from old VM first (`crosvm usb detach`)
4. Call `crosvm usb attach` on target VM
5. Update state file, release lock
Runs via polkit — invokes a systemd oneshot template that runs as root.
### `vmsilo-usb detach <vm> <vid:pid|devpath>`
1. Acquire lock, read state file
2. Find matching entries for that VM
3. Call `crosvm usb detach`
4. Remove entries from state file, release lock
Same polkit mechanism as attach.
## Persistent Attachment
### On VM Start
`vmsilo-<vm>-usb-attach.service` — a oneshot unit with `After=` and `Requires=` on the VM service:
1. Wait for the crosvm control socket to appear
2. For each configured `usbDevices` entry: scan sysfs for matching VID:PID (+ serial if specified)
3. Attach all matches via `crosvm usb attach`, update state file under lock
4. If a device isn't plugged in, log a warning and continue
### On VM Stop
ExecStopPost in the VM service removes that VM's entries from the state file under lock. No `crosvm usb detach` needed since the VM is shutting down.
## Permission Model
- `vmsilo-usb attach/detach` use polkit, same as `vm-start`/`vm-stop`
- Implemented as systemd oneshot template services authorized via existing vmsilo polkit rules
- `crosvm usb attach` runs as root (needs USB device fd + control socket access)
- `vmsilo-usb` (listing) needs no privileges — reads sysfs and the state file
## crosvm Interface
- `crosvm usb attach BUS_ID:ADDR:BUS_NUM:DEV_NUM DEV_PATH SOCKET_PATH` — opens device fd, passes to VM over control socket
- `crosvm usb detach PORT SOCKET_PATH` — detaches by crosvm port number
- `crosvm usb list SOCKET_PATH` — returns `{port, vendor_id, product_id}` per device
- Control socket at `/run/vmsilo/<vm>-crosvm-control.socket`
- USB enabled by default in crosvm (no extra flags needed)
## Files Modified
- `modules/options.nix``usbDevices` option
- `modules/assertions.nix` — duplicate VID:PID+serial assertion
- `modules/scripts.nix``vmsilo-usb` script, USB helper functions (sysfs enumeration, state file with flock, resolution logic)
- `modules/services.nix``vmsilo-<vm>-usb-attach.service`, ExecStopPost state cleanup, polkit oneshot templates for runtime
- `modules/package.nix` — symlink `vmsilo-usb` into `$out/bin/`

View 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.

View file

@ -228,30 +228,5 @@
'';
};
};
nixosModules.netvmSettings =
# Guest settings for a networkmanager-based netvm. Not used by default.
{
config,
pkgs,
lib,
...
}:
{
config = {
programs.nm-applet.enable = true;
boot.kernel.sysctl."net.ipv4.ip_forward" = 1;
hardware.enableAllFirmware = true;
networking = {
useDHCP = false;
nat.enable = true;
# TODO don't hard code this
nat.externalInterface = "host";
nat.internalInterfaces = [ "clients" ];
nftables.enable = true;
networkmanager.enable = true;
networkmanager.wifi.powersave = true;
};
};
};
};
}

View file

@ -1,4 +1,4 @@
# Network configuration for vmsilo NixOS module
# Host network configuration for vmsilo NixOS module
# TAP interfaces and NAT
{
config,
@ -67,6 +67,12 @@ in
# Bridge changes are not reliable with the scripted approach
networking.useNetworkd = true;
# systemd-networkd-wait-online interacts badly with non-routable interfaces. It
# ends up slowing down system config activation waiting for routes that never
# appear. Ignoring interfaces does not reliably fix this. Just disable the damn
# thing until I find a better solution.
systemd.network.wait-online.enable = false;
# Configure vmsilo TAP/bridge network units:
# - Not required for online (VMs may not be running during nixos-rebuild)
# - ConfigureWithoutCarrier: assign static addresses even when link has no