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
13 changes: 13 additions & 0 deletions Changelog.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,18 @@
# Changelog

## v1.121.11

State-integrity hardening — fixes found by a deep audit of the Dry-Run engine, configuration profile import/export, and the batch (scripted) apply.

- **A rolled-back Dry-Run commit no longer silently drops changes.** If an atomic commit failed partway and rolled the applied steps back, those steps were still marked "Applied" — so a retry skipped them and reported "all steps applied" while their changes were actually missing. Reverted steps are now re-applied on retry.
- **Applying a saved profile can't strand a server with no IP.** The network step removed the current address before adding the new one; if the new address failed (duplicate IP, bad gateway) the box was left with no IPv4 and no gateway. It now restores the previous address on failure. (Same fix applied to the drift-remediation path.)
- **The batch report tells the truth about failures.** A shared-storage step that was refused or failed, a Windows Update run with no connectivity, and a failed power-plan change were all being counted as successful applied changes. Each now reports the real result.
- **Cancelling one import step no longer aborts the whole import.** Declining the network reconfiguration (or hitting an invalid domain name) used to silently skip every remaining step and the summary; it now skips just that step and continues.
- **Export no longer invents a DNS server.** When a machine had no DNS configured, export wrote a public resolver (8.8.8.8) into the profile; it now leaves DNS blank so import doesn't apply a value the source never had.
- **'Undo all' restores DNS after a DNS-only batch change**, and the interactive text export reports a real error instead of silently writing a truncated file.

No module or CLI action changes (81 modules, 201 actions).

## v1.121.10

Credential-handling hardening — fixes found by a deep audit of password generation, SecureString hygiene, and secret leakage.
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.10
1.121.11
.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.10"
$script:ScriptVersion = "1.121.11"
$script:ScriptStartTime = Get-Date

