diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ad4ccee..fa11dd7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,14 +14,12 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v7 - name: Setup .NET - uses: actions/setup-dotnet@v4 + uses: actions/setup-dotnet@v6 with: - dotnet-version: | - 8.0.x - 9.0.x + dotnet-version: 8.0.x - name: Restore run: dotnet restore HyperVConsoleKit.sln @@ -34,3 +32,20 @@ jobs: - name: Pack run: dotnet pack src\HyperVConsoleKit\HyperVConsoleKit.csproj --configuration Release --no-build --output artifacts\packages + + - name: Publish self-contained compatibility probe + run: dotnet publish samples\CompatibilityProbe\CompatibilityProbe.csproj --configuration Release --runtime win-x64 --self-contained true --output artifacts\compatibility-probe + + - name: Verify package and publish artifacts + shell: pwsh + run: .\build\Verify-Artifacts.ps1 -PackageDirectory artifacts\packages -PublishDirectory artifacts\compatibility-probe + + - name: Upload compatibility validation bundle + uses: actions/upload-artifact@v7 + with: + name: HyperVConsoleKit-0.6.0-preview.1-validation + path: | + artifacts/compatibility-probe/** + artifacts/packages/** + if-no-files-found: error + retention-days: 14 diff --git a/.github/workflows/publish-nuget.yml b/.github/workflows/publish-nuget.yml index f7f2078..dca2ba7 100644 --- a/.github/workflows/publish-nuget.yml +++ b/.github/workflows/publish-nuget.yml @@ -16,10 +16,10 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v7 - name: Setup .NET - uses: actions/setup-dotnet@v4 + uses: actions/setup-dotnet@v6 with: dotnet-version: 9.0.x @@ -42,9 +42,20 @@ jobs: throw "Release tag '${{ github.event.release.tag_name }}' does not match package version 'v$version'." } + - name: Verify physical compatibility matrix + shell: pwsh + run: .\build\Verify-CompatibilityMatrix.ps1 + - name: Pack run: dotnet pack src\HyperVConsoleKit\HyperVConsoleKit.csproj --configuration Release --no-build --output artifacts\packages + - name: Publish self-contained compatibility probe + run: dotnet publish samples\CompatibilityProbe\CompatibilityProbe.csproj --configuration Release --runtime win-x64 --self-contained true --output artifacts\compatibility-probe + + - name: Verify package and publish artifacts + shell: pwsh + run: .\build\Verify-Artifacts.ps1 -PackageDirectory artifacts\packages -PublishDirectory artifacts\compatibility-probe + - name: NuGet login uses: NuGet/login@v1 id: login diff --git a/HyperVConsoleKit.sln b/HyperVConsoleKit.sln index 547c14c..d1098ad 100644 --- a/HyperVConsoleKit.sln +++ b/HyperVConsoleKit.sln @@ -23,6 +23,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{0AB3BF05 EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "HyperVConsoleKit.Tests", "tests\HyperVConsoleKit.Tests\HyperVConsoleKit.Tests.csproj", "{82276E05-EF92-4499-89FC-1CC227702324}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CompatibilityProbe", "samples\CompatibilityProbe\CompatibilityProbe.csproj", "{9B4C74C8-9E41-4681-B8EA-9EF538057F63}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -117,6 +119,18 @@ Global {82276E05-EF92-4499-89FC-1CC227702324}.Release|x64.Build.0 = Release|Any CPU {82276E05-EF92-4499-89FC-1CC227702324}.Release|x86.ActiveCfg = Release|Any CPU {82276E05-EF92-4499-89FC-1CC227702324}.Release|x86.Build.0 = Release|Any CPU + {9B4C74C8-9E41-4681-B8EA-9EF538057F63}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {9B4C74C8-9E41-4681-B8EA-9EF538057F63}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9B4C74C8-9E41-4681-B8EA-9EF538057F63}.Debug|x64.ActiveCfg = Debug|Any CPU + {9B4C74C8-9E41-4681-B8EA-9EF538057F63}.Debug|x64.Build.0 = Debug|Any CPU + {9B4C74C8-9E41-4681-B8EA-9EF538057F63}.Debug|x86.ActiveCfg = Debug|Any CPU + {9B4C74C8-9E41-4681-B8EA-9EF538057F63}.Debug|x86.Build.0 = Debug|Any CPU + {9B4C74C8-9E41-4681-B8EA-9EF538057F63}.Release|Any CPU.ActiveCfg = Release|Any CPU + {9B4C74C8-9E41-4681-B8EA-9EF538057F63}.Release|Any CPU.Build.0 = Release|Any CPU + {9B4C74C8-9E41-4681-B8EA-9EF538057F63}.Release|x64.ActiveCfg = Release|Any CPU + {9B4C74C8-9E41-4681-B8EA-9EF538057F63}.Release|x64.Build.0 = Release|Any CPU + {9B4C74C8-9E41-4681-B8EA-9EF538057F63}.Release|x86.ActiveCfg = Release|Any CPU + {9B4C74C8-9E41-4681-B8EA-9EF538057F63}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -129,5 +143,6 @@ Global {40CCF4A0-91E2-4B01-B45C-0DAC2F0DB07C} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} {DEB5D1AA-CA37-4BBA-BF7C-43A72B026720} = {5D20AA90-6969-D8BD-9DCD-8634F4692FDA} {82276E05-EF92-4499-89FC-1CC227702324} = {0AB3BF05-4346-4AA6-1389-037BE0695223} + {9B4C74C8-9E41-4681-B8EA-9EF538057F63} = {5D20AA90-6969-D8BD-9DCD-8634F4692FDA} EndGlobalSection EndGlobal diff --git a/README.md b/README.md index c0084e0..42e71dc 100644 --- a/README.md +++ b/README.md @@ -26,22 +26,25 @@ It is not trying to replace RDP, VMConnect, ScreenConnect, TeamViewer, or a prop The core library targets: ```xml -net46;netstandard2.0;net8.0-windows +net8.0-windows ``` -That means it is compatible with modern .NET on Windows, including .NET Core/.NET services that can consume `netstandard2.0`, plus .NET 8 Windows apps and services. It is not cross-platform .NET because Hyper-V console capture and input are Windows-only WMI APIs. +The supported deployment shape is a self-contained .NET 8 Windows x64 application or service. It is not +cross-platform because Hyper-V console capture and input are Windows-only management APIs. The NuGet package includes XML documentation for IntelliSense and generated API documentation. ## How It Works -The raw emergency console path uses the Hyper-V WMI provider: +The raw emergency console path uses `Microsoft.Management.Infrastructure` with a local CIM session connected to +the Hyper-V provider: ```text root\virtualization\v2 ``` Screen capture uses Hyper-V thumbnail capture. Input uses Hyper-V virtual keyboard and mouse WMI devices where available. +The package does not depend on `System.Management` and does not load `wminet_utils.dll`. Single captures return raw `Rgb565` bytes sized exactly: @@ -81,7 +84,7 @@ Your process must run on the Hyper-V host with administrator rights or as LocalS ```csharp using HyperVConsoleKit; -var client = new HyperVConsoleClient(); +using var client = new HyperVConsoleClient(); foreach (var vm in client.GetVirtualMachines()) { @@ -89,6 +92,9 @@ foreach (var vm in client.GetVirtualMachines()) } ``` +`HyperVConsoleClient` owns its local CIM session and is disposable. Dispose any console sessions before disposing +their client. + ## Agent Policy If you are embedding this in an MSP agent, set policy at the client or session boundary. This keeps your service from accidentally opening an unlimited, full-resolution, full-input remote console. @@ -110,7 +116,7 @@ var policy = new HyperVConsolePolicy AllowPowerControl = false }; -var client = new HyperVConsoleClient(policy); +using var client = new HyperVConsoleClient(policy); ``` Power control defaults to disabled. Policy and stream options are copied when retained, so changing the original @@ -177,7 +183,7 @@ using var session = client.OpenConsole( ```csharp using HyperVConsoleKit; -var client = new HyperVConsoleClient(); +using var client = new HyperVConsoleClient(); var vms = client.GetVirtualMachines(); foreach (var vm in vms) @@ -202,7 +208,7 @@ Use this before deciding what UI to show a technician. ```csharp using HyperVConsoleKit; -var client = new HyperVConsoleClient(); +using var client = new HyperVConsoleClient(); var vm = client.GetVirtualMachines().First(v => v.Name == "Recovery VM"); var caps = client.GetConsoleCapabilities(vm.Id); @@ -241,7 +247,7 @@ recommends `RawHostConsole`; Enhanced Session remains an optional local VMConnec ```csharp using HyperVConsoleKit; -var client = new HyperVConsoleClient(); +using var client = new HyperVConsoleClient(); var vm = client.GetVirtualMachines().First(v => v.IsRunning); using var session = client.OpenConsole(vm.Id, new HyperVConsoleOpenOptions @@ -321,7 +327,7 @@ The streaming API gives you frames and lets you choose the transport. ```csharp using HyperVConsoleKit; -var client = new HyperVConsoleClient(); +using var client = new HyperVConsoleClient(); var vm = client.GetVirtualMachines().First(v => v.IsRunning); using var session = client.OpenConsole(vm.Id, new HyperVConsoleOpenOptions @@ -603,7 +609,7 @@ shortcuts may still need the sample's dedicated command buttons. ## Power Controls ```csharp -var powerClient = new HyperVConsoleClient(new HyperVConsolePolicy +using var powerClient = new HyperVConsoleClient(new HyperVConsolePolicy { AllowPowerControl = true }); @@ -758,8 +764,14 @@ dotnet test HyperVConsoleKit.sln ``` Current tests cover pixel conversion, tile diffing, policy validation/cloning, input contracts, and frame-hub -lifecycle/keyframe behavior. GitHub CI builds, tests, and packs on pushes and pull requests; publishing repeats -the tests and verifies that a release tag matches the package version. +lifecycle/keyframe behavior. They also cover older-provider shapes, optional properties, immediate and asynchronous +Hyper-V method completion, and dependency rejection. GitHub CI builds, tests, creates a self-contained x64 probe, +and verifies package/publish artifacts; publishing repeats the checks and verifies that a release tag matches the +package version. + +For physical-host compatibility validation, build and run the unchanged probe described in +[`docs/COMPATIBILITY_VALIDATION.md`](docs/COMPATIBILITY_VALIDATION.md). Physical Hyper-V validation is required +for every supported Windows release; CI alone is insufficient. ## WebSocket Frame Shape Used By The Sample @@ -813,6 +825,7 @@ Guest is broken -> use RawHostConsole fallback ## Requirements - Windows. +- .NET 8, or a self-contained .NET 8 `win-x64` publish. - Hyper-V installed. - Administrator privileges or LocalSystem. - Hyper-V WMI provider at `root\virtualization\v2`. @@ -824,6 +837,8 @@ Guest is broken -> use RawHostConsole fallback - Enhanced Session is detected and launchable, but not headlessly streamed. - Mouse input depends on Hyper-V mouse device availability and guest behavior. - International keyboard layouts and IME scenarios may need extra mapping work. +- The compatibility matrix and current physical-host evidence are tracked in + [`docs/COMPATIBILITY_VALIDATION.md`](docs/COMPATIBILITY_VALIDATION.md). - The sample web console has baseline controls, but still needs product-grade identity, authorization, TLS, distributed rate limiting, durable audit storage, and operational monitoring before internet exposure. diff --git a/THIRD-PARTY-NOTICES.md b/THIRD-PARTY-NOTICES.md new file mode 100644 index 0000000..02e00e1 --- /dev/null +++ b/THIRD-PARTY-NOTICES.md @@ -0,0 +1,15 @@ +# Third-party notices + +HyperVConsoleKit uses the following third-party package: + +## Microsoft.Management.Infrastructure 3.0.0 + +Copyright (c) Microsoft Corporation. + +Licensed under the MIT License. The package license and source are available from: + +- https://www.nuget.org/packages/Microsoft.Management.Infrastructure/3.0.0 +- https://github.com/PowerShell/MMI + +Microsoft.Management.Infrastructure is used for local Windows CIM access. HyperVConsoleKit does not use +`System.Management`. diff --git a/build/Verify-Artifacts.ps1 b/build/Verify-Artifacts.ps1 new file mode 100644 index 0000000..d357485 --- /dev/null +++ b/build/Verify-Artifacts.ps1 @@ -0,0 +1,68 @@ +param( + [Parameter(Mandatory = $true)] + [string]$PackageDirectory, + + [Parameter(Mandatory = $true)] + [string]$PublishDirectory +) + +$ErrorActionPreference = 'Stop' + +$packageDirectoryPath = (Resolve-Path -LiteralPath $PackageDirectory).Path +$publishDirectoryPath = (Resolve-Path -LiteralPath $PublishDirectory).Path + +$forbiddenPublishFiles = Get-ChildItem -LiteralPath $publishDirectoryPath -Recurse -File | + Where-Object { $_.Name -ieq 'System.Management.dll' } +if ($forbiddenPublishFiles) { + throw "System.Management.dll was found in publish output: $($forbiddenPublishFiles.FullName -join ', ')" +} + +$requiredNativeAsset = Get-ChildItem -LiteralPath $publishDirectoryPath -Recurse -File | + Where-Object { $_.Name -ieq 'Microsoft.Management.Infrastructure.Native.Unmanaged.dll' } | + Select-Object -First 1 +if (-not $requiredNativeAsset) { + throw 'The x64 Microsoft.Management.Infrastructure native runtime asset is missing from publish output.' +} + +Add-Type -AssemblyName System.IO.Compression +$projectPath = Join-Path $PSScriptRoot '..\src\HyperVConsoleKit\HyperVConsoleKit.csproj' +[xml]$project = Get-Content -LiteralPath $projectPath +$packageVersion = $project.Project.PropertyGroup.Version | Select-Object -First 1 +$expectedPackageName = "HyperVConsoleKit.$packageVersion.nupkg" +$packages = Get-ChildItem -LiteralPath $packageDirectoryPath -Filter $expectedPackageName -File +if (-not $packages) { + throw "Expected NuGet package $expectedPackageName was not found in $packageDirectoryPath." +} + +foreach ($package in $packages) { + $archive = [System.IO.Compression.ZipFile]::OpenRead($package.FullName) + try { + $forbiddenEntries = $archive.Entries | + Where-Object { $_.FullName -match '(^|/)System\.Management(\.dll)?$' } + if ($forbiddenEntries) { + throw "System.Management was found in package $($package.Name)." + } + + $nuspec = $archive.Entries | Where-Object { $_.FullName -like '*.nuspec' } | Select-Object -First 1 + if (-not $nuspec) { + throw "Package $($package.Name) has no nuspec." + } + + $reader = [System.IO.StreamReader]::new($nuspec.Open()) + try { + $nuspecText = $reader.ReadToEnd() + } + finally { + $reader.Dispose() + } + + if ($nuspecText -match 'System\.Management') { + throw "System.Management was found in the generated nuspec for $($package.Name)." + } + } + finally { + $archive.Dispose() + } +} + +Write-Host 'Artifact verification passed: no System.Management package/assembly and the MMI x64 runtime asset is present.' diff --git a/build/Verify-CompatibilityMatrix.ps1 b/build/Verify-CompatibilityMatrix.ps1 new file mode 100644 index 0000000..2083fcf --- /dev/null +++ b/build/Verify-CompatibilityMatrix.ps1 @@ -0,0 +1,14 @@ +param( + [string]$MatrixPath = 'validation\compatibility-matrix.json' +) + +$ErrorActionPreference = 'Stop' + +$matrix = Get-Content -LiteralPath $MatrixPath -Raw | ConvertFrom-Json +$pending = @($matrix.required | Where-Object { $_.status -ne 'Pass' }) +if ($pending.Count -gt 0) { + $names = $pending | ForEach-Object { "$($_.operatingSystem) $($_.architecture): $($_.status)" } + throw "Physical compatibility validation is incomplete: $($names -join '; ')" +} + +Write-Host "Physical compatibility matrix passed for package $($matrix.packageVersion)." diff --git a/docs/COMPATIBILITY_VALIDATION.md b/docs/COMPATIBILITY_VALIDATION.md new file mode 100644 index 0000000..6685b62 --- /dev/null +++ b/docs/COMPATIBILITY_VALIDATION.md @@ -0,0 +1,51 @@ +# HyperVConsoleKit 0.6 compatibility validation + +The same self-contained `win-x64` probe must pass on every supported operating system before the prerelease is +published. + +## Required matrix + +| Operating system | Status | Evidence | +| --- | --- | --- | +| Windows Server 2012 R2 x64 with ESU | Pending | Required physical Hyper-V host | +| Windows Server 2016 x64 | Pending | Must include the host exhibiting the original `wminet_utils.dll` failure | +| Windows Server 2019 x64 | Pending | Required physical Hyper-V host | +| Windows Server 2022 x64 | Pending | Required physical Hyper-V host | +| Windows Server 2025 x64 | Passed | Build 26100; local Hyper-V probe passed | +| Windows 10 x64 | Pending | Required physical Hyper-V host | +| Windows 11 x64 | Pending | Required physical Hyper-V host | + +The Server 2025 run passed VM enumeration and lookup, capability discovery, first and sustained capture, +recommended-size capture, mouse movement and click, Enter, Shift+Tab, Ctrl+Alt+Del, repeated open/close, +client disposal, asynchronous-job VM start, force-stop with restoration to the original offline state, and +absence of both `System.Management.dll` and `wminet_utils.dll`. The unchanged self-contained probe passed once as +an elevated administrator and once as `NT AUTHORITY\SYSTEM`. + +## Build the unchanged probe + +```powershell +dotnet publish samples\CompatibilityProbe\CompatibilityProbe.csproj ` + --configuration Release ` + --runtime win-x64 ` + --self-contained true ` + --output artifacts\compatibility-probe +``` + +Copy the complete `artifacts\compatibility-probe` directory to each host. From an elevated prompt on the Hyper-V +host, with a designated running test VM: + +```powershell +.\CompatibilityProbe.exe ` + --vm "Test VM" ` + --send-input ` + --output .\compatibility-result.json +``` + +`--send-input` sends a mouse click, Enter, Shift+Tab, and Ctrl+Alt+Del to the selected VM. Use only a designated +test VM. When starting from an offline test VM, add `--power-cycle` to exercise the package's start and force-stop +paths; the probe returns the VM to its original offline state. + +Repeat the probe under `LocalSystem`, using the same files and VM. Retain the JSON report with the OS build, +process architecture, package version, per-check results, loaded managed assemblies, and loaded native modules. + +CI success is not a substitute for these physical-host results. diff --git a/docs/MANAGEMENT_MIGRATION.md b/docs/MANAGEMENT_MIGRATION.md new file mode 100644 index 0000000..e8a0531 --- /dev/null +++ b/docs/MANAGEMENT_MIGRATION.md @@ -0,0 +1,56 @@ +# Hyper-V management migration inventory + +HyperVConsoleKit 0.6 replaces `System.Management` with an internal adapter over +`Microsoft.Management.Infrastructure`. The adapter opens only a local `CimSession`; it does not configure +WS-Man, credentials, listeners, or remote management. + +## Current provider surface + +Namespace: `root\virtualization\v2` + +### Queries + +| Purpose | WQL | +| --- | --- | +| Enumerate VMs | `SELECT * FROM Msvm_ComputerSystem WHERE Caption = 'Virtual Machine'` | +| Find VM | Same query with `Name = ''` | +| Find management service | `SELECT * FROM Msvm_VirtualSystemManagementService` | + +### Properties + +| Class | Properties | +| --- | --- | +| `Msvm_ComputerSystem` | `Name`, `ElementName`, `EnabledState` | +| `Msvm_VirtualSystemSettingData` | optional `EnhancedSessionTransportType` | +| `Msvm_VideoHead` | optional `CurrentHorizontalResolution`, `CurrentVerticalResolution`, `CurrentBitsPerPixel`, `EnabledState` | +| `Msvm_ConcreteJob` | `JobState`, optional `ErrorCode` | + +### Associations + +| Source | Association | Result | +| --- | --- | --- | +| `Msvm_ComputerSystem` | `Msvm_SettingsDefineState` | `Msvm_VirtualSystemSettingData` | +| `Msvm_ComputerSystem` | `Msvm_SystemDevice` | `Msvm_VideoHead` | +| `Msvm_ComputerSystem` | `Msvm_SystemDevice` | `Msvm_Keyboard` | +| `Msvm_ComputerSystem` | `Msvm_SystemDevice` | `Msvm_SyntheticMouse` | + +### Methods + +| Class | Methods and inputs | +| --- | --- | +| `Msvm_ComputerSystem` | `RequestStateChange(RequestedState)` | +| `Msvm_VirtualSystemManagementService` | `GetVirtualSystemThumbnailImage(TargetSystem, WidthPixels, HeightPixels)` | +| `Msvm_Keyboard` | `TypeText(asciiText)`, `TypeKey(keyCode)`, `PressKey(keyCode)`, `ReleaseKey(keyCode)`, `TypeScancodes(scanCodes)`, `TypeCtrlAltDel()` | +| `Msvm_SyntheticMouse` | `SetAbsolutePosition(HorizontalPosition, VerticalPosition)`, `ClickButton(ButtonIndex)` | + +Every method processes immediate return code `0`. Return code `4096` resolves and polls the returned +`Msvm_ConcreteJob`; other provider return codes retain the existing `HyperVWmiException` contract. + +## Compatibility behavior + +- Missing optional settings or video properties produce unknown/default capabilities instead of failing VM enumeration. +- Provider availability, access denial, invalid class, timeout, and operational failures are classified separately. +- Management implementation types remain internal. +- `HyperVConsoleClient` now owns the local CIM session and implements `IDisposable`. Console sessions must be + disposed before their client. +- Package and CI checks reject `System.Management` in references, package metadata, and publish output. diff --git a/docs/PACKAGE_DEPENDENCIES.md b/docs/PACKAGE_DEPENDENCIES.md new file mode 100644 index 0000000..7dfca24 --- /dev/null +++ b/docs/PACKAGE_DEPENDENCIES.md @@ -0,0 +1,28 @@ +# Package dependency report + +Package: `HyperVConsoleKit 0.6.0-preview.1` + +Target: `net8.0-windows7.0` + +## Direct dependency + +| Package | Version | +| --- | --- | +| `Microsoft.Management.Infrastructure` | `3.0.0` | + +## Transitive dependencies + +| Package | Version | +| --- | --- | +| `Microsoft.Management.Infrastructure.Runtime.Win` | `3.0.0` | +| `Microsoft.Management.Infrastructure.Runtime.Unix` | `3.0.0` | + +NuGet resolves the Windows runtime assets for the supported `win-x64` publish. The published self-contained probe +contains: + +- `Microsoft.Management.Infrastructure.dll` +- `Microsoft.Management.Infrastructure.Native.dll` +- `Microsoft.Management.Infrastructure.Native.Unmanaged.dll` + +The project reference graph, generated package nuspec, `.nupkg`, and self-contained publish output contain no +`System.Management` dependency or assembly. diff --git a/docs/PUBLIC_API_COMPATIBILITY.md b/docs/PUBLIC_API_COMPATIBILITY.md new file mode 100644 index 0000000..44d06a0 --- /dev/null +++ b/docs/PUBLIC_API_COMPATIBILITY.md @@ -0,0 +1,26 @@ +# Public API compatibility report + +Comparison baseline: published HyperVConsoleKit 0.5.0 `net8.0-windows7.0` assembly. + +## Result + +The existing .NET 8 public type and member surface is retained. Hyper-V management implementation types remain +internal. + +Additions in 0.6: + +- `HyperVConsoleClient` implements `IDisposable` and adds `Dispose()`. +- `HyperVProviderUnavailableException` +- `HyperVProviderClassUnavailableException` +- `HyperVManagementTimeoutException` + +The new exceptions derive from the existing `HyperVConsoleException`. Existing access-denied, VM-not-found, +capture, keyboard, mouse, and WMI return-code exception contracts remain available. + +## Intentional compatibility change + +The package now contains only `net8.0-windows7.0`. The former `net46` and `netstandard2.0` assets were intentionally +removed because ScreenGymp's self-contained .NET 8 Windows x64 agent is the sole consumer. + +Console sessions must be disposed before their owning client. Existing code that did not dispose the client +continues to compile, but new code should dispose it so the local CIM session is released deterministically. diff --git a/samples/CompatibilityProbe/CompatibilityProbe.csproj b/samples/CompatibilityProbe/CompatibilityProbe.csproj new file mode 100644 index 0000000..6d8f255 --- /dev/null +++ b/samples/CompatibilityProbe/CompatibilityProbe.csproj @@ -0,0 +1,15 @@ + + + + Exe + net8.0-windows + enable + enable + win-x64 + + + + + + + diff --git a/samples/CompatibilityProbe/Program.cs b/samples/CompatibilityProbe/Program.cs new file mode 100644 index 0000000..7d5291f --- /dev/null +++ b/samples/CompatibilityProbe/Program.cs @@ -0,0 +1,428 @@ +using System.Diagnostics; +using System.Reflection; +using System.Runtime.InteropServices; +using System.Text.Json; +using HyperVConsoleKit; + +var vmSelector = GetOption("--vm"); +var outputPath = GetOption("--output"); +var sendInput = args.Contains("--send-input", StringComparer.OrdinalIgnoreCase); +var powerCycle = args.Contains("--power-cycle", StringComparer.OrdinalIgnoreCase); +var checks = new List(); +var startedUtc = DateTime.UtcNow; +var startedByProbe = false; + +using var client = new HyperVConsoleClient(new HyperVConsolePolicy { AllowPowerControl = powerCycle }); +var virtualMachines = Run( + "VM enumeration", + () => client.GetVirtualMachines(), + checks, + Array.Empty()); + +var vm = ResolveVm(vmSelector, virtualMachines); +if (vm == null) +{ + checks.Add(ProbeCheck.Fail("VM selection", "No matching running virtual machine was found.")); +} +else +{ + if (powerCycle && !vm.IsRunning) + { + var started = Run( + "Power start", + () => + { + client.StartVirtualMachine(vm.Id); + return WaitForState(client, vm.Id, expectedRunning: true, TimeSpan.FromMinutes(1)); + }, + checks, + null); + if (started != null) + { + vm = started; + startedByProbe = true; + Run( + "Console readiness", + () => WaitForConsoleReady(client, vm.Id, TimeSpan.FromMinutes(1)), + checks, + false); + } + } + + Run("VM lookup", () => client.GetVirtualMachine(vm.Id), checks, null); + var capabilities = Run("Console capabilities", () => client.GetConsoleCapabilities(vm.Id), checks, null); + + if (capabilities != null && capabilities.CanCaptureNow) + { + using var session = Run( + "Console opening", + () => client.OpenConsole(vm.Id), + checks, + null); + + if (session != null) + { + var smallFrame = Run( + "First frame", + () => session.CaptureFrame(new ConsoleFrameOptions { Width = 64, Height = 64 }), + checks, + null); + if (smallFrame != null) + { + Check( + "First frame shape", + smallFrame.RawBytes.Length == smallFrame.Width * smallFrame.Height * 2, + $"{smallFrame.Width}x{smallFrame.Height}, {smallFrame.RawBytes.Length} bytes", + checks); + } + + var recommendedFrame = Run( + "Recommended frame", + () => session.CaptureFrame(new ConsoleFrameOptions()), + checks, + null); + + Run( + "Sustained capture", + () => + { + using var cancellation = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + var frames = 0; + try + { + session.StreamFramesAsync( + new ConsoleFrameStreamOptions + { + Width = 64, + Height = 64, + PixelFormat = ConsoleFramePixelFormat.Rgb332, + ActiveFramesPerSecond = 3, + IdleFramesPerSecond = 1 + }, + (frame, token) => + { + frames++; + return Task.CompletedTask; + }, + cancellation.Token).GetAwaiter().GetResult(); + } + catch (OperationCanceledException) + { + } + + if (frames < 2) + { + throw new InvalidOperationException($"Expected at least two frames, received {frames}."); + } + + return frames; + }, + checks, + 0); + + if (capabilities.CanSendMouseInputNow && recommendedFrame != null) + { + var x = recommendedFrame.Width / 2; + var y = recommendedFrame.Height / 2; + Check("Mouse movement", session.TrySendMouseMove(x, y), $"{x},{y}", checks); + if (sendInput) + { + Check("Mouse click", session.TrySendMouseClick(x, y, MouseButton.Left), "Left", checks); + } + else + { + checks.Add(ProbeCheck.Skip("Mouse click", "Pass --send-input to exercise guest input.")); + } + } + + if (capabilities.CanSendKeyboardInputNow && sendInput) + { + RunAction("Enter input", () => session.SendKey(ConsoleKeyCode.Enter), checks); + RunAction( + "Shift+Tab input", + () => session.SendChord(ConsoleKeyCode.Shift, ConsoleKeyCode.Tab), + checks); + RunAction("Ctrl+Alt+Del input", session.SendCtrlAltDel, checks); + } + else + { + checks.Add(ProbeCheck.Skip("Keyboard input", "Unavailable or --send-input was not supplied.")); + } + } + + RunAction( + "Repeated open and close", + () => + { + for (var index = 0; index < 3; index++) + { + using var repeatedSession = client.OpenConsole(vm.Id); + repeatedSession.CaptureFrame(new ConsoleFrameOptions { Width = 64, Height = 64 }); + } + }, + checks); + } + else + { + checks.Add(ProbeCheck.Fail("Live console checks", "The selected VM cannot capture in its current state.")); + } +} + +if (startedByProbe && vm != null) +{ + Run( + "Power force stop", + () => + { + client.StopVirtualMachine(vm.Id, force: true); + return WaitForState(client, vm.Id, expectedRunning: false, TimeSpan.FromMinutes(1)); + }, + checks, + null); +} + +var loadedAssemblies = AppDomain.CurrentDomain.GetAssemblies() + .Select(assembly => assembly.GetName().Name ?? string.Empty) + .Where(name => name.Length > 0) + .OrderBy(name => name, StringComparer.OrdinalIgnoreCase) + .ToArray(); +var loadedModules = GetLoadedModules(); + +Check( + "System.Management assembly absence", + !loadedAssemblies.Contains("System.Management", StringComparer.OrdinalIgnoreCase), + "System.Management.dll is not loaded.", + checks); +Check( + "wminet_utils module absence", + !loadedModules.Any(path => string.Equals(Path.GetFileName(path), "wminet_utils.dll", StringComparison.OrdinalIgnoreCase)), + "wminet_utils.dll is not loaded.", + checks); + +client.Dispose(); +RunAction( + "Disposed client rejection", + () => + { + try + { + client.GetVirtualMachines(); + } + catch (ObjectDisposedException) + { + return; + } + + throw new InvalidOperationException("Disposed client accepted an operation."); + }, + checks); + +var report = new ProbeReport +{ + StartedUtc = startedUtc, + CompletedUtc = DateTime.UtcNow, + OsDescription = RuntimeInformation.OSDescription, + OsVersion = Environment.OSVersion.VersionString, + OsArchitecture = RuntimeInformation.OSArchitecture.ToString(), + ProcessArchitecture = RuntimeInformation.ProcessArchitecture.ToString(), + FrameworkDescription = RuntimeInformation.FrameworkDescription, + Is64BitProcess = Environment.Is64BitProcess, + Is64BitOperatingSystem = Environment.Is64BitOperatingSystem, + UserName = Environment.UserName, + ProcessIdentity = WindowsIdentityName(), + PackageAssemblyVersion = typeof(HyperVConsoleClient).Assembly + .GetCustomAttribute()?.InformationalVersion, + SelectedVm = vm == null ? null : new ProbeVm + { + Id = vm.Id, + Name = vm.Name, + State = vm.State.ToString() + }, + LoadedAssemblies = loadedAssemblies, + LoadedModules = loadedModules, + Checks = checks, + Passed = checks.All(check => check.Outcome != "Fail") +}; + +var json = JsonSerializer.Serialize(report, new JsonSerializerOptions { WriteIndented = true }); +Console.WriteLine(json); +if (!string.IsNullOrWhiteSpace(outputPath)) +{ + File.WriteAllText(outputPath, json); +} + +return report.Passed ? 0 : 1; + +string? GetOption(string name) +{ + var index = Array.FindIndex(args, arg => string.Equals(arg, name, StringComparison.OrdinalIgnoreCase)); + return index >= 0 && index + 1 < args.Length ? args[index + 1] : null; +} + +static HyperVVirtualMachine? ResolveVm(string? selector, IReadOnlyList virtualMachines) +{ + if (string.IsNullOrWhiteSpace(selector)) + { + return virtualMachines.FirstOrDefault(candidate => candidate.IsRunning); + } + + if (Guid.TryParse(selector, out var id)) + { + return virtualMachines.FirstOrDefault(candidate => candidate.Id == id); + } + + return virtualMachines.FirstOrDefault( + candidate => string.Equals(candidate.Name, selector, StringComparison.OrdinalIgnoreCase)); +} + +static HyperVVirtualMachine WaitForState( + HyperVConsoleClient client, + Guid vmId, + bool expectedRunning, + TimeSpan timeout) +{ + var deadline = DateTime.UtcNow.Add(timeout); + while (DateTime.UtcNow < deadline) + { + var vm = client.GetVirtualMachine(vmId); + if (vm.IsRunning == expectedRunning) + { + return vm; + } + + Thread.Sleep(250); + } + + throw new TimeoutException( + $"VM {vmId} did not reach expected running state {expectedRunning} within {timeout}."); +} + +static bool WaitForConsoleReady(HyperVConsoleClient client, Guid vmId, TimeSpan timeout) +{ + var deadline = DateTime.UtcNow.Add(timeout); + HyperVWmiException? lastInvalidState = null; + while (DateTime.UtcNow < deadline) + { + try + { + using var session = client.OpenConsole(vmId); + session.CaptureFrame(new ConsoleFrameOptions()); + return true; + } + catch (HyperVWmiException ex) when (ex.ReturnCode == 32775) + { + lastInvalidState = ex; + Thread.Sleep(500); + } + } + + throw new TimeoutException( + $"VM {vmId} console did not become ready within {timeout}.", + lastInvalidState); +} + +static T Run(string name, Func operation, ICollection checks, T fallback) +{ + try + { + var result = operation(); + checks.Add(ProbeCheck.Pass(name)); + return result; + } + catch (Exception ex) + { + checks.Add(ProbeCheck.Fail(name, ex.GetType().Name + ": " + ex.Message)); + return fallback; + } +} + +static void RunAction(string name, Action operation, ICollection checks) +{ + Run( + name, + () => + { + operation(); + return true; + }, + checks, + false); +} + +static void Check(string name, bool passed, string detail, ICollection checks) +{ + checks.Add(passed ? ProbeCheck.Pass(name, detail) : ProbeCheck.Fail(name, detail)); +} + +static string[] GetLoadedModules() +{ + try + { + return Process.GetCurrentProcess().Modules + .Cast() + .Select(module => module.FileName) + .Where(path => !string.IsNullOrWhiteSpace(path)) + .OrderBy(path => path, StringComparer.OrdinalIgnoreCase) + .ToArray(); + } + catch (Exception ex) + { + return new[] { "MODULE_ENUMERATION_FAILED: " + ex.GetType().Name + ": " + ex.Message }; + } +} + +static string WindowsIdentityName() +{ + try + { + return System.Security.Principal.WindowsIdentity.GetCurrent().Name; + } + catch + { + return Environment.UserName; + } +} + +internal sealed class ProbeReport +{ + public DateTime StartedUtc { get; set; } + public DateTime CompletedUtc { get; set; } + public string? OsDescription { get; set; } + public string? OsVersion { get; set; } + public string? OsArchitecture { get; set; } + public string? ProcessArchitecture { get; set; } + public string? FrameworkDescription { get; set; } + public bool Is64BitProcess { get; set; } + public bool Is64BitOperatingSystem { get; set; } + public string? UserName { get; set; } + public string? ProcessIdentity { get; set; } + public string? PackageAssemblyVersion { get; set; } + public ProbeVm? SelectedVm { get; set; } + public string[] LoadedAssemblies { get; set; } = Array.Empty(); + public string[] LoadedModules { get; set; } = Array.Empty(); + public IReadOnlyCollection Checks { get; set; } = Array.Empty(); + public bool Passed { get; set; } +} + +internal sealed class ProbeVm +{ + public Guid Id { get; set; } + public string? Name { get; set; } + public string? State { get; set; } +} + +internal sealed class ProbeCheck +{ + public string Name { get; set; } = string.Empty; + public string Outcome { get; set; } = string.Empty; + public string? Detail { get; set; } + + public static ProbeCheck Pass(string name, string? detail = null) => + new() { Name = name, Outcome = "Pass", Detail = detail }; + + public static ProbeCheck Fail(string name, string detail) => + new() { Name = name, Outcome = "Fail", Detail = detail }; + + public static ProbeCheck Skip(string name, string detail) => + new() { Name = name, Outcome = "Skip", Detail = detail }; +} diff --git a/samples/ConsoleDiagnostics/Program.cs b/samples/ConsoleDiagnostics/Program.cs index 6ca170c..6516cb0 100644 --- a/samples/ConsoleDiagnostics/Program.cs +++ b/samples/ConsoleDiagnostics/Program.cs @@ -2,7 +2,7 @@ var vmSelector = args.FirstOrDefault(arg => !arg.StartsWith("--", StringComparison.OrdinalIgnoreCase)); var sendInput = args.Any(arg => string.Equals(arg, "--send-input", StringComparison.OrdinalIgnoreCase)); -var client = new HyperVConsoleClient(); +using var client = new HyperVConsoleClient(); var vms = client.GetVirtualMachines(); if (vms.Count == 0) diff --git a/samples/ConsoleKeyboardInput/Program.cs b/samples/ConsoleKeyboardInput/Program.cs index 708390a..5289659 100644 --- a/samples/ConsoleKeyboardInput/Program.cs +++ b/samples/ConsoleKeyboardInput/Program.cs @@ -6,7 +6,7 @@ return 1; } -var client = new HyperVConsoleClient(); +using var client = new HyperVConsoleClient(); var vm = ResolveVm(args[0], client.GetVirtualMachines()); using (var session = client.OpenConsole(vm.Id)) diff --git a/samples/ConsoleListVms/Program.cs b/samples/ConsoleListVms/Program.cs index 207f29f..3870c5c 100644 --- a/samples/ConsoleListVms/Program.cs +++ b/samples/ConsoleListVms/Program.cs @@ -1,6 +1,6 @@ using HyperVConsoleKit; -var client = new HyperVConsoleClient(); +using var client = new HyperVConsoleClient(); var vms = client.GetVirtualMachines(); Console.WriteLine("Name\tId\tState\tCanCapture\tCanKeyboard\tCanMouse\tEnhanced\tRecommended"); diff --git a/samples/ConsoleScreenshot/Program.cs b/samples/ConsoleScreenshot/Program.cs index d850ff4..1c497ce 100644 --- a/samples/ConsoleScreenshot/Program.cs +++ b/samples/ConsoleScreenshot/Program.cs @@ -1,6 +1,6 @@ using HyperVConsoleKit; -var client = new HyperVConsoleClient(); +using var client = new HyperVConsoleClient(); var vms = client.GetVirtualMachines(); if (vms.Count == 0) { diff --git a/src/HyperVConsoleKit/CimManagement.cs b/src/HyperVConsoleKit/CimManagement.cs new file mode 100644 index 0000000..40052c2 --- /dev/null +++ b/src/HyperVConsoleKit/CimManagement.cs @@ -0,0 +1,483 @@ +using System; +using System.Collections; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using Microsoft.Management.Infrastructure; + +namespace HyperVConsoleKit +{ + /// + /// Small internal adapter over Microsoft.Management.Infrastructure. It intentionally + /// models only the WMI operations used by HyperVConsoleKit. + /// + internal class ManagementScope : IDisposable + { + private readonly ConcurrentDictionary _references = + new ConcurrentDictionary(StringComparer.OrdinalIgnoreCase); + private CimSession _session; + + public ManagementScope(string namespacePath) + { + if (string.IsNullOrWhiteSpace(namespacePath)) + { + throw new ArgumentException("A CIM namespace is required.", nameof(namespacePath)); + } + + NamespacePath = namespacePath; + } + + internal string NamespacePath { get; } + + internal CimSession Session + { + get + { + if (_session == null) + { + throw new ObjectDisposedException(nameof(ManagementScope)); + } + + return _session; + } + } + + public virtual void Connect() + { + try + { + // A null computer name creates an in-process local CIM session. It does + // not opt into WS-Man, credentials, or remote management. + _session = CimSession.Create(null); + } + catch (CimException ex) + { + throw Translate(ex, "Unable to create the local CIM session."); + } + } + + internal virtual IEnumerable Query(string query) + { + try + { + return Session.QueryInstances(NamespacePath, "WQL", query).ToArray(); + } + catch (CimException ex) + { + throw Translate(ex, "The local CIM query failed."); + } + } + + internal virtual IEnumerable GetRelated( + CimInstance source, + string relatedClass, + string associationClass, + string resultRole, + string thisRole) + { + try + { + return Session.EnumerateAssociatedInstances( + NamespacePath, + source, + associationClass, + relatedClass, + thisRole, + resultRole).ToArray(); + } + catch (CimException ex) + { + throw Translate(ex, "The local CIM association query failed."); + } + } + + internal virtual bool HasMethod(CimInstance instance, string methodName) + { + try + { + using (var cimClass = Session.GetClass(NamespacePath, instance.CimSystemProperties.ClassName)) + { + return cimClass.CimClassMethods[methodName] != null; + } + } + catch (CimException ex) + { + throw Translate(ex, "Unable to read CIM method metadata for " + methodName + "."); + } + } + + internal virtual ManagementBaseObject Invoke( + CimInstance instance, + string methodName, + ManagementBaseObject inParameters) + { + try + { + using (var cimClass = Session.GetClass(NamespacePath, instance.CimSystemProperties.ClassName)) + { + var declaration = cimClass.CimClassMethods[methodName]; + if (declaration == null) + { + throw new ManagementException( + "CIM class " + instance.CimSystemProperties.ClassName + + " does not expose method " + methodName + "."); + } + + using (var parameters = new CimMethodParametersCollection()) + { + if (inParameters != null) + { + foreach (var pair in inParameters.Values) + { + var parameterDeclaration = declaration.Parameters[pair.Key]; + if (parameterDeclaration == null) + { + throw new ManagementException( + "CIM method " + methodName + + " has no input parameter named " + pair.Key + "."); + } + + var value = pair.Value is ManagementObject managementObject + ? (object)managementObject.Instance + : pair.Value; + parameters.Add(CimMethodParameter.Create( + pair.Key, + value, + parameterDeclaration.CimType, + CimFlags.In)); + } + } + + return new ManagementBaseObject( + this, + Session.InvokeMethod(NamespacePath, instance, methodName, parameters)); + } + } + } + catch (CimException ex) + { + throw Translate(ex, "The local CIM method call failed."); + } + } + + internal virtual CimInstance Refresh(CimInstance instance) + { + try + { + return Session.GetInstance(NamespacePath, instance); + } + catch (CimException ex) + { + throw Translate(ex, "Unable to refresh the local CIM instance."); + } + } + + internal void RegisterReference(CimInstance instance) + { + var path = instance?.CimSystemProperties?.Path; + if (!string.IsNullOrEmpty(path)) + { + _references[path] = new CimInstance(instance); + } + } + + internal virtual CimInstance ResolveReference(string path) + { + if (string.IsNullOrWhiteSpace(path)) + { + throw new ArgumentException("A CIM instance path is required.", nameof(path)); + } + + if (!_references.TryGetValue(path, out var reference)) + { + throw new ManagementException("The CIM reference was not returned by this management session: " + path); + } + + try + { + return Session.GetInstance(NamespacePath, reference); + } + catch (CimException ex) + { + throw Translate(ex, "Unable to refresh CIM instance " + path + "."); + } + } + + internal object ExportValue(object value) + { + if (value is CimInstance reference) + { + RegisterReference(reference); + return new ManagementObject(this, new CimInstance(reference)); + } + + if (value is CimInstance[] references) + { + return references + .Select(item => (object)new ManagementObject(this, new CimInstance(item))) + .ToArray(); + } + + return value; + } + + internal static Exception Translate(CimException exception, string message) + { + switch (exception.NativeErrorCode) + { + case NativeErrorCode.AccessDenied: + return new UnauthorizedAccessException(message, exception); + case NativeErrorCode.InvalidNamespace: + case NativeErrorCode.ServerIsShuttingDown: + return new HyperVProviderUnavailableException(message, exception); + case NativeErrorCode.InvalidClass: + return new HyperVProviderClassUnavailableException(message, exception); + case NativeErrorCode.InvalidOperationTimeout: + return new HyperVManagementTimeoutException(message, exception); + default: + return new ManagementException(message, exception); + } + } + + public virtual void Dispose() + { + var session = _session; + _session = null; + session?.Dispose(); + + foreach (var reference in _references.Values) + { + reference.Dispose(); + } + + _references.Clear(); + } + } + + internal sealed class ObjectQuery + { + public ObjectQuery(string queryString) + { + QueryString = queryString ?? throw new ArgumentNullException(nameof(queryString)); + } + + internal string QueryString { get; } + } + + internal sealed class ManagementObjectSearcher : IDisposable + { + private readonly ManagementScope _scope; + private readonly ObjectQuery _query; + + public ManagementObjectSearcher(ManagementScope scope, ObjectQuery query) + { + _scope = scope ?? throw new ArgumentNullException(nameof(scope)); + _query = query ?? throw new ArgumentNullException(nameof(query)); + } + + public ManagementObjectCollection Get() + { + return new ManagementObjectCollection( + _scope.Query(_query.QueryString).Select(instance => new ManagementObject(_scope, instance))); + } + + public void Dispose() + { + } + } + + internal sealed class ManagementObjectCollection : IEnumerable, IDisposable + { + private readonly IReadOnlyList _items; + private readonly HashSet _ownedItems; + + public ManagementObjectCollection(IEnumerable items) + { + _items = items.ToArray(); + _ownedItems = new HashSet(_items); + } + + public IEnumerator GetEnumerator() + { + return _items.GetEnumerator(); + } + + IEnumerator IEnumerable.GetEnumerator() + { + return GetEnumerator(); + } + + public void Detach(ManagementObject item) + { + _ownedItems.Remove(item); + } + + public void Dispose() + { + foreach (var item in _ownedItems) + { + item.Dispose(); + } + + _ownedItems.Clear(); + } + } + + internal sealed class ManagementPath + { + public ManagementPath(string path) + { + Path = path; + } + + public string Path { get; } + } + + internal class ManagementBaseObject : IDisposable + { + private readonly Dictionary _values = + new Dictionary(StringComparer.OrdinalIgnoreCase); + private readonly CimMethodResult _methodResult; + + internal ManagementBaseObject() + { + } + + internal ManagementBaseObject(ManagementScope scope, CimMethodResult result) + { + _methodResult = result; + _values["ReturnValue"] = result.ReturnValue?.Value; + foreach (var parameter in result.OutParameters) + { + _values[parameter.Name] = scope.ExportValue(parameter.Value); + } + } + + public object this[string propertyName] + { + get + { + _values.TryGetValue(propertyName, out var value); + return value; + } + set + { + _values[propertyName] = value; + } + } + + internal IEnumerable> Values => _values; + + public virtual void Dispose() + { + foreach (var value in _values.Values.OfType()) + { + value.Dispose(); + } + + _methodResult?.Dispose(); + } + } + + internal sealed class ManagementObject : ManagementBaseObject + { + private readonly ManagementScope _scope; + private CimInstance _instance; + + internal ManagementObject(ManagementScope scope, CimInstance instance) + { + _scope = scope ?? throw new ArgumentNullException(nameof(scope)); + _instance = instance ?? throw new ArgumentNullException(nameof(instance)); + _scope.RegisterReference(instance); + } + + public ManagementObject(ManagementScope scope, ManagementPath path, object options) + : this(scope, scope.ResolveReference(path?.Path)) + { + } + + internal CimInstance Instance + { + get + { + if (_instance == null) + { + throw new ObjectDisposedException(nameof(ManagementObject)); + } + + return _instance; + } + } + + public new object this[string propertyName] + { + get + { + var property = Instance.CimInstanceProperties[propertyName]; + return property == null ? null : _scope.ExportValue(property.Value); + } + } + + public ManagementPath Path => new ManagementPath(Instance.CimSystemProperties.Path); + + public ManagementObjectCollection GetRelated( + string relatedClass, + string associationClass, + string requiredQualifier, + string requiredAssociationQualifier, + string resultRole, + string thisRole, + bool classDefinitionsOnly, + object options) + { + return new ManagementObjectCollection( + _scope.GetRelated(Instance, relatedClass, associationClass, resultRole, thisRole) + .Select(instance => new ManagementObject(_scope, instance))); + } + + public ManagementBaseObject GetMethodParameters(string methodName) + { + if (!_scope.HasMethod(Instance, methodName)) + { + throw new ManagementException( + "CIM class " + Instance.CimSystemProperties.ClassName + + " does not expose method " + methodName + "."); + } + + return new ManagementBaseObject(); + } + + public ManagementBaseObject InvokeMethod(string methodName, ManagementBaseObject inParameters, object options) + { + return _scope.Invoke(Instance, methodName, inParameters); + } + + public void Get() + { + var refreshed = _scope.Refresh(Instance); + var previous = _instance; + _instance = refreshed; + previous.Dispose(); + _scope.RegisterReference(refreshed); + } + + public override void Dispose() + { + var instance = _instance; + _instance = null; + instance?.Dispose(); + base.Dispose(); + } + } + + internal sealed class ManagementException : Exception + { + public ManagementException(string message) : base(message) + { + } + + public ManagementException(string message, Exception innerException) : base(message, innerException) + { + } + } +} diff --git a/src/HyperVConsoleKit/Exceptions.cs b/src/HyperVConsoleKit/Exceptions.cs index b44dd5d..8f50522 100644 --- a/src/HyperVConsoleKit/Exceptions.cs +++ b/src/HyperVConsoleKit/Exceptions.cs @@ -19,6 +19,30 @@ public sealed class HyperVAccessDeniedException : HyperVConsoleException public HyperVAccessDeniedException(string message, Exception innerException = null) : base(message, innerException) { } } + /// + /// Thrown when the local Hyper-V management provider is unavailable. + /// + public sealed class HyperVProviderUnavailableException : HyperVConsoleException + { + public HyperVProviderUnavailableException(string message, Exception innerException = null) : base(message, innerException) { } + } + + /// + /// Thrown when a required Hyper-V provider class is unavailable. + /// + public sealed class HyperVProviderClassUnavailableException : HyperVConsoleException + { + public HyperVProviderClassUnavailableException(string message, Exception innerException = null) : base(message, innerException) { } + } + + /// + /// Thrown when a Hyper-V management operation exceeds its timeout. + /// + public sealed class HyperVManagementTimeoutException : HyperVConsoleException + { + public HyperVManagementTimeoutException(string message, Exception innerException = null) : base(message, innerException) { } + } + /// /// Thrown when the requested VM id cannot be found on the local Hyper-V host. /// diff --git a/src/HyperVConsoleKit/HyperVConsoleClient.cs b/src/HyperVConsoleKit/HyperVConsoleClient.cs index bd35b6f..1d90263 100644 --- a/src/HyperVConsoleKit/HyperVConsoleClient.cs +++ b/src/HyperVConsoleKit/HyperVConsoleClient.cs @@ -4,7 +4,6 @@ using System.Diagnostics; using System.IO; using System.Linq; -using System.Management; using System.Runtime.ExceptionServices; using System.Threading; using System.Threading.Tasks; @@ -18,13 +17,14 @@ namespace HyperVConsoleKit /// The client uses the local Hyper-V WMI provider at root\virtualization\v2 and must run on the Hyper-V host /// with sufficient privileges, typically elevated or as LocalSystem. /// - public sealed class HyperVConsoleClient + public sealed class HyperVConsoleClient : IDisposable { internal const string NamespacePath = @"root\virtualization\v2"; private readonly ManagementScope _scope; private readonly object _wmiLock = new object(); private readonly ConcurrentDictionary _virtualMachineLocks = new ConcurrentDictionary(); private readonly HyperVConsolePolicy _policy; + private int _disposeState; private static readonly TimeSpan DefaultJobTimeout = TimeSpan.FromMinutes(5); /// /// Raised for auditable actions performed by the client or sessions opened by this client. @@ -58,10 +58,15 @@ public HyperVConsoleClient(HyperVConsolePolicy policy) /// Creates a client connected to a specific Hyper-V WMI namespace with a default console policy. /// public HyperVConsoleClient(string namespacePath, HyperVConsolePolicy policy) + : this(new ManagementScope(namespacePath), policy) + { + } + + internal HyperVConsoleClient(ManagementScope scope, HyperVConsolePolicy policy) { _policy = (policy ?? new HyperVConsolePolicy()).Clone(); _policy.Validate(); - _scope = new ManagementScope(namespacePath); + _scope = scope ?? throw new ArgumentNullException(nameof(scope)); _scope.Connect(); } @@ -70,6 +75,7 @@ public HyperVConsoleClient(string namespacePath, HyperVConsolePolicy policy) /// public IReadOnlyList GetVirtualMachines() { + ThrowIfDisposed(); try { lock (_wmiLock) @@ -77,7 +83,16 @@ public IReadOnlyList GetVirtualMachines() using (var searcher = new ManagementObjectSearcher(_scope, new ObjectQuery("SELECT * FROM Msvm_ComputerSystem WHERE Caption = 'Virtual Machine'"))) using (var results = searcher.Get()) { - return results.Cast().Select(ToVirtualMachine).ToList(); + var virtualMachines = new List(); + foreach (var vm in results) + { + using (vm) + { + virtualMachines.Add(ToVirtualMachine(vm)); + } + } + + return virtualMachines; } } } @@ -138,6 +153,7 @@ public IHyperVConsoleSession OpenConsole(Guid virtualMachineId) /// public IHyperVConsoleSession OpenConsole(Guid virtualMachineId, HyperVConsoleOpenOptions options) { + ThrowIfDisposed(); if (options == null) { options = new HyperVConsoleOpenOptions(); @@ -159,6 +175,7 @@ public IHyperVConsoleSession OpenConsole(Guid virtualMachineId, HyperVConsoleOpe internal HyperVConsolePolicy GetPolicySnapshot() { + ThrowIfDisposed(); return _policy.Clone(); } @@ -167,6 +184,7 @@ internal HyperVConsolePolicy GetPolicySnapshot() /// public HyperVConsoleCapabilities GetConsoleCapabilities(Guid virtualMachineId) { + ThrowIfDisposed(); lock (GetVirtualMachineLock(virtualMachineId)) { using (var vm = GetVirtualMachineObject(virtualMachineId)) @@ -181,6 +199,7 @@ public HyperVConsoleCapabilities GetConsoleCapabilities(Guid virtualMachineId) /// public HyperVEnhancedSessionLaunchInfo GetEnhancedSessionLaunchInfo(Guid virtualMachineId) { + ThrowIfDisposed(); var vm = GetVirtualMachine(virtualMachineId); var vmConnectPath = FindVmConnectPath(); return new HyperVEnhancedSessionLaunchInfo @@ -200,6 +219,7 @@ public HyperVEnhancedSessionLaunchInfo GetEnhancedSessionLaunchInfo(Guid virtual /// public bool TryLaunchEnhancedSession(Guid virtualMachineId) { + ThrowIfDisposed(); var launchInfo = GetEnhancedSessionLaunchInfo(virtualMachineId); if (!launchInfo.CanLaunchFromCurrentProcess) { @@ -305,6 +325,7 @@ internal ManagementObject GetVirtualMachineObject(Guid id) { foreach (ManagementObject vm in results) { + results.Detach(vm); return vm; } } @@ -313,6 +334,16 @@ internal ManagementObject GetVirtualMachineObject(Guid id) } internal static void EnsureCompleted(string wmiClass, string methodName, ManagementBaseObject outParams, ManagementScope scope) + { + EnsureCompleted(wmiClass, methodName, outParams, scope, DefaultJobTimeout); + } + + internal static void EnsureCompleted( + string wmiClass, + string methodName, + ManagementBaseObject outParams, + ManagementScope scope, + TimeSpan timeout) { var returnCode = Convert.ToUInt32(outParams["ReturnValue"]); if (returnCode == WmiReturnCode.Completed) @@ -322,7 +353,7 @@ internal static void EnsureCompleted(string wmiClass, string methodName, Managem if (returnCode == WmiReturnCode.Started) { - WaitForJob(wmiClass, methodName, outParams, scope, DefaultJobTimeout); + WaitForJob(wmiClass, methodName, outParams, scope, timeout); return; } @@ -336,20 +367,21 @@ internal static void EnsureCompleted(string wmiClass, string methodName, Managem private static void WaitForJob(string wmiClass, string methodName, ManagementBaseObject outParams, ManagementScope scope, TimeSpan timeout) { + var returnedJob = outParams["Job"] as ManagementObject; var jobPath = outParams["Job"] as string; - if (string.IsNullOrEmpty(jobPath)) + if (returnedJob == null && string.IsNullOrEmpty(jobPath)) { throw new HyperVWmiException(wmiClass, methodName, WmiReturnCode.Started); } - using (var job = new ManagementObject(scope, new ManagementPath(jobPath), null)) + using (var job = returnedJob ?? new ManagementObject(scope, new ManagementPath(jobPath), null)) { var deadline = DateTime.UtcNow.Add(timeout); while (true) { if (DateTime.UtcNow > deadline) { - throw new HyperVConsoleException(string.Format("Timed out waiting for Hyper-V WMI job. Class={0}, Method={1}, Timeout={2}.", wmiClass, methodName, timeout)); + throw new HyperVManagementTimeoutException(string.Format("Timed out waiting for Hyper-V WMI job. Class={0}, Method={1}, Timeout={2}.", wmiClass, methodName, timeout)); } job.Get(); @@ -561,6 +593,7 @@ private static ManagementObject GetFirstRelatedObject(ManagementObject source, s { foreach (ManagementObject item in related) { + related.Detach(item); return item; } } @@ -751,6 +784,25 @@ internal static HyperVConsoleAuditEvent CreateAuditEvent(Guid virtualMachineId, }; } + /// + /// Releases the local CIM session owned by this client. Dispose console + /// sessions opened by the client before disposing the client. + /// + public void Dispose() + { + if (Interlocked.Exchange(ref _disposeState, 1) == 0) + { + _scope.Dispose(); + } + } + + private void ThrowIfDisposed() + { + if (Volatile.Read(ref _disposeState) != 0) + { + throw new ObjectDisposedException(nameof(HyperVConsoleClient)); + } + } } internal sealed class ConsoleFrameSize @@ -825,7 +877,7 @@ public ConsoleFrame CaptureFrame(ConsoleFrameOptions options) throw new HyperVConsoleCaptureNotSupportedException("No Hyper-V virtual system settings object was found for this virtual machine."); } - inParams["TargetSystem"] = settings.Path.Path; + inParams["TargetSystem"] = settings; inParams["WidthPixels"] = (ushort)options.Width; inParams["HeightPixels"] = (ushort)options.Height; @@ -1552,6 +1604,7 @@ private ManagementObject GetVirtualMachineObject() { foreach (ManagementObject vm in results) { + results.Detach(vm); return vm; } } @@ -1566,6 +1619,7 @@ private ManagementObject GetManagementService() { foreach (ManagementObject service in results) { + results.Detach(service); return service; } } @@ -1579,6 +1633,7 @@ private static ManagementObject GetFirstRelatedObject(ManagementObject source, s { foreach (ManagementObject item in related) { + related.Detach(item); return item; } } diff --git a/src/HyperVConsoleKit/HyperVConsoleKit.csproj b/src/HyperVConsoleKit/HyperVConsoleKit.csproj index 8765283..835df20 100644 --- a/src/HyperVConsoleKit/HyperVConsoleKit.csproj +++ b/src/HyperVConsoleKit/HyperVConsoleKit.csproj @@ -1,11 +1,11 @@ - net46;netstandard2.0;net8.0-windows + net8.0-windows HyperVConsoleKit HyperVConsoleKit HyperVConsoleKit - 0.5.0 + 0.6.0-preview.1 HyperVConsoleKit Host-side emergency Hyper-V VM console access toolkit. Hyper-V;WMI;console;virtualization @@ -25,17 +25,14 @@ latest - - - - - - + + + @@ -44,8 +41,13 @@ - - - + + + <_SystemManagementReference Include="@(ReferencePath)" + Condition="'%(ReferencePath.Filename)' == 'System.Management'" /> + + + diff --git a/tests/HyperVConsoleKit.Tests/CimProviderShapeTests.cs b/tests/HyperVConsoleKit.Tests/CimProviderShapeTests.cs new file mode 100644 index 0000000..4857174 --- /dev/null +++ b/tests/HyperVConsoleKit.Tests/CimProviderShapeTests.cs @@ -0,0 +1,290 @@ +using Microsoft.Management.Infrastructure; + +namespace HyperVConsoleKit.Tests; + +public sealed class CimProviderShapeTests +{ + [Fact] + public void Server2012R2ShapedProviderAllowsMissingOptionalProperties() + { + var vmId = Guid.NewGuid(); + using var scope = new FakeManagementScope( + vmId, + enabledState: 2, + includeEnhancedSessionTransport: false, + includeVideoDimensions: false); + using var client = new HyperVConsoleClient(scope, new HyperVConsolePolicy()); + + var vm = Assert.Single(client.GetVirtualMachines()); + + Assert.Equal(vmId, vm.Id); + Assert.True(vm.IsRunning); + Assert.True(vm.SupportsKeyboardInput); + Assert.False(vm.SupportsMouseInput); + Assert.Null(vm.RecommendedFrameWidth); + Assert.Null(vm.RecommendedFrameHeight); + Assert.Equal(HyperVEnhancedSessionTransportType.Unknown, client.GetConsoleCapabilities(vmId).EnhancedSessionTransportType); + } + + [Theory] + [InlineData(2, HyperVVirtualMachineState.Running)] + [InlineData(3, HyperVVirtualMachineState.Off)] + [InlineData(6, HyperVVirtualMachineState.Saved)] + [InlineData(9, HyperVVirtualMachineState.Paused)] + [InlineData(32776, HyperVVirtualMachineState.Pausing)] + public void ProviderStatesMapWithoutOsVersionChecks(ushort enabledState, HyperVVirtualMachineState expected) + { + using var scope = new FakeManagementScope(Guid.NewGuid(), enabledState, true, true); + using var client = new HyperVConsoleClient(scope, new HyperVConsolePolicy()); + + Assert.Equal(expected, Assert.Single(client.GetVirtualMachines()).State); + } + + [Fact] + public void EmptyProviderInventoryReturnsEmptyCollection() + { + using var scope = new FakeManagementScope(null, 3, false, false); + using var client = new HyperVConsoleClient(scope, new HyperVConsolePolicy()); + + Assert.Empty(client.GetVirtualMachines()); + } + + [Fact] + public void ImmediatePowerMethodCompletionUsesInternalManagementAdapter() + { + var vmId = Guid.NewGuid(); + using var scope = new FakeManagementScope(vmId, 3, false, false); + using var client = new HyperVConsoleClient(scope, new HyperVConsolePolicy { AllowPowerControl = true }); + + client.StartVirtualMachine(vmId); + + Assert.Equal("RequestStateChange", scope.LastInvokedMethod); + Assert.Equal((ushort)2, scope.LastInputParameters!["RequestedState"]); + } + + [Fact] + public void AsynchronousPowerMethodCompletionRefreshesConcreteJob() + { + var vmId = Guid.NewGuid(); + using var scope = new FakeManagementScope(vmId, 3, false, false) + { + ReturnAsynchronousJob = true + }; + using var client = new HyperVConsoleClient(scope, new HyperVConsolePolicy { AllowPowerControl = true }); + + client.StartVirtualMachine(vmId); + + Assert.True(scope.JobWasRefreshed); + } + + [Fact] + public void FailedAsynchronousJobPreservesWmiFailureContract() + { + var vmId = Guid.NewGuid(); + using var scope = new FakeManagementScope(vmId, 3, false, false) + { + ReturnAsynchronousJob = true, + JobFinalState = 10, + JobErrorCode = 32775 + }; + using var client = new HyperVConsoleClient(scope, new HyperVConsolePolicy { AllowPowerControl = true }); + + var exception = Assert.Throws(() => client.StartVirtualMachine(vmId)); + + Assert.Equal((uint)32775, exception.ReturnCode); + } + + [Fact] + public void AsynchronousJobTimeoutHasSpecificException() + { + using var scope = new FakeManagementScope(Guid.NewGuid(), 3, false, false); + using var result = new ManagementBaseObject(); + result["ReturnValue"] = 4096u; + result["Job"] = "fake-job"; + + Assert.Throws( + () => HyperVConsoleClient.EnsureCompleted( + "Msvm_ComputerSystem", + "RequestStateChange", + result, + scope, + TimeSpan.Zero)); + } + + [Fact] + public void ManagedAssemblyHasNoSystemManagementReference() + { + Assert.DoesNotContain( + typeof(HyperVConsoleClient).Assembly.GetReferencedAssemblies(), + assembly => string.Equals(assembly.Name, "System.Management", StringComparison.OrdinalIgnoreCase)); + } + + [Fact] + public void MissingProviderNamespaceHasSpecificException() + { + using var client = new HyperVConsoleClient(@"root\HyperVConsoleKit_DefinitelyMissing"); + + Assert.Throws(() => client.GetVirtualMachines()); + } + + private sealed class FakeManagementScope : ManagementScope + { + private const string Namespace = @"root\virtualization\v2"; + private readonly Guid? _vmId; + private readonly ushort _enabledState; + private readonly bool _includeEnhancedSessionTransport; + private readonly bool _includeVideoDimensions; + + public FakeManagementScope( + Guid? vmId, + ushort enabledState, + bool includeEnhancedSessionTransport, + bool includeVideoDimensions) + : base(Namespace) + { + _vmId = vmId; + _enabledState = enabledState; + _includeEnhancedSessionTransport = includeEnhancedSessionTransport; + _includeVideoDimensions = includeVideoDimensions; + } + + public string? LastInvokedMethod { get; private set; } + public Dictionary? LastInputParameters { get; private set; } + public bool ReturnAsynchronousJob { get; set; } + public bool JobWasRefreshed { get; private set; } + public ushort JobFinalState { get; set; } = 7; + public uint JobErrorCode { get; set; } + + public override void Connect() + { + } + + internal override IEnumerable Query(string query) + { + if (query.Contains("Msvm_VirtualSystemManagementService", StringComparison.Ordinal)) + { + return new[] { Instance("Msvm_VirtualSystemManagementService") }; + } + + if (!_vmId.HasValue || !query.Contains("Msvm_ComputerSystem", StringComparison.Ordinal)) + { + return Array.Empty(); + } + + return new[] + { + Instance( + "Msvm_ComputerSystem", + ("Name", _vmId.Value.ToString("D").ToUpperInvariant(), CimType.String), + ("ElementName", "Compatibility VM", CimType.String), + ("EnabledState", _enabledState, CimType.UInt16)) + }; + } + + internal override IEnumerable GetRelated( + CimInstance source, + string relatedClass, + string associationClass, + string resultRole, + string thisRole) + { + switch (relatedClass) + { + case "Msvm_VirtualSystemSettingData": + var settings = Instance("Msvm_VirtualSystemSettingData"); + if (_includeEnhancedSessionTransport) + { + settings.CimInstanceProperties.Add( + CimProperty.Create("EnhancedSessionTransportType", (ushort)0, CimType.UInt16, CimFlags.Property)); + } + + return new[] { settings }; + + case "Msvm_VideoHead": + if (!_includeVideoDimensions) + { + return Array.Empty(); + } + + return new[] + { + Instance( + "Msvm_VideoHead", + ("CurrentHorizontalResolution", (uint)1024, CimType.UInt32), + ("CurrentVerticalResolution", (uint)768, CimType.UInt32), + ("CurrentBitsPerPixel", (uint)32, CimType.UInt32), + ("EnabledState", (ushort)2, CimType.UInt16)) + }; + + case "Msvm_Keyboard": + return new[] { Instance("Msvm_Keyboard") }; + + case "Msvm_SyntheticMouse": + return Array.Empty(); + + default: + return Array.Empty(); + } + } + + internal override bool HasMethod(CimInstance instance, string methodName) + { + return instance.CimSystemProperties.ClassName == "Msvm_ComputerSystem" + ? methodName == "RequestStateChange" + : instance.CimSystemProperties.ClassName == "Msvm_Keyboard" + && (methodName == "TypeKey" || methodName == "PressKey" || methodName == "ReleaseKey"); + } + + internal override ManagementBaseObject Invoke( + CimInstance instance, + string methodName, + ManagementBaseObject inParameters) + { + LastInvokedMethod = methodName; + LastInputParameters = inParameters.Values.ToDictionary(pair => pair.Key, pair => pair.Value); + + var result = new ManagementBaseObject(); + result["ReturnValue"] = ReturnAsynchronousJob ? (uint)4096 : 0u; + if (ReturnAsynchronousJob) + { + result["Job"] = "fake-job"; + } + + return result; + } + + internal override CimInstance ResolveReference(string path) + { + Assert.Equal("fake-job", path); + return JobInstance(4); + } + + internal override CimInstance Refresh(CimInstance instance) + { + JobWasRefreshed = true; + return JobInstance(JobFinalState, JobErrorCode); + } + + private static CimInstance JobInstance(ushort state, uint errorCode = 0) + { + return Instance( + "Msvm_ConcreteJob", + ("JobState", state, CimType.UInt16), + ("ErrorCode", errorCode, CimType.UInt32)); + } + + private static CimInstance Instance( + string className, + params (string Name, object Value, CimType Type)[] properties) + { + var instance = new CimInstance(className, Namespace); + foreach (var property in properties) + { + instance.CimInstanceProperties.Add( + CimProperty.Create(property.Name, property.Value, property.Type, CimFlags.Property)); + } + + return instance; + } + } +} diff --git a/tests/HyperVConsoleKit.Tests/HyperVConsoleKit.Tests.csproj b/tests/HyperVConsoleKit.Tests/HyperVConsoleKit.Tests.csproj index e5492cb..da75324 100644 --- a/tests/HyperVConsoleKit.Tests/HyperVConsoleKit.Tests.csproj +++ b/tests/HyperVConsoleKit.Tests/HyperVConsoleKit.Tests.csproj @@ -1,7 +1,7 @@  - net9.0 + net8.0-windows enable enable false diff --git a/validation/compatibility-matrix.json b/validation/compatibility-matrix.json new file mode 100644 index 0000000..7db12a2 --- /dev/null +++ b/validation/compatibility-matrix.json @@ -0,0 +1,46 @@ +{ + "packageVersion": "0.6.0-preview.1", + "required": [ + { + "operatingSystem": "Windows Server 2012 R2", + "architecture": "x64", + "status": "Pending" + }, + { + "operatingSystem": "Windows Server 2016", + "architecture": "x64", + "status": "Pending" + }, + { + "operatingSystem": "Windows Server 2019", + "architecture": "x64", + "status": "Pending" + }, + { + "operatingSystem": "Windows Server 2022", + "architecture": "x64", + "status": "Pending" + }, + { + "operatingSystem": "Windows Server 2025", + "architecture": "x64", + "status": "Pass", + "build": "26100", + "validatedUtc": "2026-07-27", + "identities": [ + "Elevated administrator", + "LocalSystem" + ] + }, + { + "operatingSystem": "Windows 10", + "architecture": "x64", + "status": "Pending" + }, + { + "operatingSystem": "Windows 11", + "architecture": "x64", + "status": "Pending" + } + ] +}