From 202851cb2d7c4a0743648821b5f6a0cfa1a51433 Mon Sep 17 00:00:00 2001 From: Micah R Ledbetter Date: Fri, 13 Nov 2015 18:43:43 -0600 Subject: [PATCH] Make postinstall scripts more robust (Still having problems, but I'm knocking them out one by one) - Enable UAC in win81x86 - Throw if my scripts don't pass syntax check BEFORE running Packer - Make the postinstall scripts more robust in general - Add more robust Invoke-ExpressionEx, remove Invoke-ExpressionAndLog - Simplify win-updates.ps1. Don't duplicate code. - Use scheduled tasks instead of registry keys for running postinstall commands after reboots --- buildlab.ps1 | 33 +--- packer/windows_81_x86/Autounattend.xml | 5 - readme.markdown | 5 + scripts/autounattend-postinstall.ps1 | 36 ++-- scripts/provisioner-postinstall.ps1 | 18 +- scripts/win-updates.ps1 | 183 ++++++--------------- scripts/wintriallab-postinstall.psm1 | 219 +++++++++++++++---------- 7 files changed, 217 insertions(+), 282 deletions(-) diff --git a/buildlab.ps1 b/buildlab.ps1 index b639e7a..f7bd086 100644 --- a/buildlab.ps1 +++ b/buildlab.ps1 @@ -8,9 +8,7 @@ Which build actions do you want to perform? .parameter tag A tag for the temporary directory, the output directory, and the resulting Vagrant box #> -#function buildlab { -[cmdletbinding()] -param( +[cmdletbinding()] param( [parameter(mandatory=$true,ParameterSetName="BuildPacker")] [parameter(mandatory=$true,ParameterSetName="AddToVagrant")] [parameter(mandatory=$true,ParameterSetName="VagrantUp")] @@ -31,7 +29,8 @@ param( ) $errorActionPreference = "Stop" -Set-StrictMode -Version 2.0 +Get-Module |? -Property Name -match "wintriallab-postinstall" | Remove-Module +import-module $PSScriptRoot\scripts\wintriallab-postinstall.psm1 $dateStamp = get-date -UFormat "%Y-%m-%d-%H-%M-%S" $packerOutDir = "$baseOutDir\PackerOut" @@ -59,27 +58,6 @@ $packerConfigRoot = "${PSScriptRoot}\packer\${baseConfigName}" $packerFile = "${packerConfigRoot}\${baseConfigName}.packerfile.json" $packedBoxPath = "${outDir}\${baseConfigName}_virtualbox.box" $vagrantTemplate = "${packerConfigRoot}\vagrantfile-${baseConfigName}.template" - -function Test-PowershellSyntax { - [cmdletbinding()] - param( - [parameter(mandatory=$true)] [string] $fileName, - [switch] $ThrowOnFailure - ) - $tokens = @() - $parseErrors = @() - $parser = [System.Management.Automation.Language.Parser] - $fileName = resolve-path $fileName - $parsed = $parser::ParseFile($fileName, [ref]$tokens, [ref]$parseErrors) - - if ($parseErrors.count -gt 0) { - $message = "$($parseErrors.count) parse errors found in file '$fileName':`r`n" - $parseErrors |% { $message += "`r`n $_" } - if ($ThrowOnFailure) { throw $message } else { write-verbose $message } - return $false - } - return $true -} function Build-PackerFile { [cmdletbinding()] @@ -185,7 +163,7 @@ function Show-LabVariable { if (-not $SkipSyntaxcheck) { foreach ($script in (gci $PSScriptRoot\scripts\* -include *.ps1,*.psm1)) { - $valid = Test-PowershellSyntax -fileName $script.fullname + $valid = Test-PowershellSyntax -fileName $script.fullname -ThrowOnFailure New-Object PSObject -Property @{ ScriptName = $script.name ValidSyntax = $valid @@ -228,6 +206,3 @@ if ($AddToVagrant) { if ($VagrantUp) { Run-VagrantBox -vagrantBoxName $fullConfigName -workingDirectory $outDir -whatif:$whatif } - -#} - diff --git a/packer/windows_81_x86/Autounattend.xml b/packer/windows_81_x86/Autounattend.xml index 5e453e7..5bd48e4 100644 --- a/packer/windows_81_x86/Autounattend.xml +++ b/packer/windows_81_x86/Autounattend.xml @@ -76,11 +76,6 @@ en-US - - - false - - diff --git a/readme.markdown b/readme.markdown index 4ee8498..cf12683 100644 --- a/readme.markdown +++ b/readme.markdown @@ -74,3 +74,8 @@ upstream improvements - The shell, windows-shell, and powershell provisioners are VERY finicky. I canNOT make them work reliably. The easiest thing I can figure out how to do is to use a Powershell provisioner to call a file with no arguments over WinRM. lmfao - However the situation was much improved when I switched to WinRM with the powershell provisioner. That seems to work OK - I think the problem was that using the shell provisioner with OpenSSH, which provides an emulated POSIX environment of some kind +- There's lots of information on the Internet claiming you can use `HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\RunServices` (or `RunServicesOnce`) to run something at boot, before logon - analogous to the `Run`/`RunOnce` keys. This is apparently false for any NT operating system. Properties on these keys DO NOT RUN AT BOOT - they are completely ignored by the operating system. + - The original packer-windows crew got aroudn this by using the `Run` key and disabling UAC in `Autounattend.xml` + - I'm planning to get around this by creating a scheduled task that starts at boot and runs with highest privileges. This won't work pre-Vista/2008, but that's OK with me. + - This means I need to write an executor that can start at boot, and then check for things to execute located elsewhere. Bleh. + diff --git a/scripts/autounattend-postinstall.ps1 b/scripts/autounattend-postinstall.ps1 index 7ab42be..a145170 100644 --- a/scripts/autounattend-postinstall.ps1 +++ b/scripts/autounattend-postinstall.ps1 @@ -1,4 +1,7 @@ -[cmdletbinding()] param() +[cmdletbinding(DefaultParameterSetName="RunWindowsUpdates")] param( + [Parameter(ParameterSetName="RunWindowsUpdates")] [switch] $RunWindowsUpdates, + [Parameter(Mandatory=$true,ParameterSetName="SkipWindowsUpdates")] [switch] $SkipWindowsUpdates +) import-module $PSScriptRoot\wintriallab-postinstall.psm1 $errorActionPreference = "Stop" @@ -9,25 +12,22 @@ Invoke-ScriptblockAndCatch -scriptBlock { Set-PasswordExpiry -accountName "vagrant" -expirePassword $false Disable-HibernationFile Enable-MicrosoftUpdate - Set-AllNetworksToPrivate # Required for Windows 10, not required for 81, not sure about other OSes - Install-VBoxAdditions -fromDisc # Need to reboot for some of these drivers to take - Set-PinnedApplication -Action PinToTaskbar -Filepath "$PSHOME\Powershell.exe" - Set-PinnedApplication -Action PinToTaskbar -Filepath "${env:SystemRoot}\system32\eventvwr.msc" + # Need to reboot for some of these drivers to take + # Requires that the packer file attach the Guest VM driver disc, rather than upload it + # (Uploading it also gives problems when using WinRM - too big? - so this is a better solution anyway) + Install-VBoxAdditions -fromDisc - # To reboot, then run Windows updates, then enable WinRM: - $winRmCommand = "$PSHome\powershell.exe -File A:\enable-winrm.ps1" - $winUpdateCommand = "$PSHOME\powershell.exe -File A:\win-updates.ps1 -RestartAction RunAtLogon -PostUpdateExpression `"$winRmCommand`"" + # Required for Windows 10, not required for 81, not sure about other OSes + # Should probably happen after installing Guest VM drivers, in case installing the drivers would cause Windows to see the network as a new connection + Set-AllNetworksToPrivate - # To install Windows Updates then enable WinRM after reboot: - Set-RestartRegistryEntry -restartAction RunAtLogon -restartCommand $winUpdateCommand - - # To just enable WinRM without installing updates after reboot: - #Set-RestartRegistryEntry -restartAction RunAtLogon -restartCommand $winRmCommand - - $message = "Checking restart registry key: `r`n" - Get-RestartRegistryEntry | select -expand StringRepr |% { $message += "`r`n$_`r`n"} - Write-EventLogWrapper $message - + switch ($PsCmdlet.ParameterSetName) { + "RunWindowsUpdates" { $restartCommand = [ScriptBlock]::Create("A:\win-updates.ps1 -PostUpdateExpression A:\enable-winrm.ps1") } + "SkipWindowsUpdates" { $restartCommand = [ScriptBlock]::Create("A:\enable-winrm.ps1") } + default { throw "Not configured for this parameter set..." } + } + Set-RestartScheduledTask -RestartCommand $restartCommand | out-null Restart-Computer -force } + diff --git a/scripts/provisioner-postinstall.ps1 b/scripts/provisioner-postinstall.ps1 index f5fc8b3..84da961 100644 --- a/scripts/provisioner-postinstall.ps1 +++ b/scripts/provisioner-postinstall.ps1 @@ -6,13 +6,19 @@ Don't require parameters - it won't run with parameters during post install. Thi $packerBuildName = ${env:PACKER_BUILD_NAME}, $packerBuilderType = ${env:PACKER_BUILDER_TYPE} ) -$errorActionPreference = "stop" +$errorActionPreference = "Continue" import-module $PSScriptRoot\wintriallab-postinstall.psm1 -# Gotta do this outside Invoke-ScriptblockAndCatch because I need to use try/catch here: + +# These commands are fragile and shouldn't fail the build if they fail, so I put them in a try/catch outside of Invoke-ScriptblockAndCatch try { Set-PinnedApplication -Action UnpinFromTaskbar -Filepath "C:\Program Files\WindowsApps\Microsoft.WindowsStore_2015.10.5.0_x86__8wekyb3d8bbwe\WinStore.Mobile.exe" -ErrorAction Continue + Set-PinnedApplication -Action PinToTaskbar -Filepath "$PSHOME\Powershell.exe" + Set-PinnedApplication -Action PinToTaskbar -Filepath "${env:SystemRoot}\system32\eventvwr.msc" + $UserPinnedTaskBar = "${env:AppData}\Microsoft\Internet Explorer\Quick Launch\User Pinned\TaskBar" + if (test-path "$UserPinnedTaskBar\Server Manager.lnk") { rm "$UserPinnedTaskBar\Server Manager.lnk" } } catch {} + Invoke-ScriptblockAndCatch -scriptBlock { Write-EventLogWrapper "PostInstall for packer build '$packerBuildName' of type '$packerBuilderType'" Install-SevenZip @@ -33,14 +39,6 @@ Invoke-ScriptblockAndCatch -scriptBlock { } Set-UserOptions @suoParams - # TODO: This would be better done as links because they're easier to deal with later - # TODO: document the difference between using Set-PinnedApplication vs the links in AppData - Set-PinnedApplication -Action PinToTaskbar -Filepath "$PSHOME\Powershell.exe" - Set-PinnedApplication -Action PinToTaskbar -Filepath "${env:SystemRoot}\system32\eventvwr.msc" - $UserPinnedTaskBar = "${env:AppData}\Microsoft\Internet Explorer\Quick Launch\User Pinned\TaskBar" - if (test-path "$UserPinnedTaskBar\Server Manager.lnk") { rm "$UserPinnedTaskBar\Server Manager.lnk" } - - Install-CompiledDotNetAssemblies # Takes about 15 minutes for me Compress-WindowsInstall # Takes maybe another 15 minutes } diff --git a/scripts/win-updates.ps1 b/scripts/win-updates.ps1 index d6d8329..5507119 100644 --- a/scripts/win-updates.ps1 +++ b/scripts/win-updates.ps1 @@ -4,23 +4,18 @@ Run Windows Update, installing all available updates and rebooting as necessary .parameter MaxCycles The number of times to check for updates before forcing a reboot, even if Windows Update has not indicated that one is required TODO: this doesn't appear to be working reliably -.parameter ScriptProductName -The name for this script that you want to make visible to the sysadmin in logs. .parameter PostUpdateExpression A string representing a PowerShell expression that is run one time after all updates have been applied (or MaxCycles has been reached without rebooting) TODO: the parenthetical is nonobvious behavior and should be eliminated. In fact the whole of MaxCycles should be rethought. -.parameter RestartAction -When reboots are required, you can choose to run this script again before logon (using the RunServicesOnce key) or after logon (using the RunOnce key), or to skip reboots. +.parameter NoRestart +Don't reboot (useful for debugging) .notes This script can be run directly, but it can also be dot-sourced to get access to internal functions without automatically checking for updates, applying them, or rebooting -This script is intended to be 100% standalone because it needs to be able to tell Windows to call it again upon reboot. There are intentionally no dependencies, and features related to Windows Update that don't fit here (such as enable Microsoft Update) should live elsewhere #> param( [int] $MaxCycles = 5, - [string] $ScriptProductName = "WinUp-Marionettist", [string] $PostUpdateExpression, - [string] [ValidateSet('RunBeforeLogon','RunAtLogon','NoRestart')] $RestartAction = "NoRestart", - [switch] $CalledFromRegistry + [switch] $NoRestart ) <# Notes on the code: @@ -28,123 +23,39 @@ param( I'm trying to use StrictMode. That ends up making some code more complex than it would have to be otherwise. For instance, sometimes I have to check that a property exists (like $SomeVar.PSObject.Properties['PropertyName']) before I can use it. TODO: Make sure every path of my program is writing unique log messages. Make sure all those paths are working. -TODO: Pass unique event IDs for every event to Write-WinUpEventLog. ?? +TODO: Pass unique event IDs for every event to Write-EventLogWrapper. ?? #> $ErrorActionPreference = "Stop" -Set-StrictMode -version 2.0 + +import-module $PSScriptRoot\wintriallab-postinstall.psm1 # I need to get the path to this script from inside functions, which mess with the $MyInvocation variable $script:ScriptPath = $MyInvocation.MyCommand.Path -$script:RestartRegistryKeys = @{ - RunBeforeLogon = "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\RunServicesOnce" - RunAtLogon = "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\RunOnce" -} -$script:RestartRegistryProperty = "$ScriptProductName" - -<# -.synopsis -Write to a special event log, named after the $ScriptProductName. -If that event log doesn't already exist, create it first. -#> -function Write-WinUpEventLog { - param( - [parameter(mandatory=$true)] [String] $message, - [int] $eventId = 0, - [ValidateSet("Error",'Warning','Information','SuccessAudit','FailureAudit')] $entryType = "Information" - ) - $eventLogName = $ScriptProductName - if (-not (get-eventlog -logname * |? { $_.Log -eq $eventLogName })) { - New-EventLog -Source $ScriptProductName -LogName $eventLogName - } - $messagePlus = "$message`r`n`r`nScript: $($script:ScriptPath)`r`nUser: ${env:USERDOMAIN}\${env:USERNAME}" - Write-Host -foreground magenta "====Writing to $ScriptProductName event log====" - write-host -foreground darkgray "$messagePlus`r`n" - Write-EventLog -LogName $eventLogName -Source $ScriptProductName -EventID $eventId -EntryType $entryType -Message $MessagePlus -} -<# -.synopsis -Create and set the registry property which will run this script on reboot -#> -function Set-RestartRegistryEntry { +function Restart-ComputerAndUpdater { param( [parameter(mandatory=$true)] [int] $CyclesRemaining, - [parameter(mandatory=$true)] [ValidateSet('RunBeforeLogon','RunAtLogon','NoRestart')] [string] $RestartAction, - [string] $scriptPath = $script:ScriptPath + [parameter(mandatory=$true)] [switch] $NoRestart ) - - if ($RestartAction -match "NoRestart") { - Write-WinUpEventLog "Called Set-RestartRegistryEntry with -RestartAction NoRestart, will not write registry key" - return - } - - $psCallComponents = @( - "$PSHOME\powershell.exe" - ('-File "{0}"' -f $scriptPath) + $restartCommandComponents = @( + ('& "{0}"' -f $scriptPath) "-MaxCycles $CyclesRemaining" - "-ScriptProductName $ScriptProductName" - "-CalledFromRegistry" - "-RestartAction ${script:RestartAction}" - ('-PostUpdateExpression "{0}"' -f "$PostUpdateExpression") - ) - $psCall = $psCallComponents -join " " - $message = "Setting the Restart Registry Key at: {0}\{1}`r`n{2}" -f $script:RestartRegistryKeys.$RestartAction, $script:RestartRegistryProperty, $psCall - Write-WinUpEventLog -message $message - New-Item $script:RestartRegistryKeys.$RestartAction -force | out-null - Set-ItemProperty -Path $script:RestartRegistryKeys.$RestartAction -Name $script:RestartRegistryProperty -Value $psCall -} - - -function Remove-RestartRegistryEntries { - [cmdletbinding()] param() - foreach ($key in $script:RestartRegistryKeys.Keys) { - try { Remove-ItemProperty -Path $script:RestartRegistryKeys[$key] -name $script:RestartRegistryProperty} catch {} - } -} - -function Get-RestartRegistryEntry { - [cmdletbinding()] param() - $entries = @() - foreach ($key in $script:RestartRegistryKeys.Keys) { - $regKey = $script:RestartRegistryKeys[$key] - $entry = New-Object PSObject -Property @{ - RegistryKey = $regKey - RegistryProperty = $script:RestartRegistryProperty - PropertyValue = $null - } - if (test-path $regKey) { - $regProps = get-item $regKey | select -expand Property - if ($regProps -contains $script:RestartRegistryProperty) { - $entry.PropertyValue = Get-ItemProperty -path $regKey | select -expand $script:RestartRegistryProperty - } - } - Add-Member -inputObject $entry -memberType ScriptProperty -Name StringRepr -Value { - "Key: $($this.RegistryKey), Property: $($this.RegistryProperty), Value: $($this.PropertyValue)" - } - $entries += @($entry) - } - return $entries -} - -function Restart-ComputerAndUpdater { - param( - [parameter(mandatory=$true)] [int] $CyclesRemaining, - [parameter(mandatory=$true)] [ValidateSet('RunBeforeLogon','RunAtLogon','NoRestart')] [string] $RestartAction ) - Remove-RestartRegistryEntries - if ($RestartAction -match "NoRestart") { - Write-WinUpEventLog "Restart-ComputerAndUpdater was called, but '-RestartAction NoRestart' was passed; exiting instead..." + if ($PostUpdateExpression) { $restartCommandComponents += '-PostUpdateExpression "{0}"' -f "$PostUpdateExpression" } + if ($NoRestart) { $restartCommandComponents += "-NoRestart" } + $restartCommand = [ScriptBlock]::Create(($restartCommandComponents -join " ")) + Set-RestartScheduledTask -RestartCommand $restartCommand | out-null + + if ($NoRestart) { + Write-EventLogWrapper "Restart-ComputerAndUpdater was called, but '-NoRestart' was passed; exiting instead." exit 1 } else { - Set-RestartRegistryEntry -CyclesRemaining $CyclesRemaining -RestartAction $RestartAction - $message = "Rebooting...`r`n`r`nChecking restart registry key:" - Get-RestartRegistryEntry | select -expand StringRepr |% { $message += "`r`n`r`n$_"} - Write-WinUpEventLog $message + Write-EventLogWrapper "Restarting..." Restart-Computer -Force - exit 0 # Restarting returns immediately and script execution continues until the reboot is processed. Lol. - } + exit 0 # Restarting returns immediately and script execution continues until the reboot is processed. Lol. + } } <# @@ -154,7 +65,7 @@ Return a new Microsoft Update Session for use with Check-WindowsUpdates and Inst function New-UpdateSession { [cmdletbinding()] param() $UpdateSession = New-Object -ComObject 'Microsoft.Update.Session' - $UpdateSession.ClientApplicationID = "$ScriptProductName" + $UpdateSession.ClientApplicationID = "win-updates.ps1" return $UpdateSession } @@ -164,11 +75,11 @@ Run the expression passed to this script with -PostUpdateExpression, if any #> function Run-PostUpdate { if ($PostUpdateExpression) { - Write-WinUpEventLog -message "Running PostUpdate expression:`r`n`r`n${PostUpdateExpression}" + Write-EventLogWrapper -message "Running PostUpdate expression:`r`n`r`n${PostUpdateExpression}" Invoke-Expression $PostUpdateExpression } else { - Write-WinUpEventLog -message "No PostUpdate to run" + Write-EventLogWrapper -message "No PostUpdate to run" } } @@ -185,17 +96,17 @@ function Get-RestartPendingStatus { # Component Based Servicing (aka Windows components) (Vista+ only) $CsbProp = Get-ItemProperty "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Component Based Servicing" if ($CsbProp.PSObject.Properties['RebootPending']) { - Write-WinUpEventLog "(Un)installation of a Windows component requires a restart" + Write-EventLogWrapper "(Un)installation of a Windows component requires a restart" return $true } $WuauProp = "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\WindowsUpdate\Auto Update" if ($WuauProp.PSObject.Properties['RebootRequired']) { - Write-WinUpEventLog "Updates have been installed recently, but the machine must be restarted before installation is complete" + Write-EventLogWrapper "Updates have been installed recently, but the machine must be restarted before installation is complete" return $true } - Write-WinUpEventLog "There are no Windows Update -related reboots pending" + Write-EventLogWrapper "There are no Windows Update -related reboots pending" return $false } @@ -219,7 +130,7 @@ function Install-WindowsUpdates { ) if (-not $UpdateList) { - Write-WinUpEventLog -message "No Updates To Download..." + Write-EventLogWrapper -message "No Updates To Download..." return $false } @@ -239,9 +150,9 @@ function Install-WindowsUpdates { } $message = "There were $($AcceptedEulas.count) updates with a EULA which was automatically accepted`r`n" $AcceptedEulas |% { $message += "`r`n - $($_.Title)"} - Write-WinUpEventLog $message + Write-EventLogWrapper $message - Write-WinUpEventLog -message 'Downloading Updates...' + Write-EventLogWrapper -message 'Downloading Updates...' $ok = $false $attempts = 0 while ((! $ok) -and ($attempts -lt $MaxDownloadAttempts)) { @@ -253,7 +164,7 @@ function Install-WindowsUpdates { } catch { $message = "Error downloading updates. Retrying in 30s.`r`n`r`n$($_.Exception)" - Write-WinUpEventLog -message $message + Write-EventLogWrapper -message $message $attempts += 1 Start-Sleep -s 30 } @@ -267,7 +178,7 @@ function Install-WindowsUpdates { $UpdatesToInstall.Add($Update) |Out-Null } } - Write-WinUpEventLog -message $message + Write-EventLogWrapper -message $message $RebootRequired = $false if ($UpdatesToInstall.Count -gt 0) { @@ -275,7 +186,7 @@ function Install-WindowsUpdates { $Installer.Updates = $UpdatesToInstall if ($Installer.PSObject.Properties['RebootRequiredBeforeInstallation'] -and $Installer.RebootRequiredBeforeInstallation) { - Write-WinUpEventLog "Reboot required before installation. (This can happen when updates are installed but the machine is not rebooted before trying to install again.)" + Write-EventLogWrapper "Reboot required before installation. (This can happen when updates are installed but the machine is not rebooted before trying to install again.)" return $true } @@ -288,10 +199,10 @@ function Install-WindowsUpdates { $message += "`r`n`r`nItem: $($update.Title)" $message += "`r`nResult: $ResultCode" } - Write-WinUpEventLog -message $message + Write-EventLogWrapper -message $message } else { - Write-WinUpEventLog -message 'No updates available to install...' + Write-EventLogWrapper -message 'No updates available to install...' } return $RebootRequired } @@ -314,7 +225,7 @@ function Check-WindowsUpdates { [switch] $FilterInteractiveUpdates, [int] $MaxSearchAttempts = 12 ) - Write-WinUpEventLog -message "Checking for Windows Updates at $(Get-Date)" -eventId 104 + Write-EventLogWrapper -message "Checking for Windows Updates at $(Get-Date)" -eventId 104 $SearchResult = $null $UpdateSearcher = $UpdateSession.CreateUpdateSearcher() @@ -327,7 +238,7 @@ function Check-WindowsUpdates { } catch { $message = "Search call to UpdateSearcher was unsuccessful. Retrying in 10s.`r`n`r`n$($_.Exception | Format-List -force)" - Write-WinUpEventLog -message $message + Write-EventLogWrapper -message $message $attempts += 1 Start-Sleep -s 10 } @@ -358,7 +269,7 @@ function Check-WindowsUpdates { if ($SkippedUpdates.Count -gt 0) { $SkippedUpdates |% { $message += "`r`n - $($_.Title)" } } - Write-WinUpEventLog -message $message + Write-EventLogWrapper -message $message return $ApplicableUpdates } @@ -366,32 +277,34 @@ function Check-WindowsUpdates { <# .synopsis Run Windows Update, installing all available updates and rebooting as necessary +.notes +- Skips any update that requires user interaction +- Automatically accepts the EULA of all the updates it installs #> function Run-WindowsUpdate { [cmdletbinding()] param() - $message = "Running Windows Update " - $message += if ($CalledFromRegistry) { "from the Restart Registry Entry" } else { "after being called directly" } - Write-WinUpEventLog $message + $message = "Running Windows Update..." + Write-EventLogWrapper $message if (Get-RestartPendingStatus) { - Restart-ComputerAndUpdater -CyclesRemaining $maxCycles -RestartAction $script:RestartAction + Restart-ComputerAndUpdater -CyclesRemaining $maxCycles -NoRestart:$NoRestart } $UpdateSession = New-UpdateSession for (; $maxCycles -gt 0; $maxCycles -= 1) { - Write-WinUpEventLog -message "Starting to check for updates. $maxCycles cycles remain." + Write-EventLogWrapper -message "Starting to check for updates. $maxCycles cycles remain." $CheckedUpdates = Check-WindowsUpdates -FilterInteractiveUpdates -UpdateSession $UpdateSession if (-not $CheckedUpdates) { - Write-WinUpEventLog "No applicable updates were detected. Done!" + Write-EventLogWrapper "No applicable updates were detected. Done!" break } $RebootRequired = Install-WindowsUpdates -UpdateSession $UpdateSession -UpdateList $CheckedUpdates if ($RebootRequired) { - Write-WinUpEventLog -message "Restart Required - Restarting..." - Restart-ComputerAndUpdater -CalledFromRegistry -CyclesRemaining $maxCycles -RestartAction $script:RestartAction + Write-EventLogWrapper -message "Restart Required - Restarting..." + Restart-ComputerAndUpdater -CyclesRemaining $maxCycles -NoRestart:$NoRestart } } Run-PostUpdate @@ -406,7 +319,7 @@ if ($MyInvocation.InvocationName -ne '.') { $message += "======== ERROR STACK ========`r`n" $error |% { $message += "$_`r`n----`r`n" } $message += "======== ========" - Write-WinUpEventLog $message + Write-EventLogWrapper $message exit 666 } } diff --git a/scripts/wintriallab-postinstall.psm1 b/scripts/wintriallab-postinstall.psm1 index 8264b1f..f05f7ff 100644 --- a/scripts/wintriallab-postinstall.psm1 +++ b/scripts/wintriallab-postinstall.psm1 @@ -29,11 +29,6 @@ $URLs = @{ SdeleteDownload = "http://download.sysinternals.com/files/SDelete.zip" } $script:ScriptPath = $MyInvocation.MyCommand.Path -$script:RestartRegistryKeys = @{ - RunBeforeLogon = "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\RunServicesOnce" - RunAtLogon = "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\RunOnce" -} -$script:RestartRegistryProperty = "$ScriptProductName" ### Private support functions I use behind the scenes @@ -54,25 +49,38 @@ function Get-WebUrl { <# .synopsis -Invoke an expression; log the expression, any output, and the last exit code +Invoke an expression; log the expression, optionally with any output, and the last exit code if appropriate #> -function Invoke-ExpressionAndLog { +function Invoke-ExpressionEx { [cmdletbinding()] param( [parameter(mandatory=$true)] [string] $command, [switch] $invokeWithCmdExe, [switch] $checkExitCode, + [switch] $logToStdout, [int] $sleepSeconds ) $global:LASTEXITCODE = 0 - if ($invokeWithCmdExe) { - Write-EventLogWrapper "Invoking CMD: '$command'" - $output = cmd /c "$command" + $commandSb = if ($invokeWithCmdExe) {{cmd /c "$command"}} else {{invoke-expression -command $command}} + Write-EventLogWrapper "Invoke-ExpressionEx called to run command '$command'`r`n`r`nUsing scriptblock: $($commandSb.ToString())" + $output = $null + + try { + if ($logToStdout) { + $commandSb.invoke() + $message = "Expression '$command' exited with code '$LASTEXITCODE'" + } + else { + $output = $commandSb.invoke() + $message = "Expression '$command' exited with code '$LASTEXITCODE' and output the following to the console:`r`n`r`n$output" + } + Write-EventLogWrapper -message $message } - else { - Write-EventLogWrapper "Invoking Powershell expression: '$command'" - $output = invoke-expression -command $command + catch { + Write-EventLogWrapper -message "Invoke-ExpressionEx faile3d to run command '$command'" + Write-ErrorStackToEventLog -errorStack $_ + throw $_ } - Write-EventLogWrapper "Expression '$command' had a last exit code of '$LastExitCode' and output the following to the console:`r`n`r`n$output" + if ($checkExitCode -and $global:LASTEXITCODE -ne 0) { throw "LASTEXITCODE: ${global:LASTEXITCODE} for command: '${command}'" } @@ -97,6 +105,7 @@ function Write-EventLogWrapper { New-EventLog -Source $EventLogSource -LogName $eventLogName } $messagePlus = "$message`r`n`r`nScript: $($script:ScriptPath)`r`nUser: ${env:USERDOMAIN}\${env:USERNAME}" + if ($messagePlus.length -gt 32766) { $messagePlus = $messagePlus.SubString(0,32766) } # Because Write-EventLog will die otherwise Write-Host -foreground magenta "====Writing to $EvengLogName event log====" write-host -foreground darkgray "$messagePlus`r`n" Write-EventLog -LogName $eventLogName -Source $EventLogSource -EventID $eventId -EntryType $entryType -Message $MessagePlus @@ -134,55 +143,89 @@ function Write-ErrorStackToEventLog { Write-EventLogWrapper $message } -<# -.synopsis -Create and set the registry property which will run this script on reboot -#> -function Set-RestartRegistryEntry { +function Test-PowershellSyntax { + [cmdletbinding(DefaultParameterSetName='FromText')] param( - [parameter(mandatory=$true)] [ValidateSet('RunBeforeLogon','RunAtLogon','NoRestart')] [string] $RestartAction, - [string] $restartCommand + [parameter(mandatory=$true,ParameterSetName='FromText')] [string] $text, + [parameter(mandatory=$true,ParameterSetName='FromFile')] [string] $fileName, + [switch] $ThrowOnFailure ) + $tokens = @() + $parseErrors = @() + $parser = [System.Management.Automation.Language.Parser] + if ($pscmdlet.ParameterSetName -eq 'FromText') { + $parsed = $parser::ParseInput($text, [ref]$tokens, [ref]$parseErrors) + } + elseif ($pscmdlet.ParameterSetName -eq 'FromFile') { + $fileName = resolve-path $fileName + $parsed = $parser::ParseFile($fileName, [ref]$tokens, [ref]$parseErrors) + } + write-verbose "$($tokens.count) tokens found." - if ($RestartAction -match "NoRestart") { - Write-EventLogWrapper "Called Set-RestartRegistryEntry with -RestartAction NoRestart, will not write registry key" - return + if ($parseErrors.count -gt 0) { + $message = "$($parseErrors.count) parse errors found in file '$fileName':`r`n" + $parseErrors |% { $message += "`r`n $_" } + if ($ThrowOnFailure) { throw $message } else { write-verbose $message } + return $false } + return $true +} + + +<# +.description +Set a scheduled task to run on next logon of the calling user. Intended for tasks that need to reboot and then be restarted such as applying Windows Updates +.notes +The Powershell New-ScheduledTask cmdlet is broken for me on Win81, but SchTasks.exe doesn't support actions with long arguments (requires a command line of < 200something characters). lmfao. +My workaround is to take a scriptblock, and then just save it to a file and call the file from Powershell. +I create the scheduled task with SchTasks.exe, then modify it with Powershell cmdlets that can handle long arguments just fine +#> +function Set-RestartScheduledTask { + [cmdletbinding()] param( + [Parameter(Mandatory=$true)] [Scriptblock] $restartCommand, + [string] $tempRestartScriptPath = "${env:temp}\$ScriptProductName-TempRestartScript.ps1", + [string] $taskName = "$ScriptProductName-RestartTask" + ) + Remove-RestartScheduledTask -taskName $taskName + + $currentUser = [Security.Principal.WindowsIdentity]::GetCurrent().Name + + $restartCommand.ToString() | Out-File -FilePath $tempRestartScriptPath + "Unregister-ScheduledTask -taskName '$taskName' -Confirm:`$false" | Out-File -Append -FilePath $tempRestartScriptPath + Test-PowershellSyntax -ThrowOnFailure -FileName $tempRestartScriptPath + + $schTasksCmd = 'SchTasks.exe /create /sc ONLOGON /tn "{0}" /tr "cmd.exe /c echo TemporparyPlaceholderCommand" /ru "{1}" /it /rl HIGHEST /f' -f $taskName,$currentUser + Invoke-ExpressionEx -command $schTasksCmd -invokeWithCmdExe -checkExitCode + + # SchTasks.exe cannot specify a user for the LOGON schedule - it applies to all users. Modify it here: + $trigger = New-ScheduledTaskTrigger -AtLogon -User $currentUser + # SchTasks.exe cannot specify an action with long arguments (maxes out at like 200something chars). Modify it here: + $action = New-ScheduledTaskAction -Execute "$PSHome\Powershell.exe" -Argument "-File `"$tempRestartScriptPath`"" + Set-ScheduledTask -taskname $taskName -action $action -trigger $trigger - $message = "Setting the Restart Registry Key at: {0}\{1}`r`n{2}" -f $script:RestartRegistryKeys.$RestartAction, $script:RestartRegistryProperty, $restartCommand - Write-EventLogWrapper -message $message - New-Item $script:RestartRegistryKeys.$RestartAction -force | out-null - Set-ItemProperty -Path $script:RestartRegistryKeys.$RestartAction -Name $script:RestartRegistryProperty -Value $restartCommand + $message = "Created scheduled task called '$taskName', which will run a temp file at '$tempRestartScriptPath', containing:`r`n`r`n" + $message += (Get-Content $tempRestartScriptPath) -join "`r`n" + Write-EventLogWrapper -message $message } -function Get-RestartRegistryEntry { - [cmdletbinding()] param() - $entries = @() - foreach ($key in $script:RestartRegistryKeys.Keys) { - $regKey = $script:RestartRegistryKeys[$key] - $entry = New-Object PSObject -Property @{ - RegistryKey = $regKey - RegistryProperty = $script:RestartRegistryProperty - PropertyValue = $null - } - if (test-path $regKey) { - $regProps = get-item $regKey | select -expand Property - if ($regProps -contains $script:RestartRegistryProperty) { - $entry.PropertyValue = Get-ItemProperty -path $regKey | select -expand $script:RestartRegistryProperty - } - } - Add-Member -inputObject $entry -memberType ScriptProperty -Name StringRepr -Value { - "Key: $($this.RegistryKey), Property: $($this.RegistryProperty), Value: $($this.PropertyValue)" - } - $entries += @($entry) - } - return $entries +function Get-RestartScheduledTask { + [cmdletbinding()] param( + [string] $taskName = $ScriptProductName + ) + Get-ScheduledTask |? -Property TaskName -match $taskName } -function Remove-RestartRegistryEntries { - [cmdletbinding()] param() - foreach ($key in $script:RestartRegistryKeys.Keys) { - try { Remove-ItemProperty -Path $script:RestartRegistryKeys[$key] -name $script:RestartRegistryProperty} catch {} +function Remove-RestartScheduledTask { + [cmdletbinding()] param( + [string] $taskName = $ScriptProductName + ) + $existingTask = Get-RestartScheduledTask -taskName $taskName + if ($existingTask) { + Write-EventLogWrapper -message "Found existing task named '$taskName'; deleting..." + Unregister-ScheduledTask -InputObject $existingTask -Confirm:$false | out-null + } + else { + Write-EventLogWrapper -message "Did not find any existing task named '$taskName'" } } @@ -221,7 +264,7 @@ function Install-SevenZip { Write-EventLogWrapper "Downloaded '$($URLs.SevenZipDownload.$OSArch)' to '$szDlPath', now running msiexec..." $msiCall = '& msiexec /qn /i "{0}"' -f $szDlPath # Windows suxxx so msiexec sometimes returns right away? or something idk. fuck - Invoke-ExpressionAndLog -checkExitCode -command $msiCall -sleepSeconds 30 + Invoke-ExpressionEx -checkExitCode -command $msiCall -sleepSeconds 30 } finally { rm -force $szDlPath @@ -242,9 +285,9 @@ function Install-VBoxAdditions { Write-EventLogWrapper "Installing the Oracle certificate..." $oracleCert = resolve-path "$baseDir\cert\oracle-vbox.cer" | select -expand path # NOTE: Checking for exit code, but this command will fail with an error if the cert is already installed - Invoke-ExpressionAndLog -checkExitCode -command ('& "{0}" add-trusted-publisher "{1}" --root "{1}"' -f "$baseDir\cert\VBoxCertUtil.exe",$oracleCert) + Invoke-ExpressionEx -checkExitCode -command ('& "{0}" add-trusted-publisher "{1}" --root "{1}"' -f "$baseDir\cert\VBoxCertUtil.exe",$oracleCert) Write-EventLogWrapper "Installing the virtualbox additions" - Invoke-ExpressionAndLog -checkExitCode -command ('& "{0}" /with_wddm /S' -f "$baseDir\VBoxWindowsAdditions.exe") # returns IMMEDIATELY, goddamn fuckers + Invoke-ExpressionEx -checkExitCode -command ('& "{0}" /with_wddm /S' -f "$baseDir\VBoxWindowsAdditions.exe") # returns IMMEDIATELY, goddamn fuckers while (get-process -Name VBoxWindowsAdditions*) { write-host 'Waiting for VBox install to finish...'; sleep 1; } Write-EventLogWrapper "virtualbox additions have now been installed" } @@ -255,7 +298,7 @@ function Install-VBoxAdditions { $vbgaPath = mkdir -force "${env:Temp}\InstallVbox" | select -expand fullname try { Write-EventLogWrapper "Extracting iso at '$isoPath' to directory at '$vbgaPath'..." - Invoke-ExpressionAndLog -checkExitCode -command ('sevenzip x "{0}" -o"{1}"' -f $isoPath, $vbgaPath) + Invoke-ExpressionEx -checkExitCode -command ('sevenzip x "{0}" -o"{1}"' -f $isoPath, $vbgaPath) InstallVBoxAdditionsFromDir $vbgaPath } finally { @@ -292,15 +335,21 @@ function Install-CompiledDotNetAssemblies { # http://support.microsoft.com/kb/2570538 # http://robrelyea.wordpress.com/2007/07/13/may-be-helpful-ngen-exe-executequeueditems/ # Don't check the return value - sometimes it fails and that's fine - - set-alias ngen32 "${env:WinDir}\microsoft.net\framework\v4.0.30319\ngen.exe" - ngen32 update /force /queue - ngen32 executequeueditems + + $ngen32 = "${env:WinDir}\microsoft.net\framework\v4.0.30319\ngen.exe" + Invoke-ExpressionEx "$ngen32 update /force /queue" + Invoke-ExpressionEx "$ngen32 executequeueditems" + # set-alias ngen32 "${env:WinDir}\microsoft.net\framework\v4.0.30319\ngen.exe" + # ngen32 update /force /queue + # ngen32 executequeueditems if ((Get-OSArchitecture) -match $ArchitectureId.amd64) { - set-alias ngen64 "${env:WinDir}\microsoft.net\framework64\v4.0.30319\ngen.exe" - ngen64 update /force /queue - ngen64 executequeueditems + # set-alias ngen64 "${env:WinDir}\microsoft.net\framework64\v4.0.30319\ngen.exe" + # ngen64 update /force /queue + # ngen64 executequeueditems + $ngen64 = "${env:WinDir}\microsoft.net\framework64\v4.0.30319\ngen.exe" + Invoke-ExpressionEx "$ngen64 update /force /queue" + Invoke-ExpressionEx "$ngen64 executequeueditems" } } @@ -310,19 +359,19 @@ function Compress-WindowsInstall { $udfZipPath = Get-WebUrl -url $URLs.UltraDefragDownload.$OSArch -outDir $env:temp $udfExPath = "${env:temp}\ultradefrag-portable-6.1.0.$OSArch" # This archive contains a folder - extract it directly to the temp dir - Invoke-ExpressionAndLog -checkExitCode -command ('sevenzip x "{0}" "-o{1}"' -f $udfZipPath,$env:temp) + Invoke-ExpressionEx -checkExitCode -command ('sevenzip x "{0}" "-o{1}"' -f $udfZipPath,$env:temp) $sdZipPath = Get-WebUrl -url $URLs.SdeleteDownload -outDir $env:temp $sdExPath = "${env:temp}\SDelete" # This archive does NOT contain a folder - extract it to a subfolder (will create if necessary) - Invoke-ExpressionAndLog -checkExitCode -command ('sevenzip x "{0}" "-o{1}"' -f $sdZipPath,$sdExPath) + Invoke-ExpressionEx -checkExitCode -command ('sevenzip x "{0}" "-o{1}"' -f $sdZipPath,$sdExPath) stop-service wuauserv rm -recurse -force ${env:WinDir}\SoftwareDistribution\Download start-service wuauserv - Invoke-ExpressionAndLog -checkExitCode -command ('& {0} --optimize --repeat "{1}"' -f "$udfExPath\udefrag.exe","$env:SystemDrive") - Invoke-ExpressionAndLog -checkExitCode -command ('& {0} /accepteula -q -z "{1}"' -f "$sdExPath\SDelete.exe",$env:SystemDrive) + Invoke-ExpressionEx -checkExitCode -logToStdout -command ('& {0} --optimize --repeat "{1}"' -f "$udfExPath\udefrag.exe","$env:SystemDrive") + Invoke-ExpressionEx -checkExitCode -command ('& {0} /accepteula -q -z "{1}"' -f "$sdExPath\SDelete.exe",$env:SystemDrive) } finally { rm -recurse -force $udfZipPath,$udfExPath,$sdZipPath,$sdExPath -ErrorAction Continue @@ -494,21 +543,21 @@ function Enable-WinRM { # call cmd.exe over and over like this, that problem goes away. # Note: order is important. This order makes sure that any time packer can successfully # connect to WinRm, it won't later turn winrm back off or make it unavailable. - Invoke-ExpressionAndLog -invokeWithCmdExe -command 'net stop winrm' - Invoke-ExpressionAndLog -invokeWithCmdExe -command 'sc.exe config winrm start= auto' - Invoke-ExpressionAndLog -invokeWithCmdExe -command 'winrm quickconfig -q' - Invoke-ExpressionAndLog -invokeWithCmdExe -command 'winrm quickconfig -transport:http' - Invoke-ExpressionAndLog -invokeWithCmdExe -command 'winrm set winrm/config @{MaxTimeoutms="1800000"}' - Invoke-ExpressionAndLog -invokeWithCmdExe -command 'winrm set winrm/config/winrs @{MaxMemoryPerShellMB="2048"}' - Invoke-ExpressionAndLog -invokeWithCmdExe -command 'winrm set winrm/config/service @{AllowUnencrypted="true"}' - Invoke-ExpressionAndLog -invokeWithCmdExe -command 'winrm set winrm/config/client @{AllowUnencrypted="true"}' - Invoke-ExpressionAndLog -invokeWithCmdExe -command 'winrm set winrm/config/service/auth @{Basic="true"}' - Invoke-ExpressionAndLog -invokeWithCmdExe -command 'winrm set winrm/config/client/auth @{Basic="true"}' - Invoke-ExpressionAndLog -invokeWithCmdExe -command 'winrm set winrm/config/service/auth @{CredSSP="true"}' - Invoke-ExpressionAndLog -invokeWithCmdExe -command 'winrm set winrm/config/listener?Address=*+Transport=HTTP @{Port="5985"}' - Invoke-ExpressionAndLog -invokeWithCmdExe -command 'netsh advfirewall firewall set rule group="remote administration" new enable=yes' - Invoke-ExpressionAndLog -invokeWithCmdExe -command 'netsh firewall add portopening TCP 5985 "Port 5985"' - Invoke-ExpressionAndLog -invokeWithCmdExe -command 'net start winrm' + Invoke-ExpressionEx -invokeWithCmdExe -command 'net stop winrm' + Invoke-ExpressionEx -invokeWithCmdExe -command 'sc.exe config winrm start= auto' + Invoke-ExpressionEx -invokeWithCmdExe -command 'winrm quickconfig -q' + Invoke-ExpressionEx -invokeWithCmdExe -command 'winrm quickconfig -transport:http' + Invoke-ExpressionEx -invokeWithCmdExe -command 'winrm set winrm/config @{MaxTimeoutms="1800000"}' + Invoke-ExpressionEx -invokeWithCmdExe -command 'winrm set winrm/config/winrs @{MaxMemoryPerShellMB="2048"}' + Invoke-ExpressionEx -invokeWithCmdExe -command 'winrm set winrm/config/service @{AllowUnencrypted="true"}' + Invoke-ExpressionEx -invokeWithCmdExe -command 'winrm set winrm/config/client @{AllowUnencrypted="true"}' + Invoke-ExpressionEx -invokeWithCmdExe -command 'winrm set winrm/config/service/auth @{Basic="true"}' + Invoke-ExpressionEx -invokeWithCmdExe -command 'winrm set winrm/config/client/auth @{Basic="true"}' + Invoke-ExpressionEx -invokeWithCmdExe -command 'winrm set winrm/config/service/auth @{CredSSP="true"}' + Invoke-ExpressionEx -invokeWithCmdExe -command 'winrm set winrm/config/listener?Address=*+Transport=HTTP @{Port="5985"}' + Invoke-ExpressionEx -invokeWithCmdExe -command 'netsh advfirewall firewall set rule group="remote administration" new enable=yes' + Invoke-ExpressionEx -invokeWithCmdExe -command 'netsh firewall add portopening TCP 5985 "Port 5985"' + Invoke-ExpressionEx -invokeWithCmdExe -command 'net start winrm' } function Set-PasswordExpiry { # TODO fixme use pure Powershell @@ -521,7 +570,7 @@ function Set-PasswordExpiry { # TODO fixme use pure Powershell wmic useraccount where "name='{0}'" set "PasswordExpires={1}" "@ $command = $command -f $accountName, $passwordExpiress - Invoke-ExpressionAndLog -invokeWithCmdExe -command $command + Invoke-ExpressionEx -invokeWithCmdExe -command $command } <#