# Post-update cleanup: UpdateSelf / Rollback leave a `.pending-delete` sibling next to RackStack.exe.
Expand Down
9 changes: 9 additions & 0 deletions Modules/14-WindowsUpdates.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,13 @@ function Install-WindowsUpdates {
Clear-Host
Write-CenteredOutput "Windows Updates" -color "Info"

# Pessimistic status flag for non-interactive callers (batch config) to detect a genuine
# no-op/failure. The function signals its failures by early `return` (no throw), so a caller
# can't tell success from "no network / gallery unreachable / scan failed" without this.
# Defaults to 'Failed'; set to 'Success' only at the two real success points (up-to-date /
# install completed). Interactive callers ignore it.
$script:LastWindowsUpdateStatus = 'Failed'

# Check network connectivity
Write-OutputColor " Checking network connectivity..." -color "Info"
if (-not (Test-NetworkConnectivity)) {
Expand Down Expand Up @@ -210,6 +217,7 @@ function Install-WindowsUpdates {

if ($null -eq $updates -or @($updates).Count -eq 0) {
Write-OutputColor " No updates available. System is up to date!" -color "Success"
$script:LastWindowsUpdateStatus = 'Success' # up-to-date is a successful check, not a failure
return
}

Expand Down Expand Up @@ -403,6 +411,7 @@ function Install-WindowsUpdates {
Write-OutputColor "`nWindows updates installation complete!" -color "Success"
Write-OutputColor " A reboot may be required to complete the installation." -color "Warning"
$script:RebootNeeded = $true
$script:LastWindowsUpdateStatus = 'Success'
Add-SessionChange -Category "System" -Description "Installed $updateCount Windows update(s)"
Clear-MenuCache
}
Expand Down
86 changes: 70 additions & 16 deletions Modules/45-ConfigExport.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -462,7 +462,10 @@ function Export-ServerConfiguration {
# was missing it. A kill mid-write would leave a truncated config file next to the operator
# with no warning.
$tmpExportPath = "$exportPath.tmp"
$config | Out-File -LiteralPath $tmpExportPath -Encoding UTF8 -Force
# -ErrorAction Stop so a non-terminating write failure (disk full / quota) doesn't fall
# through to a successful same-volume rename of a TRUNCATED file and report success.
# Matches the guarded writes in Save-ConfigurationProfile / Save-DriftBaseline.
$config | Out-File -LiteralPath $tmpExportPath -Encoding UTF8 -Force -ErrorAction Stop
Move-Item -LiteralPath $tmpExportPath -Destination $exportPath -Force -ErrorAction Stop

Write-OutputColor "`nConfiguration exported successfully!" -color "Success"
Expand Down Expand Up @@ -517,8 +520,11 @@ function Save-ConfigurationProfile {
"IPAddress" = $null # Intentionally null - user should set for new server
"SubnetCIDR" = if ($primaryIP) { $primaryIP.PrefixLength } else { 24 }
"Gateway" = $null # Intentionally null - user should set for new server
"DnS1" = if ($primaryDnS -and $primaryDnS.ServerAddresses.Count -ge 1) { $primaryDnS.ServerAddresses[0] } else { $script:DnSPresets["Google DnS"][0] }
"DnS2" = if ($primaryDnS -and $primaryDnS.ServerAddresses.Count -ge 2) { $primaryDnS.ServerAddresses[1] } else { $script:DnSPresets["Google DnS"][1] }
# Null when the source has no DNS configured — do NOT fabricate a public resolver
# (8.8.8.8). Fabricating a value the source never had breaks round-trip fidelity and
# injects an unexpected external resolver on import. Import skips DNS when DnS1 is null.
"DnS1" = if ($primaryDnS -and $primaryDnS.ServerAddresses.Count -ge 1) { $primaryDnS.ServerAddresses[0] } else { $null }
"DnS2" = if ($primaryDnS -and $primaryDnS.ServerAddresses.Count -ge 2) { $primaryDnS.ServerAddresses[1] } else { $null }
}
"Domain" = [ordered]@{
"JoinDomain" = if ($null -ne $computerSystem) { $computerSystem.PartOfDomain } else { $false }
Expand Down Expand Up @@ -805,36 +811,69 @@ function Import-ConfigurationProfile {
$remoteSession = $true
}
} catch { }
$proceedNetwork = $true
if ($remoteSession) {
Write-OutputColor " WARNING: applying network changes over a remote session may disconnect you." -color "Warning"
if (-not (Confirm-UserAction -Message "Continue with network reconfiguration?")) {
Write-OutputColor " Network step cancelled." -color "Info"
# Skip ONLY the network step and continue the rest of the import. A bare
# `return` here previously aborted the ENTIRE profile import — every later
# step (timezone, RDP, WinRM, firewall, domain join...), the summary, the
# reboot notice and the session-change log were all silently skipped.
Write-OutputColor " Network step cancelled — continuing with the remaining steps." -color "Info"
$errors++
return
$proceedNetwork = $false
}
}

if ($proceedNetwork) {
# Targeted removal: only delete the existing static IP that matches the saved
# adapter, not every IP on the NIC. Loop in case multiple primary IPs exist.
$existingIPs = @(Get-NetIPAddress -InterfaceAlias $adaptername -AddressFamily IPv4 -ErrorAction SilentlyContinue |
Where-Object { $_.PrefixOrigin -eq 'Manual' })
# Capture the current IP(s) + default gateway BEFORE removing them, so a failed
# New-NetIPAddress can be rolled back instead of stranding the host with no IPv4
# address and no default route — a remote box would drop off the network entirely
# with no in-band recovery.
$rollbackIPs = @($existingIPs | ForEach-Object { @{ IPAddress = $_.IPAddress; PrefixLength = $_.PrefixLength } })
$oldGateway = (Get-NetRoute -InterfaceAlias $adaptername -DestinationPrefix '0.0.0.0/0' -ErrorAction SilentlyContinue |
Select-Object -First 1).NextHop
foreach ($ip in $existingIPs) {
Remove-NetIPAddress -InterfaceAlias $adaptername -IPAddress $ip.IPAddress -Confirm:$false -ErrorAction SilentlyContinue
}
# Only remove the default route on this adapter (not every route)
Remove-NetRoute -InterfaceAlias $adaptername -DestinationPrefix '0.0.0.0/0' -Confirm:$false -ErrorAction SilentlyContinue

new-netIPAddress -InterfaceAlias $adaptername -IPAddress $configProfile.network.IPAddress `
-PrefixLength $configProfile.network.SubnetCIDR -DefaultGateway $configProfile.network.Gateway -ErrorAction Stop
try {
new-netIPAddress -InterfaceAlias $adaptername -IPAddress $configProfile.network.IPAddress `
-PrefixLength $configProfile.network.SubnetCIDR -DefaultGateway $configProfile.network.Gateway -ErrorAction Stop
}
catch {
# New IP couldn't be set — restore the previous address(es) + gateway so the
# host keeps a reachable IPv4 config, then re-throw to the outer handler.
Write-OutputColor " New IP assignment failed; restoring previous network config..." -color "Warning"
foreach ($rb in $rollbackIPs) {
New-NetIPAddress -InterfaceAlias $adaptername -IPAddress $rb.IPAddress -PrefixLength $rb.PrefixLength -ErrorAction SilentlyContinue | Out-Null
}
if ($oldGateway) {
New-NetRoute -InterfaceAlias $adaptername -DestinationPrefix '0.0.0.0/0' -NextHop $oldGateway -Confirm:$false -ErrorAction SilentlyContinue | Out-Null
}
throw
}

$dnsServers = @($configProfile.network.DnS1)
if ($configProfile.network.DnS2) { $dnsServers += $configProfile.network.DnS2 }
Set-DnsClientServerAddress -InterfaceAlias $adaptername -ServerAddresses $dnsServers -ErrorAction Stop
# Only set DNS when the profile actually carries a primary server. A null DnS1
# (source had none, no longer fabricated) must NOT be pushed as @($null), which
# would error or wipe the adapter's DNS.
if (-not [string]::IsNullOrWhiteSpace($configProfile.network.DnS1)) {
$dnsServers = @($configProfile.network.DnS1)
if ($configProfile.network.DnS2) { $dnsServers += $configProfile.network.DnS2 }
Set-DnsClientServerAddress -InterfaceAlias $adaptername -ServerAddresses $dnsServers -ErrorAction Stop
}

$changesApplied++
Write-OutputColor " network configured." -color "Success"
Add-SessionChange -Category "network" -Description "Set IP $($configProfile.network.IPAddress)/$($configProfile.network.SubnetCIDR) on $adaptername"
Clear-MenuCache
}
}
catch {
Write-OutputColor " Failed: $_" -color "Error"
Expand Down Expand Up @@ -1062,11 +1101,13 @@ function Import-ConfigurationProfile {
Write-OutputColor " [13/13] Joining domain '$($configProfile.Domain.Domainname)'..." -color "Info"
# Validate before Add-Computer (mirrors the gate in 50-EntryPoint:13280).
if (-not (Test-ValidDomainName -DomainName $configProfile.Domain.Domainname)) {
# Skip ONLY the domain-join step and fall through to the summary. A bare `return`
# here previously aborted the function before the "Profile applied" summary, the
# reboot notice and the Install-Updates prompt were ever reached.
Write-OutputColor " ERROR: '$($configProfile.Domain.Domainname)' is not a valid domain name. Skipping." -color "Error"
$errors++
Write-OutputColor "" -color "Info"
return
}
else {
Write-OutputColor " Enter domain credentials:" -color "Info"
try {
$domainCred = Get-Credential -Message "Enter credentials to join $($configProfile.Domain.Domainname)"
Expand All @@ -1083,6 +1124,7 @@ function Import-ConfigurationProfile {
Write-OutputColor " Failed: $_" -color "Error"
$errors++
}
}
} else {
Write-OutputColor " [13/13] Domain join: skipped" -color "Debug"
}
Expand Down Expand Up @@ -1520,14 +1562,26 @@ function Invoke-Remediation {
$currentIPObj = Get-NetIPAddress -InterfaceAlias $adaptername -AddressFamily IPv4 -ErrorAction SilentlyContinue |
Where-Object { $_.IPAddress -eq $current -or $_.PrefixOrigin -ne 'Dhcp' } |
Select-Object -First 1
# Capture prior IP + gateway so a failed New-NetIPAddress can be rolled back
# rather than stranding the host with no IPv4 address / default route.
$rbIP = if ($currentIPObj) { @{ IPAddress = $currentIPObj.IPAddress; PrefixLength = $currentIPObj.PrefixLength } } else { $null }
$rbGW = (Get-NetRoute -InterfaceAlias $adaptername -DestinationPrefix "0.0.0.0/0" -ErrorAction SilentlyContinue | Select-Object -First 1).NextHop
if ($currentIPObj) {
Remove-NetIPAddress -InterfaceAlias $adaptername -IPAddress $currentIPObj.IPAddress -AddressFamily IPv4 -Confirm:$false -ErrorAction SilentlyContinue
}
Remove-NetRoute -InterfaceAlias $adaptername -DestinationPrefix "0.0.0.0/0" -AddressFamily IPv4 -Confirm:$false -ErrorAction SilentlyContinue
if ($newGW) {
new-netIPAddress -InterfaceAlias $adaptername -IPAddress $newIP -PrefixLength $cidr -DefaultGateway $newGW -ErrorAction Stop | Out-null
} else {
new-netIPAddress -InterfaceAlias $adaptername -IPAddress $newIP -PrefixLength $cidr -ErrorAction Stop | Out-null
try {
if ($newGW) {
new-netIPAddress -InterfaceAlias $adaptername -IPAddress $newIP -PrefixLength $cidr -DefaultGateway $newGW -ErrorAction Stop | Out-null
} else {
new-netIPAddress -InterfaceAlias $adaptername -IPAddress $newIP -PrefixLength $cidr -ErrorAction Stop | Out-null
}
}
catch {
Write-OutputColor " New IP assignment failed; restoring previous network config..." -color "Warning"
if ($rbIP) { New-NetIPAddress -InterfaceAlias $adaptername -IPAddress $rbIP.IPAddress -PrefixLength $rbIP.PrefixLength -ErrorAction SilentlyContinue | Out-Null }
if ($rbGW) { New-NetRoute -InterfaceAlias $adaptername -DestinationPrefix "0.0.0.0/0" -NextHop $rbGW -Confirm:$false -ErrorAction SilentlyContinue | Out-Null }
throw
}
$detail = "Set IP to $newIP/$cidr$(if ($newGW) { " GW $newGW" })"
Add-SessionChange -Category "Remediation" -Description $detail
Expand Down
Loading
Loading