From c84ebc3f61e8d9e92ac94a6fa0e510fcea0db7ca Mon Sep 17 00:00:00 2001 From: TheAbider <51920546+TheAbider@users.noreply.github.com> Date: Wed, 1 Jul 2026 09:06:26 -0700 Subject: [PATCH] Harden VM-lifecycle + storage: false-success on cancel/failure paths (v1.121.14) Fixes from an adversarial audit of the VM export/import, offline-VHD, VHD convert, batch S2D, and Hyper-V Replica modules. Recurring class: false success on a cancel/failure path (data-loss risk). 53-VMExportImport.ps1: - A cancelled/timed-out VM export (operator stops watching or declines the 4h cap while vmms.exe is still exporting) is no longer reported 'Export complete!' and logged as a successful export. That false success could lead an operator to decommission the source VM against a truncated, unbootable export. 43-OfflineVHD.ps1: - Offline customization returns $false (not success) when the SYSTEM hive didn't load, so the computer-name / offline settings that were silently skipped aren't reported as applied (the VM would otherwise boot with the sysprep-default name). 41-VHDManagement.ps1: - dynamic->fixed conversion gates success on the Move actually succeeding, not on $finalPath still existing (which is the un-converted DYNAMIC copy) -- it no longer returns/reports the dynamic VHD as 'Fixed' and orphans the fixed file. 62-HyperVReplica.ps1: - A replica-side planned failover now confirms the primary was prepared (-Prepare) and shut down before Complete-VMFailover (avoids silent data loss / split-brain). - Certificate-based replica resolves + passes -CertificateThumbprint (the option never worked before). Dry-Run replica undos use -EA Stop (no silent open receiver). 50-EntryPoint.ps1: - Batch S2D carries the AllowS2DDataLoss consent flag to the gate (was dropped, making batch S2D enable permanently unreachable). Tests: Section 201 (10 asserts). Structural 5357/5357; Pester 312/312; PSSA 0. Version stamps -> 1.121.14. --- Changelog.md | 12 ++++++ Header.ps1 | 2 +- Modules/00-Initialization.ps1 | 2 +- Modules/41-VHDManagement.ps1 | 13 ++++-- Modules/43-OfflineVHD.ps1 | 18 +++++++++ Modules/50-EntryPoint.ps1 | 4 ++ Modules/53-VMExportImport.ps1 | 12 ++++++ Modules/62-HyperVReplica.ps1 | 74 +++++++++++++++++++++++++++++++---- README.md | 2 +- RackStack.ps1 | 2 +- RackStack.psd1 | 2 +- Tests/Run-Tests.ps1 | 44 ++++++++++++++++++++- 12 files changed, 170 insertions(+), 17 deletions(-) diff --git a/Changelog.md b/Changelog.md index 1d5a9d6..2798764 100644 --- a/Changelog.md +++ b/Changelog.md @@ -1,5 +1,17 @@ # Changelog +## v1.121.14 + +VM and storage safety — fixes found by a deep audit of the VM export/import, offline-VHD, VHD conversion, and Hyper-V Replica modules. + +- **A cancelled VM export is no longer reported as complete.** If you stopped watching an export (or the 4-hour cap elapsed) while it was still running, the tool printed "Export complete!" and logged a successful export — even though Hyper-V was still writing a partial, unbootable copy in the background. Trusting that, an operator could delete the source VM and lose it. It now clearly says the export wasn't confirmed and to verify it first. +- **Offline VHD customization tells the truth.** If it couldn't load the VHD's registry (e.g. a leftover mount from a prior crash), it silently applied nothing yet reported success — so the VM booted with the wrong name. It now reports the customization as incomplete. +- **VHD conversion reports the fixed disk, not the dynamic one.** If the final rename failed, the tool reported the un-converted dynamic disk as "Fixed VHD"; it now returns the actual converted disk. +- **Planned Hyper-V Replica failover asks for confirmation on the replica.** Completing a failover on the replica is only lossless if the primary was prepared and shut down first; the tool now confirms that before completing, to avoid silent data loss or split-brain. +- **Certificate-based Hyper-V Replica actually works now** (it selects and passes the HTTPS certificate), and **batch S2D setup respects its data-loss consent flag** (previously it was dropped, making batch S2D unreachable). + +No module or CLI action changes (81 modules, 201 actions). + ## v1.121.13 Role/service cleanup and reporting fixes — found by a deep audit of the ADCS, scheduled-task, WSUS, and Remote Desktop modules. diff --git a/Header.ps1 b/Header.ps1 index 8eaaadf..61acc1a 100644 --- a/Header.ps1 +++ b/Header.ps1 @@ -30,7 +30,7 @@ 7h3 4b1d3r .VERSION - 1.121.13 + 1.121.14 .LAST UPDATED 07/01/2026 diff --git a/Modules/00-Initialization.ps1 b/Modules/00-Initialization.ps1 index b0e13fc..58a22ea 100644 --- a/Modules/00-Initialization.ps1 +++ b/Modules/00-Initialization.ps1 @@ -233,7 +233,7 @@ if (-not $PSCommandPath -and $script:ScriptPath) { if (-not $script:ModuleRoot -and $script:ScriptPath) { $script:ModuleRoot = [System.IO.Path]::GetDirectoryName($script:ScriptPath) } -$script:ScriptVersion = "1.121.13" +$script:ScriptVersion = "1.121.14" $script:ScriptStartTime = Get-Date # Post-update cleanup: UpdateSelf / Rollback leave a `.pending-delete` sibling next to RackStack.exe. diff --git a/Modules/41-VHDManagement.ps1 b/Modules/41-VHDManagement.ps1 index 25fffb7..5e1e69a 100644 --- a/Modules/41-VHDManagement.ps1 +++ b/Modules/41-VHDManagement.ps1 @@ -504,14 +504,19 @@ function Copy-VHDForVM { # Move the fixed file to the final name (overwrites the dynamic copy) $finalPath = Join-Path $DestinationFolder $destFileName + $moveOk = $false try { Move-Item -LiteralPath $fixedPath -Destination $finalPath -Force -ErrorAction Stop + $moveOk = $true } catch { Write-OutputColor " Warning: Could not rename converted VHD: $_" -color "Warning" } - if (Test-Path -LiteralPath $finalPath) { + # Gate on $moveOk, NOT just Test-Path $finalPath: on a Move failure $finalPath STILL exists + # because it holds the un-converted DYNAMIC copy, so the old code returned/reported that + # dynamic file as the "Fixed VHD" and orphaned the real fixed file at $fixedPath. + if ($moveOk -and (Test-Path -LiteralPath $finalPath)) { $finalSize = (Get-Item -LiteralPath $finalPath).Length $sizeGB = [math]::Round($finalSize / 1GB, 2) Write-OutputColor " Conversion complete! Fixed VHD: ${sizeGB} GB" -color "Success" @@ -519,10 +524,12 @@ function Copy-VHDForVM { return $finalPath } elseif (Test-Path -LiteralPath $fixedPath) { - # Move failed but fixed file still exists at _fixed path - use it directly + # Rename failed but the converted FIXED file still exists at the _fixed path — return it + # directly rather than the dynamic copy at $finalPath. The dynamic copy is left in place + # for the operator to reclaim. $finalSize = (Get-Item -LiteralPath $fixedPath).Length $sizeGB = [math]::Round($finalSize / 1GB, 2) - Write-OutputColor " Conversion complete! Fixed VHD: ${sizeGB} GB" -color "Success" + Write-OutputColor " Conversion complete (kept as '$([System.IO.Path]::GetFileName($fixedPath))' — rename failed): ${sizeGB} GB" -color "Warning" return $fixedPath } else { diff --git a/Modules/43-OfflineVHD.ps1 b/Modules/43-OfflineVHD.ps1 index f20465d..b562cef 100644 --- a/Modules/43-OfflineVHD.ps1 +++ b/Modules/43-OfflineVHD.ps1 @@ -294,6 +294,24 @@ Remove-Item -Path `$MyInvocation.MyCommand.Path -Force -ErrorAction SilentlyCont Write-OutputColor " Note: First-boot script creation failed. Some settings may need manual config." -color "Error" } + # Don't report success when the offline registry customization silently did nothing. The + # computer name is applied ONLY offline (no first-boot fallback), so if a name was requested + # but the SYSTEM hive never loaded, the VM boots with its sysprep-default name — the operator + # can't find it. A leftover HKLM\OFFLINE_* mount from a prior crash is the usual cause. + if ($ComputerName -and -not $systemHiveLoaded) { + Write-OutputColor "" -color "Info" + Write-OutputColor " Offline customization INCOMPLETE: the SYSTEM hive could not be loaded, so the" -color "Error" + Write-OutputColor " computer name '$ComputerName' was NOT applied (the VM will boot with its sysprep" -color "Error" + Write-OutputColor " default name). Reboot the host to clear any leftover HKLM\OFFLINE_* mount, then retry." -color "Warning" + return $false + } + if (-not $systemHiveLoaded -and -not $softwareHiveLoaded) { + Write-OutputColor "" -color "Info" + Write-OutputColor " Offline customization INCOMPLETE: neither registry hive could be loaded — no offline" -color "Error" + Write-OutputColor " settings were applied (only the first-boot fallback script was written)." -color "Warning" + return $false + } + Write-OutputColor "" -color "Info" Write-OutputColor " Offline customization complete!" -color "Success" diff --git a/Modules/50-EntryPoint.ps1 b/Modules/50-EntryPoint.ps1 index 86ed80f..2b8fc2b 100644 --- a/Modules/50-EntryPoint.ps1 +++ b/Modules/50-EntryPoint.ps1 @@ -14474,6 +14474,10 @@ function Start-BatchMode { # Non-iSCSI backends: use the generalized initializer $configHash = @{} if ($Config.SMB3SharePath) { $configHash["SMB3SharePath"] = $Config.SMB3SharePath } + # Carry the S2D data-loss consent flag through — Initialize-StorageBackendBatch's + # gate checks $Config.AllowS2DDataLoss, so dropping it here made batch S2D enable + # permanently unreachable (the gate always saw $null and refused). + if ($Config.AllowS2DDataLoss) { $configHash["AllowS2DDataLoss"] = $Config.AllowS2DDataLoss } # Honor the boolean result: Initialize-StorageBackendBatch returns $false on real # failure/refusal paths (e.g. S2D consent missing, Enable-ClusterS2D threw and was # caught internally). Discarding it with $null = and unconditionally counting the diff --git a/Modules/53-VMExportImport.ps1 b/Modules/53-VMExportImport.ps1 index 1adc5f3..9cc80af 100644 --- a/Modules/53-VMExportImport.ps1 +++ b/Modules/53-VMExportImport.ps1 @@ -277,6 +277,18 @@ function Export-VMWizard { } Write-OutputColor "" + if ($cancelRequested) { + # The operator stopped watching (ESC) or declined the time-cap while the export job was + # still Running. Stop-Job does NOT abort the underlying vmms.exe export (see the cancel + # prompts above), so the on-disk export is truncated / still in progress. Do NOT print a + # completion summary or write an "Exported ..." audit record — that false success could + # lead the operator to trust an incomplete export and decommission the source VM. + Write-OutputColor " Export was left running in the background — completion is NOT confirmed." -color "Warning" + Write-OutputColor " Verify $exportPath\$($selectedVM.Name) is complete before relying on it or removing the source VM." -color "Warning" + Add-SessionChange -Category "VM" -Description "Export of VM '$($selectedVM.Name)' to $exportPath left running (completion NOT confirmed)" + return + } + $null = Receive-Job -Job $exportJob -ErrorAction Stop Remove-Job -Job $exportJob diff --git a/Modules/62-HyperVReplica.ps1 b/Modules/62-HyperVReplica.ps1 index 857d884..1746bd0 100644 --- a/Modules/62-HyperVReplica.ps1 +++ b/Modules/62-HyperVReplica.ps1 @@ -258,6 +258,44 @@ function Enable-ReplicaServer { } } + # Certificate-based replication needs an HTTPS-listener cert (Server Authentication EKU) in + # LocalMachine\My, and Set-VMReplicationServer REQUIRES -CertificateThumbprint for it. The old + # code offered Certificate auth but never resolved/passed a thumbprint, so a fresh cert-based + # enable always threw. Resolve one here (or abort with guidance) so the option actually works. + $certThumbprint = $null + if ($authType -eq 'Certificate') { + $serverAuthOid = '1.3.6.1.5.5.7.3.1' + $certs = @(Get-ChildItem -Path Cert:\LocalMachine\My -ErrorAction SilentlyContinue | Where-Object { + $_.HasPrivateKey -and $_.NotAfter -gt (Get-Date) -and + (@($_.EnhancedKeyUsageList | ForEach-Object { $_.ObjectId }) -contains $serverAuthOid) + }) + if ($certs.Count -eq 0) { + Write-OutputColor " No valid Server-Authentication certificate found in LocalMachine\My." -color "Error" + Write-OutputColor " Install/import an HTTPS server-auth certificate for this host, then retry." -color "Warning" + return + } + elseif ($certs.Count -eq 1) { + $certThumbprint = $certs[0].Thumbprint + Write-OutputColor " Using certificate: $($certs[0].Subject) [$($certs[0].Thumbprint)]" -color "Info" + } + else { + Write-OutputColor "" -color "Info" + Write-OutputColor " Select the certificate for HTTPS replication:" -color "Info" + for ($ci = 0; $ci -lt $certs.Count; $ci++) { + Write-OutputColor " [$($ci + 1)] $($certs[$ci].Subject) (expires $($certs[$ci].NotAfter.ToString('yyyy-MM-dd')))" -color "Info" + } + $certPick = Read-Host " Select" + $navResult = Test-NavigationCommand -UserInput $certPick + if ($navResult.ShouldReturn) { return } + if ($certPick -match '^\d+$' -and [int]$certPick -ge 1 -and [int]$certPick -le $certs.Count) { + $certThumbprint = $certs[[int]$certPick - 1].Thumbprint + } + else { + Write-OutputColor " Invalid selection." -color "Error"; return + } + } + } + # Step 2: Set allowed primary servers Write-OutputColor "" -color "Info" Write-OutputColor " ┌────────────────────────────────────────────────────────────────────────┐" -color "Info" @@ -358,7 +396,9 @@ function Enable-ReplicaServer { } ` -Apply { Enable-ReplicaServer } ` -Undo { - Set-VMReplicationServer -ReplicationEnabled $false -ErrorAction SilentlyContinue + # -EA Stop (not SilentlyContinue) so a failed rollback surfaces via the dry-run + # engine's [revert FAILED] path instead of silently leaving an OPEN replica receiver. + Set-VMReplicationServer -ReplicationEnabled $false -ErrorAction Stop } Write-OutputColor " Queued (Dry-Run): enable Hyper-V Replica Server." -color "Warning" Add-SessionChange -Category "DryRun" -Description "Queued Hyper-V Replica Server enable" @@ -378,11 +418,16 @@ function Enable-ReplicaServer { # list is honored by toggling -ReplicationAllowedFromAnyServer based on it AND # registering New-VMReplicationAuthorizationEntry rows for each allowed primary. $allowAny = ($script:ReplicaAllowedServers.Count -eq 0) - Set-VMReplicationServer -ReplicationEnabled $true ` - -AllowedAuthenticationType $authType ` - -DefaultStorageLocation $storagePath ` - -ReplicationAllowedFromAnyServer $allowAny ` - -ErrorAction Stop + $replParams = @{ + ReplicationEnabled = $true + AllowedAuthenticationType = $authType + DefaultStorageLocation = $storagePath + ReplicationAllowedFromAnyServer = $allowAny + ErrorAction = 'Stop' + } + # Certificate auth requires the HTTPS-listener cert thumbprint resolved above. + if ($authType -eq 'Certificate' -and $certThumbprint) { $replParams['CertificateThumbprint'] = $certThumbprint } + Set-VMReplicationServer @replParams if (-not $allowAny) { $trustGroup = "RackStackReplicaTrust" @@ -639,7 +684,7 @@ function Enable-VMReplicationWizard { else { $true } }.GetNewClosure() ` -Apply { Enable-VMReplicationWizard } ` - -Undo { Remove-VMReplication -VMName $capVM -ErrorAction SilentlyContinue }.GetNewClosure() + -Undo { Remove-VMReplication -VMName $capVM -ErrorAction Stop }.GetNewClosure() Write-OutputColor " Queued (Dry-Run): enable replication for VM '$capVM'." -color "Warning" Add-SessionChange -Category "DryRun" -Description "Queued VM replication for '$capVM'" return @@ -1252,7 +1297,20 @@ function Start-PlannedFailover { Write-OutputColor " └────────────────────────────────────────────────────────────────────────┘" -color "Info" } else { - # On the replica server — complete the failover + # On the replica server — complete the failover. A planned (lossless) failover REQUIRES + # that the PRIMARY already ran 'Start-VMFailover -Prepare' (which pushes the final delta) + # and SHUT DOWN the VM. A replica cannot pull that final sync itself, so completing here + # without it fails over to the last-received sync — silent data loss — and, if the + # primary is still running, causes split-brain. We can't verify the remote primary's + # state from here, so require the operator to confirm the prepare + shutdown was done. + Write-OutputColor " A planned failover is only lossless if, on the PRIMARY server, you have ALREADY" -color "Warning" + Write-OutputColor " run 'Start-VMFailover -VMName ''$vmName'' -Prepare' and SHUT DOWN the VM." -color "Warning" + Write-OutputColor " Completing without that loses the final unsynced delta (data loss); if the primary" -color "Warning" + Write-OutputColor " is still running you also get split-brain." -color "Warning" + if (-not (Confirm-UserAction -Message "Confirm the primary was prepared (-Prepare) and shut down. Complete failover now?")) { + Write-OutputColor " Failover cancelled." -color "Info" + return + } Write-OutputColor " Completing failover on replica server..." -color "Info" Start-VMFailover -VMName $vmName -ErrorAction Stop Complete-VMFailover -VMName $vmName -ErrorAction Stop diff --git a/README.md b/README.md index e3e9599..92ad99c 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ OpenSSF Best Practices codecov PSScriptAnalyzer 0 errors - 5348 structural tests + 5357 structural tests Pester 312 tests SLSA Level 3

