|
#Requires -Version 5.1 |
|
<# |
|
.SYNOPSIS |
|
Manage YubiKey (or other USB device) connections to WSL using usbipd. |
|
|
|
.DESCRIPTION |
|
Robustly attaches or detaches YubiKey devices to a running WSL instance via usbipd-win. |
|
Uses JSON output from usbipd for reliable parsing and handles USB re-enumeration. |
|
Verifies successful attachment by checking device visibility in WSL with lsusb. |
|
|
|
Exit Codes: |
|
0 - Success |
|
1 - No devices found or prerequisites missing |
|
2 - Some operations failed |
|
3 - All operations failed |
|
|
|
.PARAMETER VendorId |
|
Optional 4-digit hex USB vendor ID (e.g. 1050 for Yubico). |
|
|
|
.PARAMETER ProductId |
|
Optional 4-digit hex USB product ID (e.g. 0407). |
|
|
|
.PARAMETER BusId |
|
One or more explicit bus IDs (e.g. 20-3,21-2). Bypasses VID/PID filtering. |
|
|
|
.PARAMETER Detach |
|
If present, detaches devices instead of attaching them. |
|
|
|
.PARAMETER All |
|
Process all matching devices. Without this, only the first device is processed. |
|
|
|
.PARAMETER WhatIf |
|
Shows what would be done without making changes. |
|
|
|
.PARAMETER Force |
|
Skip confirmation prompts and prerequisite warnings. |
|
|
|
.PARAMETER NoVerify |
|
Skip verification of device attachment in WSL (lsusb check). |
|
|
|
.EXAMPLE |
|
# Attach first YubiKey found |
|
.\Connect-YubiKeyToWSL.ps1 |
|
|
|
.EXAMPLE |
|
# Attach specific YubiKey 5 NFC |
|
.\Connect-YubiKeyToWSL.ps1 -VendorId 1050 -ProductId 0407 |
|
|
|
.EXAMPLE |
|
# Detach all YubiKeys |
|
.\Connect-YubiKeyToWSL.ps1 -Detach -All |
|
|
|
.EXAMPLE |
|
# Check what would be attached without doing it |
|
.\Connect-YubiKeyToWSL.ps1 -WhatIf |
|
|
|
.EXAMPLE |
|
# Attach device by specific bus ID |
|
.\Connect-YubiKeyToWSL.ps1 -BusId "20-3" |
|
|
|
.EXAMPLE |
|
# Attach without verification |
|
.\Connect-YubiKeyToWSL.ps1 -NoVerify |
|
|
|
.NOTES |
|
Requires: |
|
- usbipd-win installed (https://github.com/dorssel/usbipd-win) |
|
- WSL2 with a running distribution |
|
- Administrator privileges for first-time device attachment |
|
- lsusb installed in WSL for verification (usually in usbutils package) |
|
|
|
Author: Enhanced version based on community script |
|
Version: 2.1.0 |
|
#> |
|
|
|
[CmdletBinding(SupportsShouldProcess, DefaultParameterSetName='Filter')] |
|
param( |
|
[Parameter(ParameterSetName='Filter')] |
|
[ValidatePattern('^[0-9a-fA-F]{4}$')] |
|
[string]$VendorId, |
|
|
|
[Parameter(ParameterSetName='Filter')] |
|
[ValidatePattern('^[0-9a-fA-F]{4}$')] |
|
[string]$ProductId, |
|
|
|
[Parameter(ParameterSetName='BusId', Mandatory)] |
|
[ValidatePattern('^[0-9]+-[0-9]+$')] |
|
[string[]]$BusId, |
|
|
|
[switch]$Detach, |
|
|
|
[switch]$All, |
|
|
|
[switch]$Force, |
|
|
|
[switch]$NoVerify |
|
) |
|
|
|
# ============================================================================ |
|
# Configuration |
|
# ============================================================================ |
|
|
|
# Known YubiKey vendor IDs |
|
$script:Config = @{ |
|
KnownYubicoVendorIds = @('1050', '2581', '1500') |
|
RetryAttempts = 5 |
|
RetryDelayMs = 500 |
|
RequiredCommands = @('usbipd', 'wsl') |
|
VerificationRetries = 3 |
|
VerificationDelayMs = 1000 |
|
} |
|
|
|
# Exit codes |
|
enum ExitCode { |
|
Success = 0 |
|
NotFound = 1 |
|
PartialFailure = 2 |
|
TotalFailure = 3 |
|
} |
|
|
|
# Device state |
|
enum DeviceState { |
|
Unknown |
|
Detached |
|
Attached |
|
AttachedToOther |
|
Error |
|
} |
|
|
|
# ============================================================================ |
|
# Classes |
|
# ============================================================================ |
|
|
|
class UsbDevice { |
|
[string]$BusId |
|
[string]$VendorId |
|
[string]$ProductId |
|
[string]$Description |
|
[string]$InstanceId |
|
[string]$ClientIPAddress |
|
[bool]$IsForced |
|
[string]$PersistedGuid |
|
[DeviceState]$State |
|
|
|
UsbDevice([PSCustomObject]$json) { |
|
$this.BusId = $json.BusId |
|
$this.Description = $json.Description |
|
$this.InstanceId = $json.InstanceId |
|
$this.ClientIPAddress = $json.ClientIPAddress |
|
$this.IsForced = $json.IsForced |
|
$this.PersistedGuid = $json.PersistedGuid |
|
|
|
# Parse VID/PID from InstanceId (e.g., "USB\VID_1050&PID_0407\...") |
|
if ($json.InstanceId -match 'VID_([0-9A-F]{4})&PID_([0-9A-F]{4})') { |
|
$this.VendorId = $Matches[1] |
|
$this.ProductId = $Matches[2] |
|
} |
|
|
|
# Determine state |
|
if ($json.ClientIPAddress) { |
|
$this.State = [DeviceState]::Attached |
|
} else { |
|
$this.State = [DeviceState]::Detached |
|
} |
|
} |
|
|
|
[bool] MatchesFilter([string]$vendorId, [string]$productId) { |
|
if ($vendorId -and $this.VendorId -ne $vendorId) { return $false } |
|
if ($productId -and $this.ProductId -ne $productId) { return $false } |
|
return $true |
|
} |
|
|
|
[bool] IsYubiKey() { |
|
return $this.VendorId -in $script:Config.KnownYubicoVendorIds -or |
|
$this.Description -match 'YubiKey|Yubico' |
|
} |
|
|
|
[string] ToString() { |
|
$vidStr = if ($this.VendorId) { $this.VendorId } else { '????' } |
|
$pidStr = if ($this.ProductId) { $this.ProductId } else { '????' } |
|
return "$($this.BusId) [$vidStr`:$pidStr] $($this.Description)" |
|
} |
|
|
|
[string] GetUsbIdString() { |
|
# Returns the VID:PID format used by lsusb (lowercase) |
|
$vidStr = if ($this.VendorId) { $this.VendorId.ToLower() } else { '????' } |
|
$pidStr = if ($this.ProductId) { $this.ProductId.ToLower() } else { '????' } |
|
return "${vidStr}:${pidStr}" |
|
} |
|
} |
|
|
|
# ============================================================================ |
|
# Helper Functions |
|
# ============================================================================ |
|
|
|
function Test-Prerequisites { |
|
<# |
|
.SYNOPSIS |
|
Checks if all required tools are available |
|
#> |
|
[CmdletBinding()] |
|
param() |
|
|
|
$issues = @() |
|
|
|
# Check for required commands |
|
foreach ($cmd in $script:Config.RequiredCommands) { |
|
if (-not (Get-Command $cmd -ErrorAction SilentlyContinue)) { |
|
$issues += "$cmd is not installed or not in PATH" |
|
} |
|
} |
|
|
|
# Check if WSL is running |
|
if (Get-Command wsl -ErrorAction SilentlyContinue) { |
|
$wslStatus = wsl --list --running 2>$null |
|
if (-not $wslStatus -or $wslStatus -match 'no running distributions') { |
|
$issues += "No WSL distributions are running. Start WSL first." |
|
} |
|
} |
|
|
|
# Check for elevation (warning only) |
|
$isAdmin = ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) |
|
if (-not $isAdmin) { |
|
Write-Warning "Not running as Administrator. You may need elevation for first-time device attachment." |
|
} |
|
|
|
if ($issues.Count -gt 0) { |
|
foreach ($issue in $issues) { |
|
Write-Error $issue |
|
} |
|
return $false |
|
} |
|
|
|
return $true |
|
} |
|
|
|
function Test-DeviceInWsl { |
|
<# |
|
.SYNOPSIS |
|
Verifies if a device is visible in WSL using lsusb |
|
#> |
|
[CmdletBinding()] |
|
param( |
|
[Parameter(Mandatory)] |
|
[UsbDevice]$Device |
|
) |
|
|
|
Write-Verbose "Verifying device visibility in WSL (VID:$($Device.VendorId) PID:$($Device.ProductId))..." |
|
|
|
try { |
|
# Run lsusb in WSL and capture output |
|
$lsusbOutput = wsl.exe lsusb 2>$null |
|
|
|
if ($LASTEXITCODE -ne 0) { |
|
Write-Warning "lsusb command failed. Ensure usbutils is installed in WSL (apt install usbutils)" |
|
return $false |
|
} |
|
|
|
# Look for the device by VID:PID |
|
$usbId = $Device.GetUsbIdString() |
|
$deviceFound = $lsusbOutput | Select-String -Pattern $usbId -Quiet |
|
|
|
if ($deviceFound) { |
|
# Get the specific line for better feedback |
|
$deviceLine = $lsusbOutput | Select-String -Pattern $usbId | Select-Object -First 1 |
|
Write-Verbose "Device found in WSL: $deviceLine" |
|
return $true |
|
} else { |
|
Write-Verbose "Device $usbId not found in lsusb output" |
|
return $false |
|
} |
|
} |
|
catch { |
|
Write-Warning "Error checking device in WSL: $_" |
|
return $false |
|
} |
|
} |
|
|
|
function Get-UsbDevices { |
|
<# |
|
.SYNOPSIS |
|
Gets USB devices from usbipd using JSON output |
|
#> |
|
[CmdletBinding()] |
|
param( |
|
[string]$VendorId, |
|
[string]$ProductId, |
|
[string[]]$BusId |
|
) |
|
|
|
Write-Verbose "Retrieving USB devices from usbipd..." |
|
|
|
try { |
|
$jsonOutput = usbipd state 2>$null |
|
if ($LASTEXITCODE -ne 0) { |
|
Write-Error "Failed to get device list from usbipd" |
|
return @() |
|
} |
|
|
|
$state = $jsonOutput | ConvertFrom-Json |
|
$devices = $state.Devices | ForEach-Object { [UsbDevice]::new($_) } |
|
|
|
Write-Verbose "Found $($devices.Count) total USB devices" |
|
|
|
# Apply filters |
|
if ($BusId) { |
|
$devices = $devices | Where-Object { $_.BusId -in $BusId } |
|
Write-Verbose "Filtered to $($devices.Count) devices by BusId" |
|
} elseif ($VendorId -or $ProductId) { |
|
$devices = $devices | Where-Object { $_.MatchesFilter($VendorId, $ProductId) } |
|
Write-Verbose "Filtered to $($devices.Count) devices by VID/PID" |
|
} else { |
|
# Default: filter to YubiKeys only |
|
$devices = $devices | Where-Object { $_.IsYubiKey() } |
|
Write-Verbose "Filtered to $($devices.Count) YubiKey devices" |
|
} |
|
|
|
return $devices |
|
} |
|
catch { |
|
Write-Error "Error parsing usbipd output: $_" |
|
return @() |
|
} |
|
} |
|
|
|
function Wait-ForDeviceReenumeration { |
|
<# |
|
.SYNOPSIS |
|
Waits for a device to reappear after detach with new BusId |
|
#> |
|
[CmdletBinding()] |
|
param( |
|
[Parameter(Mandatory)] |
|
[UsbDevice]$OriginalDevice, |
|
|
|
[int]$MaxAttempts = 10, |
|
[int]$InitialDelayMs = 1000, |
|
[int]$DelayMs = 500 |
|
) |
|
|
|
Write-Verbose "Waiting for device re-enumeration (VID:$($OriginalDevice.VendorId) PID:$($OriginalDevice.ProductId))..." |
|
|
|
# Initial delay to allow Windows to process the detach |
|
Start-Sleep -Milliseconds $InitialDelayMs |
|
|
|
for ($i = 0; $i -lt $MaxAttempts; $i++) { |
|
$devices = Get-UsbDevices -VendorId $OriginalDevice.VendorId -ProductId $OriginalDevice.ProductId |
|
|
|
# Look for any device with matching VID/PID that's not attached |
|
$availableDevice = $devices | Where-Object { |
|
$_.State -ne [DeviceState]::Attached -and |
|
$_.VendorId -eq $OriginalDevice.VendorId -and |
|
$_.ProductId -eq $OriginalDevice.ProductId |
|
} | Select-Object -First 1 |
|
|
|
if ($availableDevice) { |
|
if ($availableDevice.BusId -ne $OriginalDevice.BusId) { |
|
Write-Information "Device re-enumerated: $($OriginalDevice.BusId) → $($availableDevice.BusId)" -InformationAction Continue |
|
} |
|
return $availableDevice |
|
} |
|
|
|
# Also check if original device is back but detached |
|
$originalBack = $devices | Where-Object { |
|
$_.BusId -eq $OriginalDevice.BusId -and |
|
$_.State -eq [DeviceState]::Detached |
|
} | Select-Object -First 1 |
|
|
|
if ($originalBack) { |
|
Write-Verbose "Original device found at same BusId" |
|
return $originalBack |
|
} |
|
|
|
Write-Verbose "Attempt $($i+1)/$MaxAttempts - Device not found yet (found $($devices.Count) devices)" |
|
Start-Sleep -Milliseconds $DelayMs |
|
} |
|
|
|
Write-Warning "Device did not reappear after $MaxAttempts attempts" |
|
return $null |
|
} |
|
|
|
function Invoke-UsbDeviceAttach { |
|
<# |
|
.SYNOPSIS |
|
Attaches a USB device to WSL |
|
#> |
|
[CmdletBinding(SupportsShouldProcess)] |
|
param( |
|
[Parameter(Mandatory, ValueFromPipeline)] |
|
[UsbDevice]$Device |
|
) |
|
|
|
process { |
|
if ($Device.State -eq [DeviceState]::Attached) { |
|
Write-Information "Device already attached: $($Device.ToString())" -InformationAction Continue |
|
|
|
if ($Force -or $PSCmdlet.ShouldContinue("Device is already attached. Detach and re-attach?", "Re-attach Device")) { |
|
Write-Verbose "Detaching device for re-attachment..." |
|
$detachResult = Invoke-UsbDeviceDetach -Device $Device |
|
|
|
if (-not $detachResult) { |
|
Write-Error "Failed to detach device for re-attachment" |
|
return $false |
|
} |
|
|
|
# Wait for re-enumeration |
|
$newDevice = Wait-ForDeviceReenumeration -OriginalDevice $Device |
|
if (-not $newDevice) { |
|
Write-Error "Failed to find device after detach. The device may need to be manually reconnected." |
|
return $false |
|
} |
|
$Device = $newDevice |
|
} else { |
|
return $true |
|
} |
|
} |
|
|
|
if ($PSCmdlet.ShouldProcess($Device.ToString(), "Attach to WSL")) { |
|
Write-Verbose "Executing: usbipd attach --wsl --busid $($Device.BusId)" |
|
$output = usbipd attach --wsl --busid $Device.BusId 2>&1 |
|
|
|
if ($LASTEXITCODE -eq 0) { |
|
Write-Information "✓ Attached: $($Device.ToString())" -InformationAction Continue |
|
|
|
# Verify device is visible in WSL unless -NoVerify is specified |
|
if (-not $NoVerify) { |
|
Write-Information " Verifying device in WSL..." -InformationAction Continue |
|
|
|
# Try verification with retries |
|
$verified = $false |
|
for ($i = 0; $i -lt $script:Config.VerificationRetries; $i++) { |
|
if ($i -gt 0) { |
|
Start-Sleep -Milliseconds $script:Config.VerificationDelayMs |
|
} |
|
|
|
if (Test-DeviceInWsl -Device $Device) { |
|
$verified = $true |
|
Write-Information " ✓ Device verified in WSL (visible to lsusb)" -InformationAction Continue |
|
break |
|
} |
|
|
|
if ($i -lt ($script:Config.VerificationRetries - 1)) { |
|
Write-Verbose "Verification attempt $($i+1) failed, retrying..." |
|
} |
|
} |
|
|
|
if (-not $verified) { |
|
Write-Warning " Device attached but not visible in WSL. Check if usbutils is installed (apt install usbutils)" |
|
Write-Warning " You can manually verify with: wsl.exe lsusb | grep -i $($Device.GetUsbIdString())" |
|
} |
|
} |
|
|
|
return $true |
|
} elseif ($output -match 'already attached') { |
|
# Handle race condition where device shows as detached but is actually attached |
|
Write-Information "Device reports as already attached: $($Device.ToString())" -InformationAction Continue |
|
|
|
# Still verify if not skipped |
|
if (-not $NoVerify -and (Test-DeviceInWsl -Device $Device)) { |
|
Write-Information " ✓ Device verified in WSL" -InformationAction Continue |
|
} |
|
|
|
return $true |
|
} else { |
|
Write-Error "Failed to attach device: $output" |
|
return $false |
|
} |
|
} |
|
|
|
return $true # Dry run |
|
} |
|
} |
|
|
|
function Invoke-UsbDeviceDetach { |
|
<# |
|
.SYNOPSIS |
|
Detaches a USB device from WSL |
|
#> |
|
[CmdletBinding(SupportsShouldProcess)] |
|
param( |
|
[Parameter(Mandatory, ValueFromPipeline)] |
|
[UsbDevice]$Device |
|
) |
|
|
|
process { |
|
if ($Device.State -eq [DeviceState]::Detached) { |
|
Write-Verbose "Device already detached: $($Device.ToString())" |
|
return $true |
|
} |
|
|
|
if ($PSCmdlet.ShouldProcess($Device.ToString(), "Detach from WSL")) { |
|
Write-Verbose "Executing: usbipd detach --busid $($Device.BusId)" |
|
$output = usbipd detach --busid $Device.BusId 2>&1 |
|
|
|
if ($LASTEXITCODE -eq 0) { |
|
Write-Information "✓ Detached: $($Device.ToString())" -InformationAction Continue |
|
|
|
# Optionally verify device is no longer visible in WSL |
|
if (-not $NoVerify) { |
|
Start-Sleep -Milliseconds 500 # Brief delay for detach to complete |
|
if (-not (Test-DeviceInWsl -Device $Device)) { |
|
Write-Information " ✓ Device verified as detached from WSL" -InformationAction Continue |
|
} else { |
|
Write-Warning " Device may still be visible in WSL" |
|
} |
|
} |
|
|
|
return $true |
|
} elseif ($output -match 'not attached|no client') { |
|
Write-Verbose "Device was not attached: $($Device.ToString())" |
|
return $true |
|
} else { |
|
Write-Error "Failed to detach device: $output" |
|
return $false |
|
} |
|
} |
|
|
|
return $true # Dry run |
|
} |
|
} |
|
|
|
function Show-WslUsbDevices { |
|
<# |
|
.SYNOPSIS |
|
Shows all USB devices currently visible in WSL |
|
#> |
|
[CmdletBinding()] |
|
param() |
|
|
|
Write-Information "`nUSB devices currently visible in WSL:" -InformationAction Continue |
|
try { |
|
$lsusbOutput = wsl.exe lsusb 2>$null |
|
if ($LASTEXITCODE -eq 0 -and $lsusbOutput) { |
|
$lsusbOutput | ForEach-Object { |
|
Write-Information " $_" -InformationAction Continue |
|
} |
|
} else { |
|
Write-Information " (No devices found or lsusb not available)" -InformationAction Continue |
|
} |
|
} |
|
catch { |
|
Write-Warning "Could not list USB devices in WSL: $_" |
|
} |
|
} |
|
|
|
# ============================================================================ |
|
# Main Script Logic |
|
# ============================================================================ |
|
|
|
# Check prerequisites |
|
if (-not (Test-Prerequisites)) { |
|
if (-not $Force) { |
|
exit [ExitCode]::NotFound |
|
} |
|
Write-Warning "Continuing despite missing prerequisites (Force mode)" |
|
} |
|
|
|
# Get matching devices |
|
$devices = Get-UsbDevices -VendorId $VendorId -ProductId $ProductId -BusId $BusId |
|
|
|
if ($devices.Count -eq 0) { |
|
if ($BusId) { |
|
Write-Error "No devices found with BusId(s): $($BusId -join ', ')" |
|
} elseif ($VendorId -and $ProductId) { |
|
Write-Error "No devices found with VID:$VendorId PID:$ProductId" |
|
} elseif ($VendorId) { |
|
Write-Error "No devices found with VID:$VendorId" |
|
} elseif ($ProductId) { |
|
Write-Error "No devices found with PID:$ProductId" |
|
} else { |
|
Write-Error "No YubiKey devices found" |
|
} |
|
exit [ExitCode]::NotFound |
|
} |
|
|
|
# Display found devices |
|
Write-Information "Found $($devices.Count) matching device(s):" -InformationAction Continue |
|
foreach ($device in $devices) { |
|
$status = if ($device.State -eq [DeviceState]::Attached) { "[Attached]" } else { "[Detached]" } |
|
Write-Information " $status $($device.ToString())" -InformationAction Continue |
|
} |
|
|
|
# Select devices to process |
|
$devicesToProcess = if ($All) { $devices } else { $devices | Select-Object -First 1 } |
|
|
|
if (-not $All -and $devices.Count -gt 1) { |
|
Write-Information "Processing first device only. Use -All to process all devices." -InformationAction Continue |
|
} |
|
|
|
# Process devices |
|
$results = @{ |
|
Success = 0 |
|
Failed = 0 |
|
} |
|
|
|
foreach ($device in $devicesToProcess) { |
|
$success = if ($Detach) { |
|
Invoke-UsbDeviceDetach -Device $device |
|
} else { |
|
Invoke-UsbDeviceAttach -Device $device |
|
} |
|
|
|
if ($success) { |
|
$results.Success++ |
|
} else { |
|
$results.Failed++ |
|
} |
|
} |
|
|
|
# Summary |
|
Write-Information "" -InformationAction Continue |
|
$action = if ($Detach) { "Detached" } else { "Attached" } |
|
Write-Information "Summary: $action $($results.Success), Failed $($results.Failed)" -InformationAction Continue |
|
|
|
# Optionally show all USB devices in WSL after attachment |
|
if (-not $Detach -and $results.Success -gt 0 -and -not $NoVerify) { |
|
Show-WslUsbDevices |
|
} |
|
|
|
# Exit with appropriate code |
|
if ($results.Failed -eq 0) { |
|
exit [ExitCode]::Success |
|
} elseif ($results.Success -gt 0) { |
|
exit [ExitCode]::PartialFailure |
|
} else { |
|
exit [ExitCode]::TotalFailure |
|
} |