diff --git a/modules/options.nix b/modules/options.nix index 90577ea..8e8a8b9 100644 --- a/modules/options.nix +++ b/modules/options.nix @@ -972,6 +972,12 @@ in internal = true; }; + usbHelperLib = lib.mkOption { + type = lib.types.path; + description = "USB helper library script."; + internal = true; + }; + # Generated by netvm.nix: maps VM name -> { interfaces, guestConfig } # Used to inject netvm-derived interfaces and guest config into VMs # without creating a self-referential cycle on nixosVms. diff --git a/modules/scripts.nix b/modules/scripts.nix index aa16611..de4e5de 100644 --- a/modules/scripts.nix +++ b/modules/scripts.nix @@ -28,6 +28,210 @@ let vms = assignVmIds cfg.nixosVms; + # USB helper library — sourced by vmsilo-usb CLI, systemd oneshot services, and persistent attach service + usbHelperLib = pkgs.writeShellScript "vmsilo-usb-lib" '' + # --- constants --- + USB_STATE_FILE=/run/vmsilo/usb-state.json + USB_STATE_LOCK=/run/vmsilo/usb-state.lock + CROSVM=${cfg._internal.crosvm}/bin/crosvm + + # --- locking (fd 9) --- + usb_lock() { + exec 9>"''${USB_STATE_LOCK}" + ${pkgs.util-linux}/bin/flock 9 + } + + usb_unlock() { + exec 9>&- + } + + # --- state file --- + usb_read_state() { + if [ -f "''${USB_STATE_FILE}" ]; then + ${pkgs.coreutils}/bin/cat "''${USB_STATE_FILE}" + else + echo '[]' + fi + } + + usb_write_state() { + local json="$1" + local tmp="''${USB_STATE_FILE}.tmp.$$" + printf '%s\n' "''${json}" > "''${tmp}" + ${pkgs.coreutils}/bin/mv "''${tmp}" "''${USB_STATE_FILE}" + } + + # --- enumerate USB devices from sysfs --- + usb_enumerate() { + local result='[]' + for dev in /sys/bus/usb/devices/[0-9]*-[0-9]*; do + [ -d "''${dev}" ] || continue + # Skip interface entries (e.g. 1-3:1.0) + local base + base=$(${pkgs.coreutils}/bin/basename "''${dev}") + case "''${base}" in + *:*) continue ;; + esac + + # Read sysfs attributes + local vid pid serial busnum devnum manufacturer product devpath + vid=$(${pkgs.coreutils}/bin/cat "''${dev}/idVendor" 2>/dev/null || echo "") + pid=$(${pkgs.coreutils}/bin/cat "''${dev}/idProduct" 2>/dev/null || echo "") + serial=$(${pkgs.coreutils}/bin/cat "''${dev}/serial" 2>/dev/null || echo "") + busnum=$(${pkgs.coreutils}/bin/cat "''${dev}/busnum" 2>/dev/null || echo "") + devnum=$(${pkgs.coreutils}/bin/cat "''${dev}/devnum" 2>/dev/null || echo "") + manufacturer=$(${pkgs.coreutils}/bin/cat "''${dev}/manufacturer" 2>/dev/null || echo "") + product=$(${pkgs.coreutils}/bin/cat "''${dev}/product" 2>/dev/null || echo "") + devpath="''${base}" + local dev_file="/dev/bus/usb/$(printf '%03d' "''${busnum}")/$(printf '%03d' "''${devnum}")" + + [ -z "''${vid}" ] && continue + + result=$(printf '%s\n' "''${result}" | ${pkgs.jq}/bin/jq -c \ + --arg devpath "''${devpath}" \ + --arg vid "''${vid}" \ + --arg pid "''${pid}" \ + --arg serial "''${serial}" \ + --arg busnum "''${busnum}" \ + --arg devnum "''${devnum}" \ + --arg manufacturer "''${manufacturer}" \ + --arg product "''${product}" \ + --arg dev_file "''${dev_file}" \ + '. + [{devpath: $devpath, vid: $vid, pid: $pid, serial: $serial, busnum: $busnum, devnum: $devnum, manufacturer: $manufacturer, product: $product, dev_file: $dev_file}]') + done + printf '%s\n' "''${result}" + } + + # --- find by VID:PID (optional serial) --- + usb_find_by_vidpid() { + local vid="$1" pid="$2" serial="''${3:-}" + local devices + devices=$(usb_enumerate) + if [ -n "''${serial}" ]; then + printf '%s\n' "''${devices}" | ${pkgs.jq}/bin/jq -c \ + --arg vid "''${vid}" --arg pid "''${pid}" --arg serial "''${serial}" \ + '[.[] | select(.vid == $vid and .pid == $pid and .serial == $serial)]' + else + printf '%s\n' "''${devices}" | ${pkgs.jq}/bin/jq -c \ + --arg vid "''${vid}" --arg pid "''${pid}" \ + '[.[] | select(.vid == $vid and .pid == $pid)]' + fi + } + + # --- find by devpath --- + usb_find_by_devpath() { + local devpath="$1" + local devices + devices=$(usb_enumerate) + printf '%s\n' "''${devices}" | ${pkgs.jq}/bin/jq -c \ + --arg devpath "''${devpath}" \ + '[.[] | select(.devpath == $devpath)]' + } + + # --- attach (expects lock already held) --- + usb_do_attach() { + local vm_name="$1" devpath="$2" dev_file="$3" vid="$4" pid="$5" serial="$6" busnum="$7" devnum="$8" + local state socket response port + + socket="/run/vmsilo/''${vm_name}-crosvm-control.socket" + state=$(usb_read_state) + + # Check if already attached somewhere — detach first + local existing + existing=$(printf '%s\n' "''${state}" | ${pkgs.jq}/bin/jq -c \ + --arg devpath "''${devpath}" \ + '.[] | select(.devpath == $devpath)') + if [ -n "''${existing}" ]; then + local old_vm old_port old_socket + old_vm=$(printf '%s\n' "''${existing}" | ${pkgs.jq}/bin/jq -r '.vm') + old_port=$(printf '%s\n' "''${existing}" | ${pkgs.jq}/bin/jq -r '.port') + old_socket="/run/vmsilo/''${old_vm}-crosvm-control.socket" + if [ -S "''${old_socket}" ]; then + ''${CROSVM} usb detach "''${old_port}" "''${old_socket}" >/dev/null 2>&1 || true + fi + state=$(printf '%s\n' "''${state}" | ${pkgs.jq}/bin/jq -c \ + --arg devpath "''${devpath}" \ + '[.[] | select(.devpath != $devpath)]') + fi + + # Attach + response=$(''${CROSVM} usb attach "1:1:0000:0000" "''${dev_file}" "''${socket}" 2>&1) || { + echo "Error: crosvm usb attach failed: ''${response}" >&2 + usb_write_state "''${state}" + return 1 + } + + # Parse "ok " + port=$(printf '%s\n' "''${response}" | ${pkgs.gnused}/bin/sed -n 's/^ok \([0-9]*\)/\1/p') + if [ -z "''${port}" ]; then + echo "Error: unexpected crosvm response: ''${response}" >&2 + usb_write_state "''${state}" + return 1 + fi + + # Update state + state=$(printf '%s\n' "''${state}" | ${pkgs.jq}/bin/jq -c \ + --arg vm "''${vm_name}" \ + --arg devpath "''${devpath}" \ + --arg vid "''${vid}" \ + --arg pid "''${pid}" \ + --arg serial "''${serial}" \ + --arg busnum "''${busnum}" \ + --arg devnum "''${devnum}" \ + --arg port "''${port}" \ + '. + [{vm: $vm, devpath: $devpath, vid: $vid, pid: $pid, serial: $serial, busnum: $busnum, devnum: $devnum, port: ($port | tonumber)}]') + usb_write_state "''${state}" + echo "Attached ''${vid}:''${pid} (''${devpath}) to ''${vm_name} on port ''${port}" + } + + # --- detach (expects lock already held) --- + usb_do_detach() { + local vm_name="$1" devpath="$2" + local state socket + + socket="/run/vmsilo/''${vm_name}-crosvm-control.socket" + state=$(usb_read_state) + + local entry + entry=$(printf '%s\n' "''${state}" | ${pkgs.jq}/bin/jq -c \ + --arg vm "''${vm_name}" --arg devpath "''${devpath}" \ + '.[] | select(.vm == $vm and .devpath == $devpath)') + if [ -z "''${entry}" ]; then + echo "Error: device ''${devpath} not attached to ''${vm_name}" >&2 + return 1 + fi + + local port + port=$(printf '%s\n' "''${entry}" | ${pkgs.jq}/bin/jq -r '.port') + + if [ -S "''${socket}" ]; then + local response + response=$(''${CROSVM} usb detach "''${port}" "''${socket}" 2>&1) || { + echo "Warning: crosvm usb detach failed: ''${response}" >&2 + } + fi + + state=$(printf '%s\n' "''${state}" | ${pkgs.jq}/bin/jq -c \ + --arg vm "''${vm_name}" --arg devpath "''${devpath}" \ + '[.[] | select(.vm != $vm or .devpath != $devpath)]') + usb_write_state "''${state}" + echo "Detached ''${devpath} from ''${vm_name} (port ''${port})" + } + + # --- cleanup all entries for a VM (acquires lock) --- + usb_cleanup_vm() { + local vm_name="$1" + usb_lock + local state + state=$(usb_read_state) + state=$(printf '%s\n' "''${state}" | ${pkgs.jq}/bin/jq -c \ + --arg vm "''${vm_name}" \ + '[.[] | select(.vm != $vm)]') + usb_write_state "''${state}" + usb_unlock + } + ''; + # NOTE: getEffectiveInterfaces is intentionally duplicated in networking.nix and services.nix. # It cannot live in helpers.nix (which has no config access) and the three modules # don't share a common let-binding scope. Keep the copies in sync. @@ -594,6 +798,8 @@ in vm-stop = vmStopScript; vm-shell = vmShellScript; }; + + usbHelperLib = usbHelperLib; }; }; }