diff --git a/RackStack.ps1 b/RackStack.ps1 index a130bd2..f2cb495 100644 --- a/RackStack.ps1 +++ b/RackStack.ps1 @@ -13,7 +13,7 @@ Environment-specific settings are configured via defaults.json. .VERSION - 1.121.13 + 1.121.14 .NOTES - Requires Windows Server 2012 R2 or later (or Windows 10/11 for testing) - Must be run as Administrator diff --git a/RackStack.psd1 b/RackStack.psd1 index 2ced864..02080a0 100644 --- a/RackStack.psd1 +++ b/RackStack.psd1 @@ -1,6 +1,6 @@ @{ RootModule = 'RackStack.psm1' - ModuleVersion = '1.121.13' + ModuleVersion = '1.121.14' GUID = 'c19b8e71-4a35-4f2b-9d06-8a24f7bc0e91' Author = 'TheAbider' CompanyName = 'TheAbider' diff --git a/Tests/Run-Tests.ps1 b/Tests/Run-Tests.ps1 index 5148419..0cd87ce 100644 --- a/Tests/Run-Tests.ps1 +++ b/Tests/Run-Tests.ps1 @@ -1,6 +1,6 @@ <# .SYNOPSIS - Automated Test Runner for RackStack v1.121.13 + Automated Test Runner for RackStack v1.121.14 .DESCRIPTION Comprehensive non-interactive test suite covering: @@ -10089,6 +10089,48 @@ catch { Write-TestResult "Role/Service Module Hardening Tests" $false $_.Exception.Message } +# ============================================================================ +# SECTION 201: VM-LIFECYCLE + STORAGE HARDENING (v1.121.14) +# ============================================================================ +# Guards the fixes from the VM-lifecycle/storage audit (export/import, offline-VHD, VHD convert, +# batch S2D, Hyper-V Replica). Recurring class: false success on a cancel/failure path. +# [0] A cancelled/timed-out VM export is not reported as a completed successful export. +# [1] Offline-VHD customization returns $false when the hive(s) didn't load (nothing applied). +# [2] VHD convert gates success on the Move succeeding (not on the dynamic file still existing). +# [3] A replica-side planned failover requires explicit confirm the primary was prepared+shut down. +# [4] Batch S2D consent flag (AllowS2DDataLoss) is carried to the gate (was silently dropped). +# [5] Certificate-based replica resolves + passes a -CertificateThumbprint (was never supplied). +# [6] Dry-Run replica undos use -EA Stop so a failed rollback isn't a silent open-receiver no-op. +Write-SectionHeader "SECTION 201: VM-LIFECYCLE + STORAGE HARDENING" + +try { + $veiRaw = Get-Content "$modulesPath\53-VMExportImport.ps1" -Raw + $ovhdRaw = Get-Content "$modulesPath\43-OfflineVHD.ps1" -Raw + $vhdRaw = Get-Content "$modulesPath\41-VHDManagement.ps1" -Raw + $epRaw2 = Get-Content "$modulesPath\50-EntryPoint.ps1" -Raw + $replRaw = Get-Content "$modulesPath\62-HyperVReplica.ps1" -Raw + + # [0] + Write-TestResult "53-VMExport: cancelled export is not reported complete [0]" ($veiRaw -match 'if \(\$cancelRequested\)[\s\S]{0,700}completion is NOT confirmed[\s\S]{0,450}return') + # [1] + Write-TestResult "43-OfflineVHD: returns false when SYSTEM hive didn't load (name not applied) [1]" ($ovhdRaw -match '\$ComputerName -and -not \$systemHiveLoaded[\s\S]{0,600}return \$false') + Write-TestResult "43-OfflineVHD: returns false when neither hive loaded [1]" ($ovhdRaw -match '-not \$systemHiveLoaded -and -not \$softwareHiveLoaded[\s\S]{0,500}return \$false') + # [2] + Write-TestResult "41-VHD: convert success gated on the move succeeding [2]" (($vhdRaw -match '\$moveOk = \$false') -and ($vhdRaw -match 'if \(\$moveOk -and \(Test-Path -LiteralPath \$finalPath\)\)')) + # [3] + Write-TestResult "62-Replica: replica-side planned failover confirms primary prepared [3]" ($replRaw -match 'lossless if, on the PRIMARY[\s\S]{0,500}Confirm-UserAction[\s\S]{0,120}Complete failover now') + # [4] + Write-TestResult "50-EntryPoint: batch carries the S2D consent flag to the gate [4]" ($epRaw2 -match 'if \(\$Config\.AllowS2DDataLoss\) \{ \$configHash\["AllowS2DDataLoss"\]') + # [5] + Write-TestResult "62-Replica: certificate auth resolves + passes a thumbprint [5]" (($replRaw -match '\$certThumbprint = \$certs\[0\]\.Thumbprint') -and ($replRaw -match "CertificateThumbprint'\] = \`$certThumbprint")) + # [6] + Write-TestResult "62-Replica: enable-replica dry-run undo no longer silent (-EA Stop) [6]" (-not ($replRaw -match 'Set-VMReplicationServer -ReplicationEnabled \$false -ErrorAction SilentlyContinue')) + Write-TestResult "62-Replica: enable-vm-replication dry-run undo no longer silent (-EA Stop) [6]" (-not ($replRaw -match 'Remove-VMReplication -VMName \$capVM -ErrorAction SilentlyContinue')) +} +catch { + Write-TestResult "VM-Lifecycle + Storage Hardening Tests" $false $_.Exception.Message +} + # ============================================================================ # SECTION 174: DOCUMENTATION FRESHNESS (counts must match the codebase) # ============================================================================