Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions Changelog.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
2 changes: 1 addition & 1 deletion Header.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@
7h3 4b1d3r

.VERSION
1.121.13
1.121.14
.LAST UPDATED
07/01/2026

Expand Down
2 changes: 1 addition & 1 deletion Modules/00-Initialization.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
13 changes: 10 additions & 3 deletions Modules/41-VHDManagement.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -504,25 +504,32 @@ 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"
Write-OutputColor " Dynamic copy deleted. Master base image untouched." -color "Info"
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 {
Expand Down
18 changes: 18 additions & 0 deletions Modules/43-OfflineVHD.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down
4 changes: 4 additions & 0 deletions Modules/50-EntryPoint.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 12 additions & 0 deletions Modules/53-VMExportImport.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
74 changes: 66 additions & 8 deletions Modules/62-HyperVReplica.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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"
Expand All @@ -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"
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@
<a href="https://www.bestpractices.dev/projects/12921"><img alt="OpenSSF Best Practices" src="https://www.bestpractices.dev/projects/12921/badge"></a>
<a href="https://codecov.io/gh/TheAbider/RackStack"><img alt="codecov" src="https://codecov.io/gh/TheAbider/RackStack/branch/master/graph/badge.svg"></a>
<img alt="PSScriptAnalyzer 0 errors" src="https://img.shields.io/badge/PSScriptAnalyzer-0%20errors-brightgreen">
<img alt="5348 structural tests" src="https://img.shields.io/badge/structural%20tests-5348-brightgreen">
<img alt="5357 structural tests" src="https://img.shields.io/badge/structural%20tests-5357-brightgreen">
<img alt="Pester 312 tests" src="https://img.shields.io/badge/Pester-312%20tests-brightgreen">
<img alt="SLSA Level 3" src="https://slsa.dev/images/gh-badge-level3.svg">
</p>
Expand Down
2 changes: 1 addition & 1 deletion RackStack.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion RackStack.psd1
Original file line number Diff line number Diff line change
@@ -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'
Expand Down
Loading
Loading