Changing metadata in MP3 files, and using .NET assemblies

Manipulating MP3 metadata is unlikely to be directly relevant to work activities, but trying to learn more about how to do this in PowerShell has helped me learn more about using .NET assemblies, which has many other applications and is very likely to be useful in future. I’ve used .NET assemblies before, such as in my old script for estimating Windows 8 network usage, but usually by reusing or slightly adapting someone else’s work, rather than starting by myself from scratch.

I’ll begin by explaining exactly what I was trying to do. I recently downloaded some free albums from the Adult Swim website, but had found that the metadata for the files was inconsistent and wouldn’t play nicely with my MP3 player. I wasn’t concerned about most attributes – my focus was on the track number, track title, album artist, contributing artist/performer, and album title attributes. Since all of these attributes can be made visible within the Explorer shell in Windows 7, I assumed it would be possible to modify them without too much difficulty. I assumed wrong.

Doing some initial reading, I found this post, and thought that it would provide a good basis for what I needed to do. Unfortunately, while it describes a clever approach for reading metadata, it doesn’t provide a method for writing metadata. Subsequent reading revealed that writing MP3 metadata would require using a third-party DLL. Enter the taglib-sharp library.

Focusing on this particular library, it became clear from a variety of blog posts and questions on StackOverflow that this was the way to go. I even found this blog post describing a PowerShell module built around this very DLL, but I decided that this was a good excuse to figure out how to make it work for myself. Along the way I’d be able to start getting a better grip on how to use .NET assemblies and pull back the covers to see what’s going on beneath the cmdlet level.

To start with, the DLL needs to be loaded. There are two ways of doing this  – you can use the Reflection assembly to load it:

[System.Reflection.Assembly]::LoadFile("$($dllpath)\taglib-sharp.dll")

or you can use the Add-Type cmdlet (introduced in PowerShell v3.0):

Add-Type -Path "$($dllpath)\taglib-sharp.dll"

Now that the DLL is available, what next? At first, I didn’t know what to do next – and while that’s fine in this context (because the blog posts I had found would probably tell me what to do, or in the case of the module I mentioned, do it for me) in a wider context it renders the DLL useless. So in the more general context, the next step is to examine the DLL itself and find out what methods it provides. In my case, I used Visual Studio Community Edition 2013 to open the DLL – with no useful result:

01. Opening taglib-sharp.dll in Visual Studio

02. Contents of taglib-sharp.dll in Visual Studio

A bit more poking around on StackOverflow informed me that the Object Browser (located under the View menu in this version of VS) is what I was looking for:

03. Examining the taglib-sharp.dll object in Visual Studio

04. Contents of the taglib-sharp.dll object in Visual Studio

Click to see a full-size version

Much more useful.

So the first thing we need is to create an object in PowerShell of the relevant type. Examining the File class, there are a number of methods – including a “Create” method which accepts a simple string as its input:

$obj=[Taglib.File]::Create($filepath)

At this point it becomes easier to use existing PowerShell knowledge to explore further. For example, the object can be piped to Get-Member to see its methods and properties:

$obj | Get-Member

05. Get-Member results for a Taglib object

Examining the $obj property “Properties”, there’s not a lot of useful information:
06. Object Properties

But the property “Tag” provides what I’m looking for:

07. Object Tag

I now have enough information to start experimenting – I know where to find the object properties containing the information to be updated, and from both the File class definition in Visual Studio and the output of piping the object to Get-Member, I know that a method called Save() exists which is most likely to be what I’ll use to save changes to the file.

In my case, I looked up the album information on the Ghostly Records website and renamed the files with the convention “Track Number with leading 0 – Artist – Track title.mp3”, using the following:

$titles=@("Michna - Triple Chrome Dipped","Dabrye - Temper","The Chap - Carlos Walter Wendy Stanley","Dark Party - Active","Tycho - Cascade [Live Version]","JDSY - All Shapes","Deastro - Light Powered","Matthew Dear - R+S","FLYamSAM - The Offbeat","Cepia - Ithaca","Aeroc - Idiom","The Reflecting Skin - Traffickers","School of Seven Bells - Chain","Ben Benjamin - Squirmy Sign Language","Kill Memory Crash - Hit and Run","Osborne - Wait A Minute","Milosh - Then It Happened","1032 - Blue Little","Mux Mool - Night Court")
$songs=Gci "C:\Users\Kyle\Downloads\ghostlyswim" | Where-object {$_.Name -like "*.mp3"}

[int]$i=01
foreach ($t in $titles) {
	foreach ($s in $songs) {
		if ($t -match ($s.Name.Substring(0,$($s.Name.Length -4)).Replace("_"," - "))) {
			if ($i.ToString().Length -eq 1) {
				[string]$newname="0"+$i.ToString()+" - "+$t+".mp3"
			} else {
				[string]$newname=$i.ToString()+" - "+$t+".mp3"
			}
			Rename-Item -Path $s -NewName $newname
		}
	}
	$i++
}

I mention this because this means I can generate the desired metadata for the files from the directory and filenames. So I can create a function to take a directory path as input, get a list of all matching files in that path and generate the new metadata for each file using the following:

	[int]$trackno=($filename -split " - ")[0]
	[string]$artist=($filename -split " - ")[1]
	[string]$title=(($filename -split " - ")[2]).TrimEnd(".mp3")
	[string]$album=($dirname -split "\\")[$([int]($dirname -split "\\").count -1)]

With that done, it’s just a case of updating the properties for the $obj object created earlier, then invoking the Save() method. Right? Wrong.

For reasons I don’t fully understand, the approach I’ve described works – but the resultant updated tags would still not display correctly within the Windows 7 Explorer shell. Checking the metadata in a media application or using the DLL in PowerShell confirmed that the changes had been made, but for whatever reason they were not displaying in Windows. So back to Google I went.

After a little reading around I found a number of people claiming that Windows 7 does not play nice with Id3v2.4 tags (which surprised me chiefly because I’d never bothered to investigate the nature of Id3 tags at all, and did not realise there are multiple versions of Id3v2). I haven’t been able to conclusively prove that this was the root of my issue, but it seemed worthwhile to pursue it as a possible cause.

Looking at the output of piping the $obj object to Get-Member again, I noticed the RemoveTag() method – which works fine for removing a tag, but there’s no equivalent AddTag() method. Continuing to nose around in Visual Studio, I found that the Tag class has a method called Clear(), which looked like it might be worth trying. I also found that the namespaces for the various types of tag also contain their own Tag class. The Id3v2 namespace contains a variety of methods used for manipulating the frames within a given file’s tag. I found that using the Clear() method to remove the tag contents before assigning my desired values was enough to resolve the issue, but in the event of the issue persisting my next step probably would have been to learn more about the nature and structure of Id3v2 tag frames, then use the various methods I’ve mentioned above to remove the existing tag and create a new one with the required frames added and populated.

I’ve since been able to confirm that it is specifically Id3v2.4 tags which cause the problem, and found a simpler solution than I had anticipated. I’ve updated my script below to reflect this, but the pertinent parts are as follows:

Within the Tag property, a GetTag() method exists. Calling this method for the Id3v2 tag on the object will return the following:
08. Object Id3v2 Tag

This doesn’t make it obvious that there’s anything useful within the tag, but switching back to Visual Studio and searching for “Version” returns a number of results, including what I was looking for:
09. taglib-sharp version search

Click for full size image

Click for full size image

Some testing reveals that the Version attribute can’t be set directly, but the tried-and-tested method of casting the tag to a new object, modifying the object, then assigning the values from that object back to the original $obj will work:

$newtag=$global:obj.Tag.GetTag("Id3v2")
$newtag.Version="3"
$global:obj.Tag.SetTags($newtag)

At which point, any other metadata changes can be processed as normal and then saved with the Save() method. I have replaced the section of my script which uses the Clear() method with logic that checks for the presence of an Id3v2.4 tag and attempts to change the version to Id3v2.3.

I’ll end this post with the full script that I wrote for updating metadata in this fashion. I’ll probably still develop this script further to add more flexibility to it like logic for identifying filename patterns and matching them against metadata patterns, proper recursion support, and possibly support for reading metadata from an input file. But for now it’s enough for my needs.

In my next post, I’ll write about some more interesting tricks I learned while writing this script, around how to understand what’s happening at a deeper level when you execute a specific cmdlet in PowerShell and access options and methods that don’t appear to be available on first inspection.

Function Update-Metadata {
	param(
		[string]$filename,
		[string]$dirname,
		[boolean]$fixtag
	)
	
	$global:obj=[taglib.File]::Create($($dirname+"\"+$filename))
	$props = $global:obj.Tag | Get-Member -MemberType Property
	[boolean]$changed=$false
	
	# If "fixtag" boolean is set to true, the tag version information is updated before other attributes are checked.
	if ($fixtag) {
		foreach ($t in $global:obj.Tag.TagTypes) {
			if (($t -match "Id3v2") -and ($global:obj.Tag.GetTag("Id3v2").Version -eq "4")) {
				Write-Host "Id3v2.4 tag found."
				"Id3v2.4 tag found." | Out-File -Filepath $global:logfile -append
				try {
					$newtag=$global:obj.Tag.GetTag("Id3v2")
					$newtag.Version="3"
					$global:obj.Tag.SetTags($newtag)
					$changed=$true
				} catch {
					Write-Host "An Id3v2.4 tag was found, but the version could not be changed to Id3v2.3." -foregroundcolor red
					"An Id3v2.4 tag was found, but the version could not be changed to Id3v2.3" | Out-File -Filepath $global:logfile -append
				}
			}
		}
	}

	[int]$trackno=($filename -split " - ")[0]
	[string]$artist=($filename -split " - ")[1]
	[string]$title=(($filename -split " - ")[2]).TrimEnd(".mp3")
	[string]$album=($dirname -split "\\")[$([int]($dirname -split "\\").count -1)]
	
	Write-Host "New Metadata items:`nTrack #:`t$($trackno)`nArtist:`t$($artist)`nTitle:`t$($title)`nAlbum:`t$($album)"
	$filename,$dirname | Out-File -Filepath $global:logfile -append
	"New Metadata items:","Track #:`t$($trackno)","Artist:`t$($artist)","Title:`t$($title)","Album:`t$($album)" | Out-File -Filepath $global:logfile -append
	
	#Fields to update: Title,Album Artist,Contributing Artists, Track number, Album Title
	# Track number
	if (!$global:obj.Tag.Track -or ($global:obj.Tag.Track -ne $trackno)) {
		$global:obj.Tag.Track=$trackno
		$changed=$true
		Write-Host "Track number updated."
		"Track number updated." | Out-File -Filepath $global:logfile -append
	} else {
		Write-Host "Track number does not need to be updated."
		"Track number does not need to be updated." | Out-File -Filepath $global:logfile -append
	}
	# Album title
	if (!$global:obj.Tag.Album -or ($global:obj.Tag.Album -ne $album)) {
		$global:obj.Tag.Album=$album
		$changed=$true
		Write-Host "Album title updated."
		"Album title updated." | Out-File -Filepath $global:logfile -append
	} else {
		Write-Host "Album title does not need to be updated." 
		"Album title does not need to be updated." | Out-File -Filepath $global:logfile -append
	}
	# Album artist
	if (!$global:obj.Tag.AlbumArtists -or ($global:obj.Tag.AlbumArtists -ne $artist)) {
		$global:obj.Tag.AlbumArtists=$artist
		$changed=$true
		Write-Host "Album artist attribute updated."
		"Album artist attribute updated." | Out-File -Filepath $global:logfile -append
	} else {
		Write-Host "Album artist attribute does not need to be updated."
		"Album artist attribute does not need to be updated." | Out-File -Filepath $global:logfile -append
	}
	# Contributing artist
	if (!$global:obj.Tag.Performers -or ($global:obj.Tag.Performers -ne $artist)) {
		$global:obj.Tag.Performers=$artist
		$changed=$true
		Write-Host "Performers attribute updated."
		"Performers attribute updated." | Out-File -Filepath $global:logfile -append
	} else {
		Write-Host "Performers attribute does not need to be updated."
		"Performers attribute does not need to be updated." | Out-File -Filepath $global:logfile -append
	}
	# Track title
	if (!$global:obj.Tag.Title -or ($global:obj.Tag.Title -ne $title)) {
		$global:obj.Tag.Title=$title
		$changed=$true
		Write-host "Track title updated"
		"Track title updated" | Out-File -Filepath $global:logfile -append
	} else {
		Write-Host "Track title does not need to be updated."
		"Track title does not need to be updated." | Out-File -Filepath $global:logfile -append
	}
	Write-Host " "
	# Save change
	if ($changed) {
		try {
			$global:obj.Save()
			Write-Host "Metadata changes have been saved for $($filename)." -foregroundcolor green
			"Metadata changes have been saved for $($filename)." | Out-File -Filepath $global:logfile -append
		} catch {
			Write-Host "An error occured while updating Metadata for $($filename); changes have not been saved." -foregroundcolor red
			"An error occured while updating Metadata for $($filename); changes have not been saved." | Out-File -Filepath $global:logfile -append		
		}
	} else {
		Write-Host "No Metadata changes are required for $($filename)." -foregroundcolor green
		"No Metadata changes are required for $($filename)." | Out-File -Filepath $global:logfile -append	
	}
	Remove-Variable -Name filename,dirname,obj,props,trackno,artist,album,title,changed -Force -ErrorAction SilentlyContinue
}

# Main body

Write-Host "Update MP3 Metadata" -foregroundcolor green
$global:logfile=$PSScriptRoot+"\Update-MP3Metadata.log"
if (!(Test-Path $logfile)) {
	"Started Update-MP3Metadata script..." | Out-File -Filepath $global:logfile
} else {
	"Started Update-MP3Metadata script..." | Out-File -Filepath $global:logfile -append
}

# Check if taglib-sharp.dll is available, and attempt to load it:
[boolean]$loaded=$false
while (!$loaded) {
	if ($dllpath) {
		try {
			[System.Reflection.Assembly]::LoadFile("$($dllpath)\taglib-sharp.dll")
			[boolean]$loaded=$true
			break;
		} catch {
			Write-Host "Couldn't load DLL from $($dllpath)!"
			"Couldn't load DLL from $($dllpath)!" | Out-File -Filepath $global:logfile -append
		}
	} else {
		if (Test-Path "$PSScriptRoot\taglib-sharp.dll") {
			$dllpath=$PSScriptRoot+"\taglib-sharp.dll"
			try {
				[System.Reflection.Assembly]::LoadFile("$dllpath") | Out-Null
				[boolean]$loaded=$true
				break;
			} catch {
				Write-Host "Couldn't load DLL from $($dllpath)!"
				"Couldn't load DLL from $($dllpath)!" | Out-File -Filepath $global:logfile -append
			}
		} else {
			$PF=gci $env:programfiles -Directory -Filter "*taglib-sharp*"
			if ($PF) {
				$dllpath=(gci -recurse $PF.Fullname | Where-Object {$_.Name -like "taglib-sharp.dll"}).FullName
				if ($dllpath) {
					try {
						[System.Reflection.Assembly]::LoadFile("$($dllpath)") | Out-Null
						[boolean]$loaded=$true
						break;
					} catch {
						Write-Host "Couldn't load DLL from $($dllpath)!"
						"Couldn't load DLL from $($dllpath)!" | Out-File -Filepath $global:logfile -append
					}
				} else {
					Write-Host "Could not find required DLL in $($PF.Fullname)"
					"Could not find required DLL in $($PF.Fullname)" | Out-File -Filepath $global:logfile -append
				}
			}
			if ([System.Environment]::Is64BitOperatingSystem) {
				$PFx86=gci ${env:programfiles(x86)} -Directory -Filter "*taglib-sharp*"
				if ($PFx86) {
					$dllpath=(gci -recurse $PFx86.Fullname | Where-Object {$_.Name -like "taglib-sharp.dll"}).FullName
					if ($dllpath) {
						try {
							[System.Reflection.Assembly]::LoadFile("$($dllpath)") | Out-Null
							[boolean]$loaded=$true
							break;
						} catch {
							Write-Host "Couldn't load DLL from $($dllpath)!"
							"Couldn't load DLL from $($dllpath)!" | Out-File -Filepath $global:logfile -append
						}
					} else {
						Write-Host "Could not find required DLL in $($PFx86.Fullname)"
						"Could not find required DLL in $($PFx86.Fullname)" | Out-File -Filepath $global:logfile -append					
					}
				}
			}
		}
	}
}

if ($loaded) {
	[boolean]$validdir=$false
	while (!$validdir) {
		[string]$musicdir=Read-Host("Please enter the full path of the directory in which you want to update Metadata")
		If (Test-Path $musicdir) {
			Write-Host "Found path $($musicdir)." -foregroundcolor green
			"Found path $($musicdir)." | Out-File -Filepath $global:logfile -append
			$validdir=$true
		} else {
			Write-Host "Invalid path entered, please try again." -foregroundcolor red
			"Path $($musicdir) is invalid." | Out-File -Filepath $global:logfile -append
		}
	}
	[boolean]$validchoice=$false
	while (!$validchoice) {
		[string]$choice=Read-Host("Ensure Id3v2 tags are version 2.3? This ensures that metadata can be displayed within the Windows Explorer shell.  Y/N")
		switch ($choice) {
			{$_ -like "Y"} {
				[boolean]$fixtag=$true
				Write-Host "Id3v2 tags will be updated to version 2.3."
				"Id3v2 tags will be updated to version 2.3." | Out-File -Filepath $global:logfile -append
				$validchoice=$true
			}
			{ $_ -like "N"} {
				[boolean]$fixtag=$false
				"Id3v2 tag versions will not be checked or modified." | Out-File -Filepath $global:logfile -append
				$validchoice=$true			
			}
			default {
				Write-Host "Invalid option entered, please try again." -foregroundcolor red
				Start-Sleep 5
				CLS
			}
		}
	}
	Remove-Variable -Name validdir,validchoice -Force -ErrorAction SilentlyContinue
	cd $musicdir
	Write-Host "Working directory:`t$($musicdir.FullName)"
	$filelist=gci $musicdir -Filter "*.mp3"
	foreach ($f in $filelist) {
		Write-Host "`nFile:`t$($f.Name):`n"
		Update-Metadata $f.Name $musicdir $fixtag
	}
} else {
	Write-Error "Required DLL taglib-sharp.dll could not be found on the system; script will now exit." | Out-File -Filepath $global:logfile -append
}
Remove-Variable -Name loaded,dllpath,PF,PFx86,musicdir,filelist,fixtag -Force -ErrorAction SilentlyContinue
Advertisements

Restoring a Bootcamp partition created with Acronis True Image for Windows

Today’s post is a bit of a swerve from the usual PowerShell; I recently encountered difficulties restoring a backup of my Windows install created with Acronis True Image that took a while to understand, so I thought it would be interesting to write up.

The crux of the issue is that I am using an unsupported configuration of Acronis True Image – per this article, Acronis does not support installing True Image for Windows in a Bootcamp partition and using it to create and restore backups of that partition. Their recommended solution is to use Acronis for Mac and include the Bootcamp partition in those backups. Based on this forum thread, I’m not the only person who’s run into this issue either.

Fortunately, despite being an unsupported configuration I have been able to reproducibly restore a working Bootcamp partition without having to also restore the OS X partition, using the process described below.

Requirements:

  • Connected external storage with TIB file to be restored (using the highest transfer-rate interface available).
  • Licensed Acronis True Image bootable media (ideally for the same version used to create the backup, but this is not a hard requirement)
  • Recovery media for the version of Windows being restored (can be full install media or a recovery disk)

I have verified the process described here using Acronis True Image 2013 for Windows, Windows 7 Pro SP1 64-bit,  installed on an early 2011 MacBook Pro running OS X El Capitan. My backups are stored on an external drive connected via Firewire. I’m hoping to be able to test this with a Windows 10 install soon; I’ll add to the post when I have done so.

Restoring the partition from the TIB file

  1. Shut down the system.
  2. Boot from the Acronis bootable media (hold the Option key while booting and manually select the media being used, or hold Command + C to boot directly from optical media)
  3. Ensure the external storage containing the backup is connected and powered on.
  4. When the menu loads for the Acronis media, select the top option – in my case this was listed as “Acronis True Image 2013”.
  5. When the recovery environment has loaded, select the “Home” tab and click  on “My disks” under the “Recover” heading. This will launch the Recovery Wizard.
  6. On the Archive Selection page, click “Browse” and select the TIB archive to be restored.
  7. On the Recovery Method page, select “Recover whole disks and partitions”.
  8. On the What To Recover page, select the Bootcamp partition. (In my backup, the Bootcamp partition was the only option present with the drive letter listed as C:, unlike in full-system backups where an additional entry for the MBR and Track 0 would also be present).
  9. On the “Settings of Partition C:” page, select the internal disk’s Bootcamp partition as the destination. If possible, avoid changing the partition details.
  10. On the Finish page, click “Proceed” to restore the backup. This will take some time, so leave it running and check the “Shutdown when recovery complete” option.
  11. When the recovery has completed and the system is powered down, turn off and disconnect the external storage containing the backup files. Remove the Acronis bootable rescue media.

For step-by-step guidance on using the Acronis bootable rescue media, see this official Youtube video.

Fixing the partition table

At this point, booting the system holding down the Option key should result in both “Macintosh HD” and “Windows” being displayed as available boot options. Despite this, the newly-restored Windows partition isn’t yet bootable – attempting to boot from it in its current state will almost certainly trigger either a “Missing Operating System” or “BOOTMGR not found – press Ctrl + Alt + Dell to restart” error message. Worse, attempting to boot from Windows recovery media may show that the C: drive is not accessible, the volume is reported as corrupt, and the filesystem is detected as RAW rather than NTFS!

This is not the problem it might initially appear, however.

  1. Boot the system holding the Option key, and select “Macintosh HD”.
  2. Log into OS X using an account with administrator privileges.
  3. Launch Disk Utility, and select the internal disk in your system. Ensure you have selected the disk, not one of the partitions on it.
  4. Run the “First Aid” option and wait for it to complete. (For more information on the Disk Utility First Aid tool, see here.)
  5. Once the First Aid is complete, click “Partition” to open the disk partitioning view. Either add a new partition or reduce the size of your OS X partition. The new partition doesn’t need to be more than a couple of GB in size. (For more information on using the Disk Utility Partition too, see here.)
  6. Once the partition resize/creation process is complete, select the new partition and delete it by clicking on the “-” symbol in the “+| -” box underneath the partition diagram.
  7. When the partition deletion is complete, shut down the system.

Rebuilding the BCD

At this point, booting the system holding down the Option key will likely result in only “Macintosh HD” being displayed as an available boot option. Despite this seeming like the situation has been made worse, it is not a problem. The steps in the previous section are required to force OS X to update the partition table for the disk; the easiest way to do this automatically is to add and remove a new partition. Alternatively gdisk could be used in a terminal window to examine the disk layout and make the required changes manually, but this allows more scope for errors (including errors that could render the entire system, not just the Windows partition, unbootable!) than using the automatic recovery options.

The remaining task is to rebuild the Windows Boot Configuration Data file. In my experience, a simple repair doesn’t work so the file needs to be renamed and a new file created.

  1. Hold Option while powering on, and boot from Windows recovery media. Don’t forget about the “Press any key to boot from CD or DVD” prompt, or you’ll have to reboot again.
  2. Select your region & language and click Next, then (if using Windows installation media) select “Repair your computer”.
  3. Select the option starting “Use recovery tools that can help fix problems…” and click Next. It doesn’t matter if your installation is detected at this point.
  4. Select “Command prompt” from the list of available recovery tools, and a black command prompt window will appear on screen.
  5. OPTIONAL – if the Windows installation drive letter is not known, or the state of the file system needs to be confirmed, Diskpart can be used to check – simply type “Diskpart” and press Enter. Enter the command “list volume” to see a list of all volumes found on the disk, along the drive letter assigned to them, their size, filesystem and status. Type “Exit” to close Diskpart.
  6. At the command prompt, enter the following commands. If the Windows installation is not on C:, substitute any reference to “C:” for the drive on which the Windows installation is located.
    1. bcdedit /export C:\BCDbackup (this will export existing BCD info to a backup location in case it is required later)
    2. cd /d C: (the /d is important, since it changes drive as well as directory and moves us to the root directory)
    3. attrib C:\Boot\BCD -r -h -s (change the attributes on the Boot Configuration Data file: “-r” removes its read-only status, “-h” removes its hidden status, and “-s” removes its system file status)
    4. ren C:\Boot\BCD BCD.old (rename the file “C:\Boot\BCD” to “C:\Boot\BCD.old”)
    5. Bootrec.exe /rebuildBCD (completely rebuild the BCD store and scan the existing system for instances of Windows)
  7. The last command should return a prompt asking if the detected Windows installation should be added to the BCD store. Type “Yes” and press Enter.
  8. Once all commands have finished reading, close the command prompt. The window listing all available recovery tools should appear again.
  9. Click “Startup Repair” (the top option) and let it run. It will examine the disk and attempt to correct any problems found.
  10. When prompted, restart the system and the Bootcamp partition should now once again be bootable.

For more details on rebuilding the BCD store on a Windows system, see here.

The process itself isn’t enormously complex; it’s a bit annoying to have to jump through some extra hoops as part of the restore process.

As a final note, I’ll mention that it’s possible to use Disk Utility in the OS X Recovery Environment to clone a disk that has a Bootcamp partition on it to a second disk (e.g. if you are migrating from a spinning-plate drive to an SSD). In such a case, the second and third sections of the above process can be used to successfully restore the Bootcamp partition’s bootable status.

Complete script for disabling the Get-Windows10 upgrade agent on Windows 7 & Windows 8.1

115Further to my previous posts on disabling the Windows 10 upgrade agent, here is the full script along with instructions on how to use it.

  1. Either copy the script below and paste it into a new PowerShell ISE session, or save this ODT file and change the file extension to .ps1.
    In this example I will save the script as “Disable-GWX.ps1” in the directory C:\tmp. Note that the log file and all downloaded files will be created in the directory in which the script is saved.
  2. Open an administrative PowerShell console or ISE session:
    admin_session
  3. Check your execution policy and, if necessary, change it:
    Get-executionpolicy
    Set-ExecutionPolicy
    Note: If copying the script into the PowerShell ISE, the RemoteSigned policy is sufficient. If downloading the ODT file linked above, the policy must be set to Unrestricted. For hopefully-obvious security reasons it is highly inadvisable to use the Unrestricted execution policy for prolonged periods of time, so remember to set the execution back to a more secure setting after the script finishes running.
  4. Invoke the script:
    Runscript
    Note: Due to some issues with Windows Update during the testing process, the script function which attempts to automatically hide GWX-related updates is disabled by default. To enable it, use the switch shown in the image above.
  5. The script will provide a detailed description of all actions under way, and may prompt for input. It will not trigger an automatic reboot upon completion, but will display a notification if a reboot is required.
<# Disable-GWX: Description: A script to disable the GWX software agent and prevent its automatic repair or reinstallation by Windows.
Author: Kyle Rogers, with thanks to Andy Gormanly for the breakdown of the steps required.
URL: powershellshocked.wordpress.com 
References: 
http://blogs.technet.com/b/heyscriptingguy/archive/2011/05/11/check-for-admin-credentials-in-a-powershell-script.aspx - Check for admin rights in local session
https://gallery.technet.microsoft.com/scriptcenter/2d191bcd-3308-4edd-9de2-88dff796b0bc - PSWindowsUpdate module
https://download.sysinternals.com/files/PSTools.zip - SysInternals utilities (for PsExec.exe)
https://technet.microsoft.com/en-us/library/ff730951.aspx - working with security descriptors
https://technet.microsoft.com/en-gb/library/dd315270.aspx - working with registry keys
https://technet.microsoft.com/en-us/library/jj649816%28v=wps.630%29.aspx - scheduled tasks (win8)
http://blogs.technet.com/b/heyscriptingguy/archive/2009/04/01/how-can-i-best-work-with-task-scheduler.aspx - scheduled tasks (Win7)
http://blogs.technet.com/b/heyscriptingguy/archive/2012/12/27/powertip-use-windows-powershell-to-create-a-checkpoint.aspx - Creating restore points
#>
param(
	[boolean]$hidetask=$false
)

Function Check-AdministratorAccess {
    if (([Security.Principal.WindowsPrincipal] [Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole] "Administrator")) {
        $global:admin=$true
        Write-Host "Script is running with required privileges."
        "Script is running with required privileges." | Out-file -Filepath $global:logfile -append
    } else {
        $global:admin=$false
        Write-Host "Not running with required privileges! Open a new PowerShell instance with administrator privileges and try again." -foregroundcolor red
        "Not running with required privileges! Open a new PowerShell instance with administrator privileges and try again." | Out-file -Filepath $global:logfile -append
    }
}

Function Check-OS {
    $OS=(Get-WMIObject -class Win32_OperatingSystem -property Name).Name
    switch ($OS) {
        {$_ -like "*Windows 7*"} {
            $OSVersion="7"
            Write-host "Detected OS: Windows 7..."
            "Detected OS: Windows 7..." | Out-file -Filepath $global:logfile -append
            break
        }
        {$_ -like "*Windows 8*"} {
            $OSVersion="8"
            Write-host "Detected OS: Windows 8/8.1..."
            "Detected OS: Windows 8/8.1..." | Out-file -Filepath $global:logfile -append
            break
        }
        default {
            $OSVersion="Invalid"
            Write-Host "Invalid OS detected..." -foregroundcolor red
            "Invalid OS detected..." | Out-file -Filepath $global:logfile -append
            break     
        }
    }
}

Function Download-File {
	param(
		[string]$outfilepath,
		[string]$URI
	)
	if ($URI -and $outfilepath) {
		# Check PS Major Version proceed accordingly
				   
		if ($PSVersionTable.PSVersion.Major -gt 2) {
			Write-Host "PowerShell version 3 or higher found: Attempting to download file from $($URI)..."
			"PowerShell version 3 or higher found: Attempting to download file from $($URI) module..." | Out-file -Filepath $global:logfile -append
			try {
				Invoke-Webrequest -URI $URI -OutFile $outfilepath -ErrorAction Stop
				Write-Host "Successfully downloaded file from $($URI)." -foregroundcolor green
				"Successfully downloaded file from $($URI)." | Out-file -Filepath $global:logfile -append
			} catch {
				Write-Host "Unable to download file from $($URI), error was:`n$($_.Exception.Message)." -foregroundcolor red
				"Unable to download file from $($URI), error was:",$_.Exception.Message | Out-file -Filepath $global:logfile -append
			}
		} else {
			if (Get-Module BITSTransfer -ListAvailable) {
				try {
					ipmo BITSTransfer
					Write-Host "Loaded BITSTransfer module successfully." -foregroundcolor green
					"Loaded BITSTransfer module successfully." | Out-file -Filepath $global:logfile -append
				} catch {
					Write-Host "Unable to load BITSTransfer module for file download." -foregroundcolor red
					"Unable to load BITSTransfer module for file download." | Out-file -Filepath $global:logfile -append
				}
				if (get-Module BITSTransfer) {
					try {
						Start-BITSTransfer -source $URI -Destination $outfilepath -ErrorAction Stop
						Write-Host "Successfully downloaded file from $($URI)." -foregroundcolor green
						"Successfully downloaded file from $($URI)." | Out-file -Filepath $global:logfile -append
					} catch {
						Write-Host "Unable to download file from $($URI), error was:`n$($_.Exception.Message)." -foregroundcolor red
						"Unable to download file from $($URI), error was:",$_.Exception.Message | Out-file -Filepath $global:logfile -append
					}
				}
			} else {
				Write-Host "Unable to download file from $($URI), no suitable cmdlets or modules are available." -foregroundcolor red
				"Unable to download file from $($URI), no suitable cmdlets or modules are available." | Out-file -Filepath $global:logfile -append
			}
		}
	} else {
		Write-Host "URI or outfilepath value missing, please try again!" -foregroundcolor red
		"URI or outfilepath value missing, please try again!" | Out-file -Filepath $global:logfile -append
	}
}

# A generalised function for extracting compressed files to a specified location.
Function Extract-Zip {
    param(
        [string]$file,
        [string]$location,
        [array]$extractlist,
        [boolean]$cleanup=$false
    )
    if (!(Test-Path $location)) {
        try {
            mkdir $location | out-Null
        } catch {
            Write-Host "Unable to create folder $location, error was:`n$($_.Exception.Message)" -foregroundcolor red
			"Unable to create folder $location, error was:`n$($_.Exception.Message)" | Out-file -Filepath $global:logfile -append
        }
    }
    if ($extractlist) {
        Write-Host "Specific file extraction selected. Only the following files will be extracted:";$extractlist
    } else {
        Write-Host "Default mode selected, extracting all files..."
    }
    if ((Test-Path $file) -and (Test-Path $location)) {
        # Instantiate a new shell object and namespace
        $shell=New-Object -com Shell.Application
        $zip=$shell.NameSpace($file)
        # Check if the $extractlist parameter is set, and extract files accordingly.
        if (!$extractlist) {
            # Extract list is not set so default to extracting all files.
            try {
                foreach ($item in $zip.items()) {
                    $shell.Namespace($location).Copyhere($item)
                }
                Write-Host "Finished extracting contents of $file to $location." -foregroundcolor green
			    "Finished extracting contents of $file to $location." | Out-file -Filepath $global:logfile -append
            } catch {
                Write-Host "An error occured while extracting the contents of $file to $location; the error message was:`n$($_.Exception.Message)" -foregroundcolor red
			    "An error occured while extracting the contents of $file to $location; the error message was:", "`n", "$($_.Exception.Message)" | Out-file -Filepath $global:logfile -append
            }
        } else {
            # Extract list is set, so iterate through each name in the array and extract that file from the zip. Items in extractlist are not assumed to be unique matches, so a list of matching contents is generated for each item and a foreach loop iterates through the list, extracting each match individually.
            foreach ($e in $extractlist) {
                $list=@($zip.Items() | Where-Object {$_.Name -like $e})
                if ($list) {
                    foreach ($l in $list) {
                        try {
                            $shell.Namespace($location).Copyhere($l)
                            Write-Host "Extracted file $($e) successfully." -foregroundcolor green
                            "Finished extracting contents of $file to $location." | Out-file -Filepath $global:logfile -append
                        } catch {
                            Write-Host "Unable to extract file $($e), error was:`n$($_.Exception.Message)" -foregroundcolor red
                            "Unable to extract file $($e), error was:",$_.Exception.Message | Out-file -Filepath $global:logfile -append
                        }
                    }
                } else {
                    Write-Host "No file with name $($e) found in specified archive." -foregroundcolor yellow
                    "No file with name $($e) found in specified archive." | Out-file -Filepath $global:logfile -append
                }
				Remove-Variable -Name list -Force -ErrorAction SilentlyContinue
            }
        }
		if ($cleanup) {
			Write-Host "Cleanup enabled: deleting compressed file..." -foregroundcolor Green
			Remove-Item -Path $file -Force 
		}		
    } else {
        Write-Host "Unable to proceed with extraction, invalid input specified!" -foregroundcolor red
		"Unable to proceed with extraction, invalid input specified!" | Out-file -Filepath $global:logfile -append
        if (!(Test-Path $file)) {
            Write-Host "Could not find file $file!" -foregroundcolor red
			"Could not find file $file!" | Out-file -Filepath $global:logfile -append
			
        }
        if (!(Test-Path $location)) {
            Write-Host "Could not find or create folder path $location!" -foregroundcolor red
			"Could not find or create folder path $location!" | Out-file -Filepath $global:logfile -append
        }
    }
}

Function Install-UpdateModule {
	param(
		[string]$source,
		[string]$ModuleName
	)
	Write-Host "Installing $($ModuleName) module." -foregroundcolor green
	"Installing $($ModuleName) module." | Out-file -Filepath $global:logfile -append
	$target="C:\Windows\system32\WindowsPowershell\v1.0\Modules\"+$ModuleName
	$exists=Test-Path $target
	if (!$exists) {
		Write-Host "Target directory doesn't exist, attempting to create..." 
		"Target directory doesn't exist, attempting to create..." | Out-file -Filepath $global:logfile -append
		try {
			New-Item -Path "C:\Windows\system32\WindowsPowershell\v1.0\Modules" -name $ModuleName -ItemType Directory -ErrorAction Stop | Out-Null
			Write-Host "Target directory created." -foregroundcolor green
			"Target directory created." | Out-file -Filepath $global:logfile -append
		} catch {
			Write-Host "Couldn't create target directory." -foregroundcolor red
			"Couldn't create target directory." | Out-file -Filepath $global:logfile -append
		}
	} else {
		Write-Host "Target directory already exists."
		"Target directory already exists." | Out-file -Filepath $global:logfile -append
	}
	if (Test-Path $target) {
		foreach ($f in (GCI $source -recurse)) {
			$fileexists=Test-Path ($target+"\"+$f.Name)
			if (!$fileexists) {
				if ($f.Name -like "*.psm1" -and $PSVersionTable.PSVersion.Major -lt 3) {
					"Get-ChildItem -Path `$PSScriptRoot\*.ps1 | Foreach-Object { . `$_.FullName}" | Out-File -FilePath $f.FullName
				}
				try {
					Copy-Item $f.FullName -Destination $target -Force -Confirm:$false | Out-Null
					Write-Host "Copied file $($f.name) to installation directory." -foregroundcolor green
					"Copied file $($f.name) to installation directory." | Out-file -Filepath $global:logfile -append
				} catch {
					Write-Host "Unable to copy file $($f.name) to target directory." -foregroundcolor red
					"Unable to copy file $($f.name) to target directory." | Out-file -Filepath $global:logfile -append
				}
			} else {
				Write-Host "File $($f.name) already exists in target location." -foregroundcolor green
				"File $($f.name) already exists in target location." | Out-file -Filepath $global:logfile -append
			}
		}
	} else {
		Write-Host "Unable to install module, target directory could not be found or created." -foregroundcolor red
		"Unable to install module, target directory could not be found or created." | Out-file -Filepath $global:logfile -append
	}
}

Function Create-Checkpoint {
    try {
        Checkpoint-Computer -Description "Disable-GWX - Start of script." -RestorePointType APPLICATION_UNINSTALL -ErrorAction Stop
        Write-Host "Checkpoint restore point created successfully." -foregroundcolor green
		"Checkpoint restore point created successfully." | Out-file -Filepath $global:logfile -append
        $global:checkpoint=$true
    } catch {
        Write-Host "Unable to create checkpoint restore point; proceeding may be dangerous." -foregroundcolor red
		"Unable to create checkpoint restore point; proceeding may be dangerous." | Out-file -Filepath $global:logfile -append
        $choice=Read-Host("Continue anyway? Y/N")
        if (($choice -eq "Y") -or ($choice -eq "y")) {
            Write-Host "Bypassing checkpoint restorepoint requirement to proceed. You have been warned." -foregroundcolor yellow
			"You have chosen to bypass checkpoint restorepoint requirement to proceed. You have been warned." | Out-file -Filepath $global:logfile -append
            $global:checkpoint=$true
        } else {
            $global:checkpoint=$false
        }
    }
}

Function Schedule-HideUpdates {
	param(
		[array]$updatelist,
		[string]$scriptroot
	)
	# Export the array of updates to be hidden to a text file.
    $updatelist | Out-file -Filepath $($scriptroot+"\kbarray.txt")
	# Create a here-string containing the script to be executed post-rebooot to hide the required updates.
$taskscript=@"
`$global:logfile="$($global:logfile)"
(Get-Date -format "yyyy-mm-dd_HH:mm")+": Started post-reboot task to hide GWX-related updates in Windows Update." | Out-File -Filepath `$global:logfile -append
ipmo PSWindowsUpdate
`$timeout=300
`$kbarray=Get-Content "$($scriptroot+"\kbarray.txt")"
foreach (`$kbnumber in `$kbarray) {
	`$job = Start-Job -ScriptBlock {
		`$kbnumber=`$args[0];
		`$global:logfile=`$args[1];
		ipmo PSWindowsUpdate; 
		if (Get-WUList -KBArticleID `$kbnumber) {
			"Update `$kbnumber available from Windows Update, attempting to hide..." | Out-file -Filepath `$global:logfile -append
			try {
				Hide-WUUpdate -KBArticleID `$kbnumber -Confirm:`$false -ErrorAction Stop
				"Update `$kbnumber should now be hidden." | Out-file -Filepath `$global:logfile -append
			} catch {
				"Unable to hide update `$kbnumber, error was:",`$_.Exception.Message | Out-file -Filepath `$global:logfile -append			
			}
		} else {
			"Update `$kbnumber is not being offered by Windows Update, no action needed." | Out-file -Filepath `$global:logfile -append	
		}
	} -argumentlist $kbnumber,$global:logfile
	Wait-Job `$job -Timeout `$timeout | Out-Null;
	if (`$job.State -ne "Completed") {
		Write-Host "Job seems to have stalled, force-quitting..." -foregroundcolor red; 
		Stop-Job `$job -Confirm:`$false
		Remove-Job `$job
	} else {
		Write-Host "Job completed successfully" -foregroundcolor green
	}		
}
"Finished hiding updates.","" | Out-file -Filepath `$global:logfile -append
# Remove scheduled task so that it doesn't run again.
`$deletetask='schtasks /delete /tn "Hide GWX Updates" /f'
`$d=Start-Process -Filepath `$env:systemroot\system32\cmd.exe -ArgumentList "/c `$deletetask" -Wait -NoNewWindow -Passthru
if (`$d.ExitCode -eq 0) {
	"Successfully deleted scheduled task for hiding GWX-related updates." | Out-File -Filepath `$global:logfile -append
} else {
	"Unable to delete scheduled task for hiding GWX-related updates." | Out-File -Filepath `$global:logfile -append
}
"@
	# Output the script to a file in the script root location.
	$scriptfile=$scriptroot+"\HideGWXUpdates.ps1"
	$taskscript | Out-file -Filepath $scriptfile
	
	# Create a new scheduled task as the current user
	Write-Host "Creating scheduled task to run at next logon..."
	$password=Read-Host -Prompt "Please enter the password for $($env:username)" -AsSecureString
	$credential=New-Object -TypeName System.Management.Automation.PSCredential $env:username,$password
	
	$createtask='schtasks /create /tn "Hide GWX Updates" /tr "C:\windows\system32\WindowsPowerShell\v1.0\powershell.exe -File \"'+$scriptfile+'\"" /rl highest /sc onlogon /ru "'+$($env:username)+'" /rp "'+$($credential.GetNetworkCredential().Password)+'"'
	Remove-Variable -Name password,credential -Force -ErrorAction SilentlyContinue

	$c=Start-Process -Filepath $env:systemroot\system32\cmd.exe -ArgumentList "/c $createtask" -Wait -NoNewWindow -Passthru

	Remove-Variable -Name createtask -Force -ErrorAction SilentlyContinue
	
	if ($c.ExitCode -eq 0) {
		Write-Host "Successfully created scheduled task for hiding GWX-related updates."
		"Successfully created scheduled task for hiding GWX-related updates." | Out-File -Filepath $global:logfile -append
	} else {
		Write-Host "Failed to create scheduled task for hiding GWX-related updates."
		"Failed to create scheduled task for hiding GWX-related updates." | Out-File -Filepath $global:logfile -append	
	}
	Remove-Variable -Name createtask,c -Force -ErrorAction SilentlyContinue
}

Function Restrict-Folder {
    param(
        [string]$directory,
		[string]$group,
        [string]$user
    )
    if ($directory) {
        $global:directory=$directory
    } else {
        $valid=$false
        while (!$valid) {
            $global:directory=Read-Host("Enter the directory to be restricted")
            if (Test-Path $global:directory) {
                $valid=$true
            } else {
                Write-Host "Specified directory is invalid, please try again." -foregroundcolor red
            }
        }
    }
    if ($group) {
        $global:group=$group
    } else {
        $global:group=Read-Host("Enter the group name for the user account to block, e.g. NT Service or $($env:computername)")
    }	
    if ($user) {
        $global:user=$user
    } else {
        $global:user=Read-Host("Enter the username for the user account to block, e.g. TrustedInstaller, or $($env:Username)")
    }

    Write-Host "Attempting to reconfigure the specified directory..."
	"Attempting to reconfigure the specified directory..." | Out-file -Filepath $global:logfile -append
    $NewOwner=New-Object System.Security.AccessControl.DirectorySecurity
    $admin=New-Object System.Security.Principal.NTAccount("BUILTIN","Administrators")
    $NewOwner.SetOwner($admin)

    $folder="\\localhost\\"+($global:directory -split ":")[0]+"$"+(($global:directory -split ":")[1] -replace "\\","\\")
	$folder="\\localhost\\C$\\Windows\\system32\\GWX"
    [System.IO.Directory]::SetAccessControl($folder,$NewOwner)

    $rand=$global:directory+"."+(Get-Random).ToString()
    try {
        Rename-Item $global:directory $rand -ErrorAction Stop
        Write-Host "Renamed existing directory to $($rand)." -foregroundcolor green
		"Renamed existing directory to $($rand)." | Out-file -Filepath $global:logfile -append
    } catch {
        Write-Host "Unable to rename existing directory, error was:`n$($_.Exception.Message)" -foregroundcolor red
		"Unable to rename existing directory, error was:","$($_.Exception.Message)" | Out-file -Filepath $global:logfile -append
    }
    if (Test-Path $global:directory) {
		Write-Host "Original GWX directory has not been renamed." -foregroundcolor red
		"Original GWX directory has not been renamed." | Out-file -Filepath $global:logfile -append
    } else {
		Write-Host "Original directory has been renamed. Creating new, empty GWX directory..." -foregroundcolor green
		"Original directory has been renamed. Creating new, empty GWX directory..." | Out-file -Filepath $global:logfile -append
		New-Item $global:directory -Type Directory -Force
	}
   try {
		$colRights = [System.Security.AccessControl.FileSystemRights]"Read"
		$InheritanceFlag = [System.Security.AccessControl.InheritanceFlags]::None
		$PropagationFlag = [System.Security.AccessControl.PropagationFlags]::None
		$objType =[System.Security.AccessControl.AccessControlType]::Allow
		$objUser = New-Object System.Security.Principal.NTAccount($global:group,$global:user)
		$remACE = New-Object System.Security.AccessControl.FileSystemAccessRule ($objUser, $colRights, $InheritanceFlag, $PropagationFlag, $objType)

		$colRights = [System.Security.AccessControl.FileSystemRights]"FullControl"
		$objType =[System.Security.AccessControl.AccessControlType]::Deny
		$denyACE = New-Object System.Security.AccessControl.FileSystemAccessRule ($objUser, $colRights, $InheritanceFlag, $PropagationFlag, $objType)  
	  
		$objGroup = New-Object System.Security.Principal.NTAccount("BUILTIN","Administrators")
	  
		$objACL = Get-ACL $global:directory

		$objACL.SetAccessRuleProtection($true,$true)
	  
		$objACL.RemoveAccessRuleAll($remACE)
	  
		$objACL.AddAccessRule($denyACE)
		
		$objACL.SetOwner($objGroup)
	  
		Set-ACL -Path $global:directory -Aclobject $objACL
		Write-Host "Finished reconfiguring directory $($global:directory). The account $($global:user) should no longer have access to this directory."
		"Finished reconfiguring directory $($global:directory). The account $($global:user) should no longer have access to this directory." | Out-file -Filepath $global:logfile -append
	} catch {
		Write-Host "Unable to complete reconfiguration of directory $($global:directory). The error was:`n$($_.Exception.Message)." -foregroundcolor red
		"Unable to complete reconfiguration of directory $($global:directory). The error was:","$($_.Exception.Message)." | Out-file -Filepath $global:logfile -append
	}	
}

Function Remove-RegistryKeys {
    param(
        [array]$locations,
        [string]$key,
		[string]$psexec
    )
	$sortedlist=@()
	foreach ($location in $locations) {
		if ((Test-Path $location) -and ($key)) {
			$list=Get-ChildItem -path $location

			foreach ($l in $list) {

				if ($l.PSPath) {
					$pspath=$l.PSPath
				} else {
					$pspath=$null
				}
				try {
					$taskpath=(Get-ItemProperty -Path $l.PSPath -Name Path -ErrorAction Stop).Path
				} catch {
					$taskpath=$null
				}	
				if ($l.Name) {
					$name=$l.Name
				} else {
					$name=$null
				}
				
				$obj = New-object -TypeName PSObject -Property @{"Name"=$name;"TaskPath"=$taskpath;"PSPath"=$pspath}
				$sortedlist+=$obj
				Remove-Variable -Name obj -Force -ErrorAction SilentlyContinue
			}
			Remove-Variable -Name list -Force -ErrorAction SilentlyContinue
		} else {
			Write-Host "Unable to proceed with registry key deletion, invalid input specified!" -foregroundcolor red
			"Unable to proceed with with registry key deletion, invalid input specified!" | Out-file -Filepath $global:logfile -append
			if (!(Test-Path $location)) {
				Write-Host "Could not find registry location $location!" -foregroundcolor red
				"Could not find registry location $location!" | Out-file -Filepath $global:logfile -append
				
			}
			if (!$key) {
				Write-Host "No search key specified!" -foregroundcolor red
				"No search key specified!" | Out-file -Filepath $global:logfile -append
			}		
		}
	}
	$sortedlist = $sortedlist | Sort-Object -Property TaskPath -Descending
	Write-Host "Found $($sortedlist.count) keys in the specified locations."
	"Found $($sortedlist.count) keys in the specified locations." | Out-file -Filepath $global:logfile -append
	foreach ($k in $sortedlist) {
		if (($k.Name -like "*$($key)*") -or ($k.TaskPath -like "*$($key)*")) {
			Write-Host "Found registry entry matching the search key.`nName:`t$($k.Name)`nTaskPath:`t$($k.Taskpath)`nPSPath:`t$($k.PSPath)`n"
			"Found registry entry matching the search key.","Name:	$($k.name)","Taskpath:	$($k.Taskpath)","PSPath:	$($k.PSPath)","" | Out-file -Filepath $global:logfile -append				
			# The backtick serves to escape the quote marks which are to be included in the string. The backslash will serve as an escape character to preserve the quote marks in the string when it is passed to PSExec, so that spaces in the path do not cause problems.
			$command="Remove-Item -Path \`""+$($k.Name -replace "HKEY_LOCAL_MACHINE","HKLM:")+"\`" -Recurse -Force -Confirm:`$false"
			if ($psexec) {
				Write-Host "PsExec is available; command will be executed as the SYSTEM account."
				"PsExec is available; command will be executed as the SYSTEM account." | Out-file -Filepath $global:logfile -append
				$filepath=$psexec
				$pspath="$env:systemroot\system32\WindowsPowerShell\v1.0\powershell.exe"
				$arglist="-s /accepteula $pspath -InputFormat None -command $command"
				
				Write-Host "The command being executed is:`n$($psexec) $($arglist)"
				"The command being executed is:","$($psexec) $($arglist)" | Out-file -Filepath $global:logfile -append
				
				# Use Start-Process to run the command so that we can check the return code afterwards.
				$remkey=Start-Process -Filepath $filepath -ArgumentList $arglist -Wait -NoNewWindow -PassThru
					
				if ($remkey.Exitcode -eq "0") {
					Write-Host "Successfully deleted registry key $($k.PSPath)." -foregroundcolor green
					"Successfully deleted registry key $($k.PSPath)." | Out-file -Filepath $global:logfile -append
				} else {
					Write-Host "Failed to delete registry key $($k.PSPath)." -foregroundcolor red
					"Failed to delete registry key $($k.PSPath)." | Out-file -Filepath $global:logfile -append
				}
				Remove-Variable -Name command,filepath,pspath,arglist,remkey -Force -ErrorAction SilentlyContinue
				
			} else {
				try {
					Invoke-Expression $command -ErrorAction Stop
					Write-Host "Successfully deleted registry key $($k.PSPath)." -foregroundcolor green
					"Successfully deleted registry key $($k.PSPath)." | Out-file -Filepath $global:logfile -append
				} catch {
					Write-Host "Unable to delete registry key $($k.PSPath), error was:`n$($_.Exception.Message)" -foregroundcolor red
					"Unable to delete registry key $($k.PSPath), error was:","$($_.Exception.Message)" | Out-file -Filepath $global:logfile -append
				}
				Remove-Variable -Name command -Force -ErrorAction SilentlyContinue
			}
		}
	}
}

Function Remove-ScheduledTasks {
    param(
		[string]$key,
        [string]$psexec
    )
    $alltasks=Schtasks.exe /query /fo table /NH | Where-Object {$_ -notlike ""}                   
    Write-Host "Retrieved list of all scheduled tasks and folders."
	"Retrieved list of all scheduled tasks and folders." | Out-file -Filepath $global:logfile -append

    $folderlist=@();
    foreach ($f in ($alltasks | Where-Object {($_ -match "^Folder:") -and ($_ -like "*$($key)*")})) {
        $folderlist+=[string]($f -replace ("Folder: ",""))
    }

	$folderlist=$folderlist | Sort-Object -Descending

    if ($folderlist.count -gt 0) {
		Write-Host "Found $($folderlist.count) folders matching the search key. `nFolders:";foreach ($f in $folderlist) {$f}
		"Found $($folderlist.count) folders matching the search key.","Folders:" | Out-file -Filepath $global:logfile -append
		"Folders:" | Out-file -Filepath $global:logfile -append
		foreach ($f in $folderlist) {
			$f | Out-file -Filepath $global:logfile -append
		}
    }
   
    $foundtasks=@()
   
    foreach ($f in $folderlist) {
        $found=$false
        foreach ($t in $alltasks) {
            if ($found) {
                if (($t -notmatch "^Folder: ") -and ($t -notmatch "^INFO: ")) {
                    $foundtasks+=[string]($f+"\"+(($t -split "  ")[0]).TrimEnd(" "))
                } else {
					break
                }
            }
            if ($($t -replace ("Folder: ","")) -eq $f) {
                $found=$true
            }
        }
    }
	
	$foundtasks = $foundtasks | Sort-Object -Descending
	
    if ($foundtasks.count -gt 0) {
		# Write a list of the tasks identified for deletion to the screen and the logfile.
        Write-Host "The following scheduled tasks were found:";foreach ($t in $foundtasks) {$t}
		"The following scheduled tasks were found:" | Out-file -Filepath $global:logfile -append
		foreach ($t in $foundtasks) {
			$t | Out-file -Filepath $global:logfile -append
		}
		
		# Set a template for the schtasks.exe command to use.
		$template="schtasks.exe /Delete /S $env:computername /TN `"TASKNAME`" /F"
		
		foreach ($t in $foundtasks) {
			# Confirm task is still present on system. We don't need psexec for this as it is not a deletion command:
			$query=(($template -replace "TASKNAME",$t) -replace "/Delete","/Query") -replace " /F",""
			$arglist="/c $($query)"
			$confirm=Start-Process -Filepath "$env:systemroot\system32\cmd.exe" -ArgumentList $arglist -Wait -NoNewWindow -PassThru
			Remove-Variable -Name query,arglist -Force -ErrorAction SilentlyContinue
			if ($confirm.ExitCode -eq 0) {
				Write-Host "Task $($g) found, proceeding with deletion..."
				"Task $($g) found, proceeding with deletion..." | Out-file -Filepath $global:logfile -append							
				# Copy the template and adapt it for the task being deleted, then declare variables based on the availability of PsExec.
				$cmd=$template -replace "TASKNAME",$t
				if ($psexec) {
					$arglist="-s /accepteula $cmd"
					$filepath=$psexec
				} else {
					$arglist="/c $cmd"
					$filepath="$env:systemroot\system32\cmd.exe"
				}
				Write-Host "Executing command:`t$($cmd)"
				"Executing command:`t$($cmd)" | Out-file -Filepath $global:logfile -append

				# Use Start-Process to run the command so that we can check the return code afterwards.
				$remtask=Start-Process -Filepath $filepath -ArgumentList $arglist -Wait -NoNewWindow -PassThru
				
				if ($remtask.ExitCode -eq "0") {
					Write-Host "Successfully deleted scheduled task $($t)." -foregroundcolor green
					"Successfully deleted scheduled task $($t)." | Out-file -Filepath $global:logfile -append
				} else {
					Write-Host "Failed to delete scheduled task $($t)." -foregroundcolor red
					"Failed to delete scheduled task $($t)." | Out-file -Filepath $global:logfile -append
				}
				Remove-Variable -Name cmd,arglist,filepath,remtask -Force -ErrorAction SilentlyContinue
			} else {
				Write-Host "Task $($t) no longer exists." -foregroundcolor yellow
				"Task $($t) no longer exists." | Out-file -Filepath $global:logfile -append			
			}
		}
	   
		foreach ($f in $folderlist) {
			$cmd=$template -replace "TASKNAME",$f
			if ($psexec) {
				$arglist="-s /accepteula $cmd"
				$filepath=$psexec
			} else {
				$arglist="/c $cmd"
				$filepath="$env:systemroot\system32\cmd.exe"
			}
			Write-Host "Executing command:`t$($cmd)"
			"Executing command:`t$($cmd)" | Out-file -Filepath $global:logfile -append
		
			$remfolder=Start-Process -Filepath $filepath -ArgumentList $arglist -Wait -NoNewWindow -PassThru
			if ($remfolder.Exitcode -eq "0") {
				Write-Host "Successfully deleted scheduled task folder $($f)." -foregroundcolor green
				"Successfully deleted scheduled task folder $($f)." | Out-file -Filepath $global:logfile -append
			} else {
				Write-Host "Failed to delete scheduled task folder $($f). Error was:`n$($_.Exception.Message)" -foregroundcolor red
				"Failed to delete scheduled task folder $($f). Error was:","$($_.Exception.Message)" | Out-file -Filepath $global:logfile -append
			}
			Remove-Variable -Name cmd,arglist,filepath,remfolder -Force -ErrorAction SilentlyContinue
		}
	} else {
        Write-Host "No relevant scheduled tasks were found.`n"
		"No relevant scheduled tasks were found." | Out-file -Filepath $global:logfile -append	
	}
}

##############################
#
# END OF FUNCTION DECLARATIONS
#
##############################

# Main script body
$scriptroot=Split-Path -parent $MyInvocation.MyCommand.Definition
$global:logfile=$scriptroot+"\"+(Get-Date -format 'yyyy_MM_dd_HHmm')+"_Disable-GWX.log"
cls
Write-Host "$(Get-Date -Format 'yyyy-MM-dd HH:mm'): Disable-GWX"
(Get-Date -Format 'yyyy-MM-dd HH:mm')+": Disable-GWX script started..." | Out-file -Filepath $global:logfile -append
Write-Host "PowerShell version: $($PSVersionTable.PSVersion.Major)"
Check-AdministratorAccess
Check-OS

if ($global:admin -and ($OS -notlike "Invalid")) {
    # Check if module is installed and download it if not.
    $found=Get-Module -ListAvailable | Where-Object {$_.Name -like "PSWindowsUpdate"}
    if (!$found) {
        Write-Host "PSWindowsUpdate Module is not currently installed." -foregroundcolor yellow
        "PSWindowsUpdate Module is not currently installed." | Out-file -Filepath $global:logfile -append
		# Invoke Download-File to retrieve module files from Technet.
		Download-File -outfilepath $($scriptroot+"\PSWindowsUpdate.zip") -URI "https://gallery.technet.microsoft.com/scriptcenter/2d191bcd-3308-4edd-9de2-88dff796b0bc/file/41459/43/PSWindowsUpdate.zip"
        # Extract module files, deleting the zip file as it is no longer needed.
        Extract-Zip -file ($scriptroot+"\PSWindowsUpdate.zip") -location $scriptroot -cleanup $true
		# Install the module files.
		Install-UpdateModule -source ($scriptroot+"\PSWindowsUpdate") -ModuleName "PSWindowsUpdate"
		# Test loading the installed module.
		try {
            Import-Module -Name PSWindowsUpdate
			$global:loaded=$true
            Write-Host "Successfully loaded PSWindowsUpdate module." -foregroundcolor green
            "Successfully loaded PSWindowsUpdate module." | Out-file -Filepath $global:logfile -append
        } catch {
            Write-Host "Unable to load PSWindowsUpdate module, error was:`n$($_.Exception.Message)" -foregroundcolor red
			$global:loaded=$false
            "Unable to load PSWindowsUpdate module, error was:" | Out-file -Filepath $global:logfile -append
            "$($_.Exception.Message)" | Out-file -Filepath $global:logfile -append
        }
    } else {
        # Module is already installed; attempt to load it.
        try {
            Import-Module -Name PSWindowsUpdate
			$global:loaded=$true
            Write-Host "Successfully loaded PSWindowsUpdate module." -foregroundcolor green
            "Successfully load PSWindowsUpdate module." | Out-file -Filepath $global:logfile -append
        } catch {
			$global:loaded=$false
            Write-Host "Unable to load PSWindowsUpdate module, error was:`n$($_.Exception.Message)" -foregroundcolor red
            "Unable to load PSWindowsUpdate module, error was:" | Out-file -Filepath $global:logfile -append
            "$($_.Exception.Message)" | Out-file -Filepath $global:logfile -append
        }       
    }
	    # Attempt to create restore point.
	Create-Checkpoint
    # Use status of "startcheckpoint" to determine whether to proceed or not.
    if ($global:checkpoint) {
		# Check for and kill running GWX processes
		$proclist=(Get-Process | Where-Object {$_.ProcessName -like "*GWX*"})
		if ($proclist) {
			foreach ($p in $proclist) {
				try {
					Stop-Process $p.Id -Force -ErrorAction Stop
					Write-Host "Successfully terminated process $($p.Id)" -foregroundcolor green
					"Successfully terminated process $($p.Id)" | Out-file -Filepath $global:logfile -append
				} catch {
					Write-Host "Unable to stop process $($p.Id), error was:`n$($_.Exception.Message)" -foregroundcolor red
					"Unable to stop process $($p.Id), error was:`n$($_.Exception.Message)" | Out-file -Filepath $global:logfile -append
				}
			}
		}		
		
		# Check for GWX-related updates, uninstall if necessary
		$kbarray=("KB3035583","KB2952664","KB3021917","KB2976978")
		foreach ($kbnumber in $kbarray) {
			$present=Get-WMIObject -Class Win32_QuickFixEngineering | Where-Object {$_.HotFixID -match "^$($kbnumber)*"}
			if ($present) {
				$restart=$true
				Write-host "Found $($kbnumber) on system, attempting uninstallation..." -foregroundcolor yellow
				"Found $($kbnumber) on system, attempting uninstallation..." | Out-file -Filepath $global:logfile -append
				try {
					# Uninstall the update, supressing reboot.
					$kbnumber=$kbnumber -replace "KB",""
					wusa.exe /uninstall /kb:$kbnumber /quiet /log /norestart
					Write-Host "Uninstalled update $($kbnumber) successfully." -foregroundcolor green
					"Uninstalled update $($kbnumber) successfully. A reboot will be required to complete the uninstallation process." | Out-file -Filepath $global:logfile -append
				} catch {
					Write-Host "Unable to remove update $($kbnumber), error was:`n$($_.Exception.Message)" -foregroundcolor red
					"Unable to remove update $($kbnumber), error was:","$($_.Exception.Message)" | Out-file -Filepath $global:logfile -append
				}
			} else {
				Write-Host "$($kbnumber) is not installed." -foregroundcolor green
				"$($kbnumber) is not installed." | Out-file -Filepath $global:logfile -append
			}
		}


		# Check if the PSWindowsUpdate module is available and if so use the Hide-WUUpdate command to hide all updates in $kbarray.
		if ($global:loaded) {
            if (!$restart) {
				# No updates were uninstalled, so no reboot is required.
				foreach ($kbnumber in $kbarray) {
					# Define a job for the Hide-Update operation.
					$job = Start-Job -ScriptBlock {
						$kbnumber=$args[0]
						$global:logfile=$args[1]
						ipmo PSWindowsUpdate
						if (Get-WUList -KBArticleID $kbnumber) {
							Write-Host "Windows Update is offering update $($kbnumber); hiding..."
							"Windows Update is offering update $($kbnumber); hiding..." | Out-file -Filepath $global:logfile -append						
							Hide-WUUpdate -KBArticleID $kbnumber -Confirm:$false -ErrorAction Stop
						} else {
							Write-Host "Windows Update is not offering update $($kbnumber); no action required."
							"Windows Update is not offering update $($kbnumber); no action required." | Out-file -Filepath $global:logfile -append						
						}
					} -argumentlist $kbnumber,$global:logfile
					$job | Wait-Job -Timeout 120
					if ($job.State -eq "Completed") {
						Write-Host "Update $($kbnumber) should now be hidden." -foregroundcolor green
						"Update $($kbnumber) should now be hidden." | Out-file -Filepath $global:logfile -append
					} else {
						$job | Stop-Job -Confirm:$false
						Write-Host "An error occured while hiding update $($kbnumber); you will need to manually hide this update in Windows Update." -foregroundcolor red
						"An error occured while hiding update $($kbnumber); you will need to manually hide this update in Windows Update." | Out-file -Filepath $global:logfile -append
					}
					Remove-Variable -Name job
				}
			} else {
				# One or more updates were uninstalled so a reboot is required. 
				if (!$hidetask) {
					# If the script has been run with the Hidetask parameter at its default value, updates will not be hidden automatically.
					Write-Host "One or more updates have been uninstalled, and a reboot is required to complete the uninstallation. After rebooting, Windows Update should be checked and the following updates should be hidden to prevent reinstallation of the GWX client:" -foregroundcolor yellow
					"One or more updates have been uninstalled, and a reboot is required to complete the uninstallation. After rebooting, Windows Update should be checked and the following updates should be hidden to prevent reinstallation of the GWX client:" | Out-file -Filepath $global:logfile -append					
					foreach ($kb in $kbarray) {
						Write-Host $kb -foregroundcolor yellow
						$kb | Out-file -Filepath $global:logfile -append
					}
				}
				# Hiding the updates automatically after the reboot requires creating a scheduled task; the script first has to iterate through other GWX-related tasks to remove them, so the new task is not added until the end of the script.
			}
        } else {
			# The PSWindowsUpdate Module is not available, so automatic hiding of updates cannot be attempted.
			Write-Host "PSWindowsUpdate module is not loaded; you will need to manually hide the following GWX-related updates to prevent them from being (re)installed:" -foregroundcolor yellow
			"PSWindowsUpdate module is not loaded; the following updates will need to be hidden manually to prevent them from being (re)installed:" | Out-file -Filepath $global:logfile -append
			foreach ($kb in $kbarray) {
				Write-Host $kb -foregroundcolor yellow
				$kb | Out-file -Filepath $global:logfile -append
			}
		}

		# Configure policy for preventing the display of the update via the Windows Update interface.
		$exists=get-Item HKLM:\SOFTWARE\Policies\Microsoft\Windows\WindowsUpdate -ErrorAction SilentlyContinue
		if ($exists) {
			if ((Get-ItemProperty HKLM:\SOFTWARE\Policies\Microsoft\Windows\WindowsUpdate).DisableOSUpgrade -eq "1") {
				# Key exists and has correct value, write confirmation to screen
				Write-Host "Policy to disable delivery of Windows 10 through the Windows Update interface is already configured." -foregroundcolor green    
				"Policy to disable delivery of Windows 10 through the Windows Update interface is already configured." | Out-file -Filepath $global:logfile -append
			} else {
				# Key exists with incorrect value.
				try {
					Set-ItemProperty -Path "HKLM:\SOFTWARE\Policies\Microsoft\Windows\WindowsUpdate" -Name "DisableOSUpgrade" -Value 1 -Force -Confirm:$false
					Write-Host "Configured existing registry key to enable policy disabling Windows 10 delivery through the Windows Update interface." -foregroundcolor yellow
					"Configured existing registry key to enable policy disabling Windows 10 delivery through the Windows Update interface." | Out-file -Filepath $global:logfile -append
				} catch {
					Write-Host "An error occured while setting an existing registry key to enable policy disabling Windows 10 delivery through the Windows Update interface; the error message was:`$($_.Exception.Message)" -foregroundcolor yellow
					"An error occured while setting an existing registry key to enable policy disabling Windows 10 delivery through the Windows Update interface." | Out-file -Filepath $global:logfile -append
					"The error message was:$($_.Exception.Message)" | Out-file -Filepath $global:logfile -append
				}
			}
		} else {
			# Key doesn't exist.
			try {
				New-Item -Path "HKLM:\SOFTWARE\Policies\Microsoft\Windows\WindowsUpdate"
				New-ItemProperty -Path "HKLM:\SOFTWARE\Policies\Microsoft\Windows\WindowsUpdate" -Name "DisableOSUpgrade" -Value 1
				Write-Host "Created new registry key & property to enable policy disabling Windows 10 delivery through the Windows Update interface." -foregroundcolor yellow
				"Created new registry key to enable policy disabling Windows 10 delivery through the Windows Update interface." | Out-file -Filepath $global:logfile -append
			} catch {
				Write-Host "Error creating new registry key & property to enable policy disabling Windows 10 delivery through the Windows Update interface, error was:`n$($_.Exception.Message)" -foregroundcolor yellow
				"Error creating new registry key to enable policy disabling Windows 10 delivery through the Windows Update interface." | Out-file -Filepath $global:logfile -append
				"Error was: $($_.Exception.Message)" | Out-file -Filepath $global:logfile -append
			}
		}
	
		# Restrict the folder so that TrustedInstaller cannot repair the changes.
		Restrict-Folder -directory "C:\Windows\system32\GWX" -group "NT SERVICE" -user "TrustedInstaller"

		# Retrieve PsExec to allow running certain actions as the System account to bypass permission issues 		
		Download-File -outfilepath $($scriptroot+"\PSTools.zip") -URI "https://download.sysinternals.com/files/PSTools.zip"
		Extract-Zip -file ($scriptroot+"\PSTools.zip") -location $($scriptroot+"\PSTools") -extractlist @("PsExec.exe") -cleanup $true

		# Identify each registry key involving a GWX component, and manually iterate through them to remove each one.

		Write-Host "Commencing registry modifications."
		"Commencing registry modifications." | Out-file -Filepath $global:logfile -append
		if (Test-Path ($scriptroot+"\PSTools\PsExec.exe")) {
			Remove-RegistryKeys -locations @("HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Schedule\TaskCache\Tasks","HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Schedule\TaskCache\Tree\Microsoft\Windows\Setup") -key "gwx" -psexec $($scriptroot+"\PSTools\PsExec.exe")
		} else {
			Remove-RegistryKeys -locations @("HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Schedule\TaskCache\Tasks","HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Schedule\TaskCache\Tree\Microsoft\Windows\Setup") -key "gwx"
		}
		Write-Host "Registry modifications completed."
		"Registry modifications completed." | Out-file -Filepath $global:logfile -append		

		# Identify related Task Scheduler tasks and delete them. Check if PsExec is available and modify the process invocation accordingly.
		if (Test-Path ($scriptroot+"\PSTools\PsExec.exe")) {
			Remove-ScheduledTasks -key "gwx" -psexec $($scriptroot+"\PSTools\PsExec.exe")
		} else {
			Remove-ScheduledTasks -key "gwx"
		}

		
		# Check if a post-reboot scheduled task has been requested for hiding updates, and if so invoke the relevant function.
		If ($hidetask -and $global:loaded) {
			Write-Host "Creating scheduled task for post-reboot automated hiding of GWX-related updates."
			"Creating scheduled task for post-reboot automated hiding of GWX-related updates." | Out-File -Filepath $global:logfile -append
			Schedule-HideUpdates -updatelist $kbarray -scriptroot $scriptroot
		}
		
		# Finished!
		if ($restart) {
			Write-Host "Script actions completed. A reboot is required to complete the uninstallation of one or more updates."
			"Script actions completed. A reboot is required to complete the uninstallation of one or more updates." | Out-file -Filepath $global:logfile -append
		} else {
			Write-Host "All script actions are now complete."
			"All script actions are now complete." | Out-file -Filepath $global:logfile -append
		}
	} else {
		Write-Host "Unable to create initial restore point; no changes will be made." -foregroundcolor yellow
		"Unable to create initial restore point; no changes will be made." | Out-file -Filepath $global:logfile -append	
	}
} else {
    Write-Host "Script is not running with administrator privileges.`nPlease launch a new administrator session and relaunch the script." -foregroundcolor yellow
    "Script does not have required privileges to proceed. Terminating." | Out-file -Filepath $global:logfile -append
}

Disable the Get-Windows 10 Agent, Part 3

In my previous posts about the GWX agent, I provided a list of steps required for removing the agent and discussed how to handle the first half of them:

  1. Verify that the current session is running with administrative privileges
  2. Install (if necessary) and load the PSWindowsUpdate module.
  3. Create a restore point
  4. Kill any running GWX-related processes.
  5. Uninstall KB3035583 (and any other GWX-related updates).
  6. Check if reboot is required and schedule hiding the updates.
  7. Configure policy for disabling OS upgrade display in the Windows Update interface.

Along the way I defined functions for downloading & extracting compressed files, and for creating a scheduled task for post-reboot actions.

In this post I will primarily be discussing the remaining tasks, but I will first revisit some of the previously-defined functions. This is necessary as I have since modified them to either work around certain issues or make them more flexible.

Revised functions

I’ve updated Part 1‘s Get-UpdateModule function slightly – it was already a simple file-download function but the logging was specific to the PSWindowsUpdate module. I have also updated the Extract-Zip function more significantly – it now supports partial extraction of files based on filename, as well as a post-extraction cleanup to remove the source zip file. The revised version of the function is as follows:

# A generalised function for extracting compressed files to a specified location.
Function Extract-Zip {
    param(
        [string]$file,
        [string]$location,
        [array]$extractlist,
        [boolean]$cleanup=$false
    )
    if (!(Test-Path $location)) {
        try {
            mkdir $location | out-Null
        } catch {
            Write-Host "Unable to create folder $location, error was:`n$($_.Exception.Message)" -foregroundcolor red
			"Unable to create folder $location, error was:`n$($_.Exception.Message)" | Out-file -Filepath $global:logfile -append
        }
    }
    if ($extractlist) {
        Write-Host "Specific file extraction selected. Only the following files will be extracted:";$extractlist
    } else {
        Write-Host "Default mode selected, extracting all files..."
    }
    if ((Test-Path $file) -and (Test-Path $location)) {
        # Instantiate a new shell object and namespace
        $shell=New-Object -com Shell.Application
        $zip=$shell.NameSpace($file)
        # Check if the $extractlist parameter is set, and extract files accordingly.
        if (!$extractlist) {
            # Extract list is not set so default to extracting all files.
            try {
                foreach ($item in $zip.items()) {
                    $shell.Namespace($location).Copyhere($item)
                }
                Write-Host "Finished extracting contents of $file to $location." -foregroundcolor green
			    "Finished extracting contents of $file to $location." | Out-file -Filepath $global:logfile -append
            } catch {
                Write-Host "An error occured while extracting the contents of $file to $location; the error message was:`n$($_.Exception.Message)" -foregroundcolor red
			    "An error occured while extracting the contents of $file to $location; the error message was:", "`n", "$($_.Exception.Message)" | Out-file -Filepath $global:logfile -append
            }
        } else {
            # Extract list is set, so iterate through each name in the array and extract that file from the zip. Items in extractlist are not assumed to be unique matches, so a list of matching contents is generated for each item and a foreach loop iterates through the list, extracting each match individually.
            foreach ($e in $extractlist) {
                $list=@($zip.Items() | Where-Object {$_.Name -like $e})
                if ($list) {
                    foreach ($l in $list) {
                        try {
                            $shell.Namespace($location).Copyhere($l)
                            Write-Host "Extracted file $($e) successfully." -foregroundcolor green
                            "Finished extracting contents of $file to $location." | Out-file -Filepath $global:logfile -append
                        } catch {
                            Write-Host "Unable to extract file $($e), error was:`n$($_.Exception.Message)" -foregroundcolor red
                            "Unable to extract file $($e), error was:",$_.Exception.Message | Out-file -Filepath $global:logfile -append
                        }
                    }
                } else {
                    Write-Host "No file with name $($e) found in specified archive." -foregroundcolor yellow
                    "No file with name $($e) found in specified archive." | Out-file -Filepath $global:logfile -append
                }
				Remove-Variable -Name list -Force -ErrorAction SilentlyContinue
            }
        }
		if ($cleanup) {
			Write-Host "Cleanup enabled: deleting compressed file..." -foregroundcolor Green
			Remove-Item -Path $file -Force 
		}		
    } else {
        Write-Host "Unable to proceed with extraction, invalid input specified!" -foregroundcolor red
		"Unable to proceed with extraction, invalid input specified!" | Out-file -Filepath $global:logfile -append
        if (!(Test-Path $file)) {
            Write-Host "Could not find file $file!" -foregroundcolor red
			"Could not find file $file!" | Out-file -Filepath $global:logfile -append
			
        }
        if (!(Test-Path $location)) {
            Write-Host "Could not find or create folder path $location!" -foregroundcolor red
			"Could not find or create folder path $location!" | Out-file -Filepath $global:logfile -append
        }
    }
}

I’ve also revised Part 2‘s Schedule-HideUpdates function to use a job for each attempt at hiding an update. This was motivated by issues I have been experiencing when testing my script against a fresh install of Windows 7 SP1, whereby Windows Update appears not to detect updates unless specific patches are manually applied.

To avoid a situation of the non-interactive powershell instance started by the scheduled task running indefinitely, I restructured the job so that it first checks whether the update is available from Windows Update and then attempts to hide it. Each job is passed through Wait-Job to ensure that it has 5 minutes in which to run – this should be plenty of time for querying Windows Update and completing the hide request on a normal machine.

If the job is not complete after that time, the log file is updated to record this and the job is forcibly terminated. I have also made the invocation of this function a non-default parametrised option on the main script.

The revised function is as follows:

Function Schedule-HideUpdates {
	param(
		[array]$updatelist,
		[string]$scriptroot
	)
	# Export the array of updates to be hidden to a text file.
    $updatelist | Out-file -Filepath $($scriptroot+"\kbarray.txt")
	# Create a here-string containing the script to be executed post-rebooot to hide the required updates.
$taskscript=@"
`$global:logfile="$($global:logfile)"
(Get-Date -format "yyyy-mm-dd_HH:mm")+": Started post-reboot task to hide GWX-related updates in Windows Update." | Out-File -Filepath `$global:logfile -append
ipmo PSWindowsUpdate
`$timeout=300
`$kbarray=Get-Content "$($scriptroot+"\kbarray.txt")"
foreach (`$kbnumber in `$kbarray) {
	`$job = Start-Job -ScriptBlock {
		`$kbnumber=`$args[0];
		`$global:logfile=`$args[1];
		ipmo PSWindowsUpdate; 
		if (Get-WUList -KBArticleID `$kbnumber) {
			"Update `$kbnumber available from Windows Update, attempting to hide..." | Out-file -Filepath `$global:logfile -append
			try {
				Hide-WUUpdate -KBArticleID `$kbnumber -Confirm:`$false -ErrorAction Stop
				"Update `$kbnumber should now be hidden." | Out-file -Filepath `$global:logfile -append
			} catch {
				"Unable to hide update `$kbnumber, error was:",`$_.Exception.Message | Out-file -Filepath `$global:logfile -append			
			}
		} else {
			"Update `$kbnumber is not being offered by Windows Update, no action needed." | Out-file -Filepath `$global:logfile -append	
		}
	} -argumentlist $kbnumber,$global:logfile
	Wait-Job `$job -Timeout `$timeout | Out-Null;
	if (`$job.State -ne "Completed") {
		Write-Host "Job seems to have stalled, force-quitting..." -foregroundcolor red; 
		Stop-Job `$job -Confirm:`$false
		Remove-Job `$job
	} else {
		Write-Host "Job completed successfully" -foregroundcolor green
	}		
}
"Finished hiding updates.","" | Out-file -Filepath `$global:logfile -append
# Remove scheduled task so that it doesn't run again.
`$deletetask='schtasks /delete /tn "Hide GWX Updates" /f'
`$d=Start-Process -Filepath `$env:systemroot\system32\cmd.exe -ArgumentList "/c `$deletetask" -Wait -NoNewWindow -Passthru
if (`$d.ExitCode -eq 0) {
	"Successfully deleted scheduled task for hiding GWX-related updates." | Out-File -Filepath `$global:logfile -append
} else {
	"Unable to delete scheduled task for hiding GWX-related updates." | Out-File -Filepath `$global:logfile -append
}
"@
	# Output the script to a file in the script root location.
	$scriptfile=$scriptroot+"\HideGWXUpdates.ps1"
	$taskscript | Out-file -Filepath $scriptfile
	
	# Create a new scheduled task as the current user
	Write-Host "Creating scheduled task to run at next logon..."
	$password=Read-Host -Prompt "Please enter the password for $($env:username)" -AsSecureString
	$credential=New-Object -TypeName System.Management.Automation.PSCredential $env:username,$password
	
	$createtask='schtasks /create /tn "Hide GWX Updates" /tr "C:\windows\system32\WindowsPowerShell\v1.0\powershell.exe -File \"'+$scriptfile+'\"" /rl highest /sc onlogon /ru "'+$($env:username)+'" /rp "'+$($credential.GetNetworkCredential().Password)+'"'
	Remove-Variable -Name password,credential -Force -ErrorAction SilentlyContinue

	$c=Start-Process -Filepath $env:systemroot\system32\cmd.exe -ArgumentList "/c $createtask" -Wait -NoNewWindow -Passthru

	Remove-Variable -Name createtask -Force -ErrorAction SilentlyContinue
	
	if ($c.ExitCode -eq 0) {
		Write-Host "Successfully created scheduled task for hiding GWX-related updates."
		"Successfully created scheduled task for hiding GWX-related updates." | Out-File -Filepath $global:logfile -append
	} else {
		Write-Host "Failed to create scheduled task for hiding GWX-related updates."
		"Failed to create scheduled task for hiding GWX-related updates." | Out-File -Filepath $global:logfile -append	
	}
	Remove-Variable -Name createtask,c -Force -ErrorAction SilentlyContinue
}

New functions

That covers the main changes to the code from my previous posts, so it’s time to look at the remaining steps for the script overall. They are:

8. Take ownership of C:\Windows\System32\GWX, from TrustedInstaller
9. Give Administrators group full control of the folder
10. Rename the GWX folder to something else
11. Replace it with an empty folder named GWX and deny access to TrustedInstaller
12. Take a full backup of the whole registry now, to be paranoid, regedit again, find the GWX registry keys and delete them
13. Delete all the GWX scheduled tasks.

Steps 8-11 are all dealt with by the Restrict-Folder function. It takes three parameters – the directory path to be restricted, a user group to which ownership can be transferred, and a user to whom access will be denied.

Function Restrict-Folder {
    param(
        [string]$directory,
		[string]$group,
        [string]$user
    )
    if ($directory) {
        $global:directory=$directory
    } else {
        $valid=$false
        while (!$valid) {
            $global:directory=Read-Host("Enter the directory to be restricted")
            if (Test-Path $global:directory) {
                $valid=$true
            } else {
                Write-Host "Specified directory is invalid, please try again." -foregroundcolor red
            }
        }
    }
    if ($group) {
        $global:group=$group
    } else {
		$valid=$false
		while (!$valid) {
			$global:group=Read-Host("Enter the group name for the user account to block, e.g. NT Service or $($env:computername)")
			if ([ADSI]::Exists('WinNT://./$global:group')) {
				$valid=$true
			} else {
                Write-Host "Specified group does not exist, please try again." -foregroundcolor red			
			}
		}
    }	
    if ($user) {
        $global:user=$user
    } else {
		$valid=$false
		while (!$valid) {
			$global:user=Read-Host("Enter the username for the user account to block, e.g. TrustedInstaller, or $($env:Username)")
			if ([ADSI]::Exists('WinNT://./$global:user')) {
				$valid=$true
			} else {
                Write-Host "Specified user does not exist, please try again." -foregroundcolor red			
			}
		}			
    }

The initial block here handles the parameters and checking that they are valid; if no value is passed, the user is prompted for values. These values are then tested for validity – in the case of the directory Test-Path checks that it exists, and in the case of the user and group an ADSI static method is used to check the users and groups on the local machine (a very useful trick I discovered from [url=http://powershell.com/cs/blogs/tips/archive/2010/12/30/checking-whether-user-or-group-exists.aspx]this page[/url]. This method assumes a standalone workstation – for domain-bound systems the same method could be used replacing the WinNT check with an LDAP check; the ActiveDirectory PS module also provides another method for querying user and group membership.

    Write-Host "Attempting to reconfigure the specified directory..."
	"Attempting to reconfigure the specified directory..." | Out-file -Filepath $global:logfile -append
    $NewOwner=New-Object System.Security.AccessControl.DirectorySecurity
    $admin=New-Object System.Security.Principal.NTAccount("BUILTIN","Administrators")
    $NewOwner.SetOwner($admin)

    $folder="\\localhost\\"+($global:directory -split ":")[0]+"$"+(($global:directory -split ":")[1] -replace "\\","\\")
    [System.IO.Directory]::SetAccessControl($folder,$NewOwner)

This block handles the creation of System Security objects that can be used to manipulate the security descriptor for the target directory. Notice that the $admin object is hard-coded to represent the security group BUILTIN\Administrators – this could be trivially generalised with additional parameters to allow the function to assign new ownership as desired. After creating the $admin object, it is used to set the Owner attribute of the $NewOwner object.

You may be wondering why I didn’t just use Set-ACL for this task. I found that attempting to change the folder ownership via Set-ACL on Windows 7 when the account being used is not already the owner of the folder resulted in “Permission Denied” errors; using the SetAccessControl method from the [url=https://msdn.microsoft.com/en-us/library/system.io.directory%28v=vs.110%29.aspx]System.IO.Directory .NET class[/url] does not trigger these errors.

At this point, steps 8 and 9 of the list are complete.

    $rand=$global:directory+"."+(Get-Random).ToString()
    try {
        Rename-Item $global:directory $rand
        Write-Host "Renamed existing directory to $($rand)." -foregroundcolor green
		"Renamed existing directory to $($rand)." | Out-file -Filepath $global:logfile -append
    } catch {
        Write-Host "Unable to rename existing directory, error was:`n$($_.Exception.Message)" -foregroundcolor red
		"Unable to rename existing directory, error was:","$($_.Exception.Message)" | Out-file -Filepath $global:logfile -append
    }

Once ownership of the directory has been changed, the directory is renamed by adding a randomised suffix. During testing I found that this operation would sometimes fail due to an unspecified pending task relating to the directory; the most reliable way to avoid this appears to be to run the script shortly after a reboot, but I haven’t bothered investigating further (though I suspect Process Explorer & Process Monitor would quickly pinpoint the root cause).

At this point, step 10 is complete.

    if (Test-Path $global:directory) {
		Write-Host "Original GWX directory has not been renamed." -foregroundcolor red
		"Original GWX directory has not been renamed." | Out-file -Filepath $global:logfile -append
    } else {
		Write-Host "Original directory has been renamed. Creating new, empty GWX directory..." -foregroundcolor green
		"Original directory has been renamed. Creating new, empty GWX directory..." | Out-file -Filepath $global:logfile -append
		New-Item $global:directory -Type Directory -Force
	}
	try {
		$colRights = [System.Security.AccessControl.FileSystemRights]"Read"
		$InheritanceFlag = [System.Security.AccessControl.InheritanceFlags]::None
		$PropagationFlag = [System.Security.AccessControl.PropagationFlags]::None
		$objType =[System.Security.AccessControl.AccessControlType]::Allow
		$objUser = New-Object System.Security.Principal.NTAccount($global:group,$global:user)
		$remACE = New-Object System.Security.AccessControl.FileSystemAccessRule ($objUser, $colRights, $InheritanceFlag, $PropagationFlag, $objType)

		$colRights = [System.Security.AccessControl.FileSystemRights]"FullControl"
		$objType =[System.Security.AccessControl.AccessControlType]::Deny
		$denyACE = New-Object System.Security.AccessControl.FileSystemAccessRule ($objUser, $colRights, $InheritanceFlag, $PropagationFlag, $objType)  
	  
		$objGroup = New-Object System.Security.Principal.NTAccount("BUILTIN","Administrators")
	  
		$objACL = Get-ACL $global:directory

		$objACL.SetAccessRuleProtection($true,$true)
	  
		$objACL.RemoveAccessRuleAll($remACE)
	  
		$objACL.AddAccessRule($denyACE)
		
		$objACL.SetOwner($objGroup)
	  
		Set-ACL -Path $global:directory -Aclobject $objACL
		Write-Host "Finished reconfiguring directory $($global:directory). The account $($global:user) should no longer have access to this directory."
		"Finished reconfiguring directory $($global:directory). The account $($global:user) should no longer have access to this directory." | Out-file -Filepath $global:logfile -append
	} catch {
		Write-Host "Unable to complete reconfiguration of directory $($global:directory). The error was:`n$($_.Exception.Message)." -foregroundcolor red
		"Unable to complete reconfiguration of directory $($global:directory). The error was:","$($_.Exception.Message)." | Out-file -Filepath $global:logfile -append
	}
}

After renaming the original folder a new, empty folder is created and it is this folder to which the TrustedInstaller account will be denied access.

Next, I created two new Access Control Entries – one to remove the existing “Allow Read” access, and another to add “Deny FullControl” access. To do this, I started from the instructions in [url=https://technet.microsoft.com/en-us/library/ff730951.aspx]this PowerShell Tip of the Week article[/url] and made a few changes as necessary. Having created the ACEs I then read the ACL from the newly-created GWX directory and used its built-in methods to apply the new ACEs, ensure that the new owner was set as “Builtin\Administrators”, and lastly apply the modified ACL object to the directory.

At this point, step 11 is complete.

Function Remove-RegistryKeys {
    param(
        [array]$locations,
        [string]$key,
		[string]$psexec
    )
	$sortedlist=@()
	foreach ($location in $locations) {
		if ((Test-Path $location) -and ($key)) {
			$list=Get-ChildItem -path $location

			foreach ($l in $list) {
				if ($l.PSPath) {
					$pspath=$l.PSPath
				} else {
					$pspath=$null
				}
				try {
					$taskpath=(Get-ItemProperty -Path $l.PSPath -Name Path -ErrorAction Stop).Path
				} catch {
					$taskpath=$null
				}	
				if ($l.Name) {
					$name=$l.Name
				} else {
					$name=$null
				}
				$obj = New-object -TypeName PSObject -Property @{"Name"=$name;"TaskPath"=$taskpath;"PSPath"=$pspath}
				$sortedlist+=$obj
				Remove-Variable -Name obj -Force -ErrorAction SilentlyContinue
			}
			Remove-Variable -Name list -Force -ErrorAction SilentlyContinue
		} else {
			Write-Host "Unable to proceed with registry key deletion for location $location, invalid input specified!" -foregroundcolor red
			"Unable to proceed with with registry key deletion for location $location, invalid input specified!" | Out-file -Filepath $global:logfile -append
			if (!(Test-Path $location)) {
				Write-Host "Could not find registry location $location!" -foregroundcolor red
				"Could not find registry location $location!" | Out-file -Filepath $global:logfile -append
				
			}
			if (!$key) {
				Write-Host "No search key specified!" -foregroundcolor red
				"No search key specified!" | Out-file -Filepath $global:logfile -append
			}		
		}
	}

Step 12 has multiple tasks folded into one, so we’ll start with them here. The first task should be creating a registry backup – however, as detailed in Part 1, the script creates a System Restore snapshopt at the beginning of the operation, we don’t need to perform any additional work here. The remainder of the task is to find and remove the GWX-related keys.

The function fragment above shows that three parameters are accepted – an array of locations, a key and a psexec string. The latter string is used to specify the location of the SysInternals PsExec.exe executable – this is used to allow running tasks as the System user. While testing the script I encountered several permission restrictions when removing keys and scheduled tasks; using PsExec was the most efficient way around the problem – and since I already had functions that could take care of downloading and extracting the executable if it was needed, it didn’t take much work to add it into the script.

When invoked, the function creates an empty array named $sortedlist, then iterates through each location specified. If the location is valid and a key has been provided, a temporary list of child items in that location is created. For each item in that temporary list a new object is created containing the PowerShell Path, Name, and the TaskPath. Most of the objects being found here relate to scheduled tasks and will have a relative path for the task location within Task Scheduler. The exceptions are task folders themselves, hence using try-catch to assign a null taskpath where necessary.

	$sortedlist = $sortedlist | Sort-Object -Property TaskPath -Descending
	foreach ($k in $sortedlist) {
		if (($k.Name -like "*$($key)*") -or ($k.TaskPath -like "*$($key)*")) {
			Write-Host "Found registry entry matching the search key.`nName:`t$($k.Name)`nTaskPath:`t$($k.Taskpath)`nPSPath:`t$($k.PSPath)`n"
			"Found registry entry matching the search key.","Name:	$($k.name)","Taskpath:	$($k.Taskpath)","PSPath:	$($k.PSPath)","" | Out-file -Filepath $global:logfile -append				
			# The backtick serves to escape the quote marks which are to be included in the string. The backslash will serve as an escape character to preserve the quote marks in the string when it is passed to PSExec, so that spaces in the path do not cause problems.
			$command="Remove-Item -Path \`""+$($k.Name -replace "HKEY_LOCAL_MACHINE","HKLM:")+"\`" -Recurse -Force -Confirm:`$false"
			if ($psexec) {
				Write-Host "PsExec is available; command will be executed as the SYSTEM account."
				"PsExec is available; command will be executed as the SYSTEM account." | Out-file -Filepath $global:logfile -append
				$filepath=$psexec
				$pspath="$env:systemroot\system32\WindowsPowerShell\v1.0\powershell.exe"
				$arglist="-s /accepteula $pspath -InputFormat None -command $command"
				
				Write-Host "The command being executed is:`n$($psexec) $($arglist)"
				"The command being executed is:","$($psexec) $($arglist)" | Out-file -Filepath $global:logfile -append
				
				# Use Start-Process to run the command so that we can check the return code afterwards.
				$remkey=Start-Process -Filepath $filepath -ArgumentList $arglist -Wait -NoNewWindow -PassThru
					
				if ($remkey.Exitcode -eq "0") {
					Write-Host "Successfully deleted registry key $($k.PSPath)." -foregroundcolor green
					"Successfully deleted registry key $($k.PSPath)." | Out-file -Filepath $global:logfile -append
				} else {
					Write-Host "Failed to delete registry key $($k.PSPath)." -foregroundcolor red
					"Failed to delete registry key $($k.PSPath)." | Out-file -Filepath $global:logfile -append
				}
				Remove-Variable -Name command,filepath,pspath,arglist,remkey -Force -ErrorAction SilentlyContinue
				
			} else {
				try {
					Invoke-Expression $command -ErrorAction Stop
					Write-Host "Successfully deleted registry key $($k.PSPath)." -foregroundcolor green
					"Successfully deleted registry key $($k.PSPath)." | Out-file -Filepath $global:logfile -append
				} catch {
					Write-Host "Unable to delete registry key $($k.PSPath), error was:`n$($_.Exception.Message)" -foregroundcolor red
					"Unable to delete registry key $($k.PSPath), error was:","$($_.Exception.Message)" | Out-file -Filepath $global:logfile -append
				}
				Remove-Variable -Name command -Force -ErrorAction SilentlyContinue
			}
		}
	}
}

Having now iterated through all specified locations and identified the objects of interest, the $sortedlist array is piped through Sort-Object to sort its contents in descending order by the TaskPath value. This is not required, given that the Remove-Item cmdlet does support the Recurse parameter, but by doing so the script will log the details of each key which has been identified before attempting to delete them. In the unlikely event of a false positive this will help with remediation.

After sorting the list, each item is evaluated against the specified key in either the Name or TaskPath values. When a match is found the details are written to the logfile and a removal command is defined.

If the PsExec parameter has been specified when invoking the function, additional values are declared which are used in conjunction with Start-Process to run the removal command as the system account. Note the tuse of the “InputFormat -None” parameter when invoking powershell with PsExec – this is required for the process to terminate cleanly. Using the Wait parameter when running Start-Process means that the script waits for the new process to complete before proceeding further.

Once the process is complete, the Exitcode value is checked to determine whether the removal command executed successfully.

If the PsExec parameter has not been specified when invoking the function, the removal comand is executed using Invoke-Expression within a try-catch statement.

At the end of this function, step 12 on the list is complete.

Function Remove-ScheduledTasks {
    param(
		[string]$key,
        [string]$psexec
    )
    $alltasks=Schtasks.exe /query /fo table /NH | Where-Object {$_ -notlike ""}                   
    Write-Host "Retrieved list of all scheduled tasks and folders."
	"Retrieved list of all scheduled tasks and folders." | Out-file -Filepath $global:logfile -append

    $folderlist=@();
    foreach ($f in ($alltasks | Where-Object {($_ -match "^Folder:") -and ($_ -like "*$($key)*")})) {
        $folderlist+=[string]($f -replace ("Folder: ",""))
    }

	$folderlist=$folderlist | Sort-Object -Descending

    if ($folderlist.count -gt 0) {
		Write-Host "Found $($folderlist.count) folders matching the search key. `nFolders:";
		"Found $($folderlist.count) folders matching the search key.","Folders:" | Out-file -Filepath $global:logfile -append
		"Folders:" | Out-file -Filepath $global:logfile -append
		foreach ($f in $folderlist) {
			Write-Host $f;
			$f | Out-file -Filepath $global:logfile -append
		}
    }
	
    $foundtasks=@()
   
    foreach ($f in $folderlist) {
        [boolean]$found=$false
        foreach ($t in $alltasks) {
            if ($found) {
                if (($t -notmatch "^Folder: ") -and ($t -notmatch "^INFO: ")) {
                    $foundtasks+=[string]($f+"\"+(($t -split "  ")[0]).TrimEnd(" "))
                } else {
					break
                }
            }
            if ($($t -replace ("Folder: ","")) -eq $f) {
                $found=$true
            }
        }
    }
	
	$foundtasks = $foundtasks | Sort-Object -Descending

The last step in the list, number 13, is to remove the GWX-related scheduled tasks. While developing this script I found that the registry keys identified in step 12 mostly correlate to the scheduled tasks, but in the interests of being thorough and the possibility of new tasks being added in different locations, I decided to play it safe and assume that numerous scheduled tasks remain in place at this point.

This function takes two parameters, similarly to the Remove-RegistryKeys function – a key for matching against task names or folder locations, and the optional location of the PsExec executable.

Windows 8 & Server 2012 and beyond have native cmdlets for scheduled task handling, but those cmdlets are not available on Windows 7. To this end, all OSs use the schtasks.exe exectuable for task manipulation. Schtasks.exe uses switches to determine the mode in which it functions – so /query is essentially for listing existing tasks. The other parameters in the command specify that the output format will be tabular and that no headers should be supplied. By piping through Where-Object any empty lines can be removed, making subsequent matching tasks easier.

Next, an array of folders which match the specified search key is generated. Folders are easily identified in the output of schtasks as the line will start with “Folder: “, so a regular expression checking the start of line is enough. Once all relevant folders have been identified, they are written to the console and to the logfile. As with the registry keys, the $folderlist array is sorted in descending order. Unlike in the Remove-RegistryKeys function, here it is a requirement – schtasks will not allow the deletion of a scheduled task or folder which contains subtasks or subfolders, so by sorting the folders to be deleted in descending order the script ensures that tasks are deleted in the right order.

With the $folderlist array populated, a new array is declared for all relevant tasks. Iterating through the folders, a $found boolean is declared and set as false. Each line in in alltasks is checked; while $found is false, only folder names are checked.

When a line matching the folder name is found, the $found boolean is set to true – at this point, the full name for the task (including the folder) is constructed. In order to understand the requirement for the split operation, it’s easiest to consider a sample of the output of schtasks:

PS C:\Windows\system32> Schtasks.exe /query /fo table /NH | Where-Object {$_ -notlike ""}
Folder: \
Adobe Acrobat Update Task                N/A                    Unknown
Adobe Flash Player Updater               01/06/2016 13:39:00    Ready
AdobeAAMUpdater-1.0-Winfruity-Kyle       02/06/2016 02:00:00    Ready
CCleanerSkipUAC                          N/A                    Ready
GoogleUpdateTaskMachineCore              02/06/2016 08:45:00    Ready
GoogleUpdateTaskMachineUA                01/06/2016 12:45:00    Ready
User_Feed_Synchronization-{76A88F07-031B 15/05/2019 22:28:33    Ready
Folder: \Microsoft
INFO: There are no scheduled tasks presently available at your access level.

For each value, a TaskName, Next Run Time and Status value are returned. Since only the taskname is relevant here, the line is split. The split character is a set of 4 spaces – while the output sample above shows that only 1 space will reliably be present between the task name and the Next Run Time value, two spaces are required to avoid partial task names in cases where the name contains one or more non-sequential spaces. While testing the script I found that a minimum of 4 spaces were available to use as a split-string. To be on the safe side, I use the TrimEnd method to remove any trailing spaces from the task name. If I were to generalise a function for parsing task information from Schtasks.exe, I would most likely do so using logic which identified either “N/A” or a regular expression matching the date format used for Next Run Time.

Once a line is found which matches either a folder name or an Info statement, a break is used to exit the for loop as no further iteration is required for that folder.

After all task names have been identified and added to the $foundtasks array, the array itself is sorted in descending order, as with the

       if ($foundtasks.count -gt 0) {
		# Write a list of the tasks identified for deletion to the screen and the logfile.
        Write-Host "The following scheduled tasks were found:";
		"The following scheduled tasks were found:" | Out-file -Filepath $global:logfile -append
		foreach ($t in $foundtasks) {
			Write-Host $t
			$t | Out-file -Filepath $global:logfile -append
		}
		
		# Set a template for the schtasks.exe command to use.
		$template="schtasks.exe /Delete /S $env:computername /TN `"TASKNAME`" /F"
		
		foreach ($t in $foundtasks) {
			Write-Host "Proceeding with deletion of task $($g)..."
			"Proceeding with deletion of task $($g)..." | Out-file -Filepath $global:logfile -append							
			# Copy the template and adapt it for the task being deleted, then declare variables based on the availability of PsExec.
			$cmd=$template -replace "TASKNAME",$t
			if ($psexec) {
				$arglist="-s /accepteula $cmd"
				$filepath=$psexec
			} else {
				$arglist="/c $cmd"
				$filepath="$env:systemroot\system32\cmd.exe"
			}
			Write-Host "Executing command:`t$($cmd)"
			"Executing command:`t$($cmd)" | Out-file -Filepath $global:logfile -append

			# Use Start-Process to run the command so that we can check the return code afterwards.
			$remtask=Start-Process -Filepath $filepath -ArgumentList $arglist -Wait -NoNewWindow -PassThru
			
			if ($remtask.ExitCode -eq "0") {
				Write-Host "Successfully deleted scheduled task $($t)." -foregroundcolor green
				"Successfully deleted scheduled task $($t)." | Out-file -Filepath $global:logfile -append
			} else {
				Write-Host "Failed to delete scheduled task $($t)." -foregroundcolor red
				"Failed to delete scheduled task $($t)." | Out-file -Filepath $global:logfile -append
			}
			Remove-Variable -Name cmd,arglist,filepath,remtask -Force -ErrorAction SilentlyContinue
		}

Assuming tasks are found, the contents of the $foundtasks array are written to the display and the logfile.

Next, a template command for deleting a scheduled task on the local computer is defined. Note that in order to accept spaces in the task name, the template includes escaped quotes around the TASKNAME string.

With the template prepared, the script iterates through each entry in the $foundtasks array and for each task, creates a new command string based on the template.

Arguments are defined for a Start-Process command based on whether the PsExec executable has been specified as a parameter or not. As before Start-Process is run with the Wait switch to ensure that the process completes before the script proceeds. The outcome is evaluated by checking the ExitCode value for the removal task and the display and logfile are updated accordingly. After this, variables used within the loop are removed.

		
		foreach ($f in $folderlist) {
			$cmd=$template -replace "TASKNAME",$f
			if ($psexec) {
				$arglist="-s /accepteula $cmd"
				$filepath=$psexec
			} else {
				$arglist="/c $cmd"
				$filepath="$env:systemroot\system32\cmd.exe"
			}
			Write-Host "Executing command:`t$($cmd)"
			"Executing command:`t$($cmd)" | Out-file -Filepath $global:logfile -append
		
			$remfolder=Start-Process -Filepath $filepath -ArgumentList $arglist -Wait -NoNewWindow -PassThru
			if ($remfolder.Exitcode -eq "0") {
				Write-Host "Successfully deleted scheduled task folder $($f)." -foregroundcolor green
				"Successfully deleted scheduled task folder $($f)." | Out-file -Filepath $global:logfile -append
			} else {
				Write-Host "Failed to delete scheduled task folder $($f). Error was:`n$($_.Exception.Message)" -foregroundcolor red
				"Failed to delete scheduled task folder $($f). Error was:","$($_.Exception.Message)" | Out-file -Filepath $global:logfile -append
			}
			Remove-Variable -Name cmd,arglist,filepath,remfolder -Force -ErrorAction SilentlyContinue
		}
	} else {
        Write-Host "No relevant scheduled tasks were found.`n"
		"No relevant scheduled tasks were found." | Out-file -Filepath $global:logfile -append	
	}
}

The final section of this function is very similar to the previous section – it iterates through the folderlist and attempts to delete them.

One thing to note about this function is that the key-matching is done against the folder name. In practice while testing this, I did not run into any issues as all GWX scheduled tasks appear to be contained within a folder with the term GWX somewhere in the name. However, generalising this function would require a change to the task identification logic so that all tasks were checked against the key, instead of identifying folders of interest and enumerating all tasks within them.

The main script body

Having finished dissecting the functions used by the script, the last thing to do is review the remaining section of the main script body to see how the functions are called.

		# Restrict the folder so that TrustedInstaller cannot repair the changes.
		Restrict-Folder -directory "C:\Windows\system32\GWX" -group "NT SERVICE" -user "TrustedInstaller"

		# Retrieve PsExec to allow running certain actions as the System account to bypass permission issues 		
		Download-File -outfilepath $($scriptroot+"\PSTools.zip") -URI "https://download.sysinternals.com/files/PSTools.zip"
		Extract-Zip -file ($scriptroot+"\PSTools.zip") -location $($scriptroot+"\PSTools") -extractlist @("PsExec.exe") -cleanup $true

		# Identify each registry key involving a GWX component, and manually iterate through them to remove each one.

		Write-Host "Commencing registry modifications."
		"Commencing registry modifications." | Out-file -Filepath $global:logfile -append
		if (Test-Path ($scriptroot+"\PSTools\PsExec.exe")) {
			Remove-RegistryKeys -locations @("HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Schedule\TaskCache\Tasks","HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Schedule\TaskCache\Tree\Microsoft\Windows\Setup") -key "gwx" -psexec $($scriptroot+"\PSTools\PsExec.exe")
		} else {
			Remove-RegistryKeys -locations @("HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Schedule\TaskCache\Tasks","HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Schedule\TaskCache\Tree\Microsoft\Windows\Setup") -key "gwx"
		}
		Write-Host "Registry modifications completed."
		"Registry modifications completed." | Out-file -Filepath $global:logfile -append		

		# Identify related Task Scheduler tasks and delete them. Check if PsExec is available and modify the process invocation accordingly.
		if (Test-Path ($scriptroot+"\PSTools\PsExec.exe")) {
			Remove-ScheduledTasks -key "gwx" -psexec $($scriptroot+"\PSTools\PsExec.exe")
		} else {
			Remove-ScheduledTasks -key "gwx"
		}

		
		# Check if a post-reboot scheduled task has been requested for hiding updates, and if so invoke the relevant function.
		If ($hidetask -and $global:loaded) {
			Write-Host "Creating scheduled task for post-reboot automated hiding of GWX-related updates."
			"Creating scheduled task for post-reboot automated hiding of GWX-related updates." | Out-File -Filepath $global:logfile -append
			Schedule-HideUpdates -updatelist $kbarray -scriptroot $scriptroot
		}
		
		# Finished!
		if ($restart) {
			Write-Host "Script actions completed. A reboot is required to complete the uninstallation of one or more updates."
			"Script actions completed. A reboot is required to complete the uninstallation of one or more updates." | Out-file -Filepath $global:logfile -append
		} else {
			Write-Host "All script actions are now complete."
			"All script actions are now complete." | Out-file -Filepath $global:logfile -append
		}

First the Restrict-Folder function is invoked.

Next, Download-File and Extract-Zip are used to retrieve and extract the PsExec executable.

Once that’s complete, Remove-RegistryKeys is invoked – the paths to check and key to use are specified here, and an if-else statement is used to modify the invocation depending on whether PsExec.exe was obtained successfully.

The same approach is used when invoking Remove-ScheduledTasks.

Lastly, the $hidetask parameter and $global:loaded variable values are checked. If both are true, Schedule-HideUpdates is invoked to create a post-reboot task to attempt to hide the GWX-related updates from Windows Update. After this is done, a message is written to the screen and the log file based on whether a reboot is pending.

Given the sheer length of the script, I’ll follow up this post with another post containing just the script code, along with the (very simple) instructions for using it.

Disabling the “Get Windows 10” upgrade agent, part 2

In Part 1, I explained how I handled checking the requirements for the script to ensure that it could run correctly. In terms of the list of tasks for eliminating the GWX component, we’re still at Step 1. However, scrutiny of that list allows it to be further broken down as follows – the new additions are bolded:

  1. Verify that the current session is running with administrative privileges
  2. Install (if necessary) and load the PSWindowsUpdate module.
  3. Create a restore point
  4. Kill any running GWX-related processes.
  5. Uninstall KB3035583 (and any other GWX-related updates).
  6. Check if reboot is required and schedule hiding the updates.
  7. Configure policy for disabling OS upgrade display in the Windows Update interface.
  8. Take ownership of C:\Windows\System32\GWX, from TrustedInstaller
  9. Give Administrators group full control
  10. Rename the GWX folder to something else
  11. Replace it with an empty folder named GWX and deny access to TrustedInstaller
  12. Take a full backup of the whole registry now, to be paranoid, regedit again, find the GWX registry keys and delete them
  13. Delete all the GWX scheduled tasks.

Based on this list, steps 1 & 2 are complete and we’re ready to get started on step 3.

Function Create-Checkpoint {
    try {
        Checkpoint-Computer -Description "Disable-GWX - Start of script." -RestorePointType APPLICATION_UNINSTALL -ErrorAction Stop
        Write-Host "Checkpoint restore point created successfully." -foregroundcolor green
		"Checkpoint restore point created successfully." | Out-file -Filepath $global:logfile -append
        $global:checkpoint=$true
    } catch {
        Write-Host "Unable to create checkpoint restore point; proceeding may be dangerous." -foregroundcolor red
		"Unable to create checkpoint restore point; proceeding may be dangerous." | Out-file -Filepath $global:logfile -append
        $choice=Read-Host("Continue anyway? Y/N")
        if (($choice -eq "Y") -or ($choice -eq "y")) {
            Write-Host "Bypassing checkpoint restorepoint requirement to proceed. You have been warned." -foregroundcolor yellow
			"You have chosen to bypass checkpoint restorepoint requirement to proceed. You have been warned." | Out-file -Filepath $global:logfile -append
            $global:checkpoint=$true
        } else {
            $global:checkpoint=$false
        }
    }
}

PowerShell already has a built-in cmdlet for creating restore points, but I wrapped it in a function to add logging and a boolean variable to track whether the restore point is created successfully. I also decided it might be useful to allow the operator to continue with the process even if the checkpoint process fails.

Function Schedule-HideUpdates {
	param(
		[array]$updatelist,
		[string]$scriptroot
	)
	# Export the array of updates to be hidden to a text file.
    $updatelist | Out-file -Filepath $($scriptroot+"\kbarray.txt")
	# Create a here-string containing the script to be executed post-rebooot to hide the required updates.
$taskscript=@"
`$global:logfile=$global:logfile
Write-Host "Disable-GWX post-reboot task: Attempting to hide GWX-related updates in Windows Update..." -foregroundcolor white
(Get-Date -format "yyyy-mm-dd_HH:mm")+": Started post-reboot task to hide GWX-related updates in Windows Update." | Out-File -Filepath `$global:logfile -append
ipmo PSWindowsUpdate
`$kbarray=Get-Content $($scriptroot+"\kbarray.txt")
[int]`$success=0
[int]`$failure=0
foreach (`$kbnumber in `$kbarray) {
	try {
		Hide-WUUpdate -KBArticleID `$kbnumber -Confirm:`$false -ErrorAction Stop
		Write-Host "Update KB`$kbnumber should now be hidden." -foregroundcolor green
		"Update KB`$kbnumber should now be hidden." | Out-file -Filepath `$global:logfile -append
		`$success++
	} catch {
		Write-Host "An error occured while hiding update KB`$kbnumber; error message was:``n`$_.Exception.Message" -foregroundcolor red
		"An error occured while hiding update KB`$kbnumber; error message was","+`$_.Exception.Message" | Out-file -Filepath `$global:logfile -append
		`$failure++
	}
}
# Remove scheduled task so that it doesn't run again.
`$deletetask='schtasks /delete /tn "Hide GWX Updates" /f'
try {
	Invoke-Expression `$deletetask
	Write-Host "Successfully deleted scheduled task for hiding GWX-related updates." -foregroundcolor green
	"Successfully deleted scheduled task for hiding GWX-related updates." | Out-File -Filepath `$global:logfile -append
} catch {
	Write-Host "Unable to delete scheduled task for hiding GWX-related updates, error was:`n`$_.Exception.Message" -foregroundcolor green
	"Unable to delete scheduled task for hiding GWX-related updates, error was:",`$_.Exception.Message | Out-File -Filepath `$global:logfile -append
}
Write-Host "All actions have been completed:`nUpdates successfully hidden:`t$success`nUpdates which were not hidden:`t$failure"
Read-host=("Press Return to finish.")
"@
	# Output the script to a file in the script root location.
	$scriptfile=$scriptroot+"\HideGWXUpdates.ps1"
	$taskscript | Out-file -Filepath $scriptfile
	
	# Create a new scheduled task as the current user
	Write-Host "Creating scheduled task to run at next logon..."
	$password=Read-Host -Prompt "Please enter the password for $($env:username)" -AsSecureString
	
	$createtask='schtasks /create /tn "Hide GWX Updates" /tr "C:\windows\system32\WindowsPowerShell\v1.0\powershell.exe -File $scriptfile" /sc onlogon /ru $env:username /rp $($password | ConvertFrom-SecureString)'
	try{
		Invoke-Expression $createtask
		Remove-Variable $createtask -Force
		Write-Host "Successfully created scheduled task for hiding GWX-related updates."
		"Successfully created scheduled task for hiding GWX-related updates." | Out-File -Filepath $global:logfile -append
	} catch {
		Remove-Variable $createtask -Force
		Write-Host "Failed to create scheduled task for hiding GWX-related updates, error was:`n$_.Exception.Message"
		"Failed to create scheduled task for hiding GWX-related updates, error was:",$_.Exception.Message | Out-File -Filepath $global:logfile -append	
	}
}

The basic functionality for hiding Windows Updates is provided by the PSWindowsUpdate module. However, if an update requires a reboot to complete its uninstallation, it will not be listed as an available update (and thus be available for hiding) until the reboot is complete. To work around this, I wrote a function that accepts a list of updates and a filepath as parameters, and uses them to create a scheduled task which iterates through the list and attempts to hide each update in turn, then remove itself. This function in particular could be quite easily repurposed – for example, the actions specified in the script generated by the function could be parametrised rather than statically defined.

The first action in the function is to export the list of updates to be hidden (stored in an array whose name is supplied as a parameter) to a text file.

The next action is to start a here-string which will contain the script to be run by the scheduled task. As always with here-strings, the opening and closing lines have to be at the start of their respective lines. The first line of the here-string defines the path for the $global:logfile variable to be the same as that used in the main script, so that the scheduled task action outcomes will be appended to the main script log. Note the use of backticks (`) to differentiate between variables that will be evaluated within the here-string and variable names being declared. Next, the PSWindowsUpdate module is loaded, and the text file with the list of updates is read into an array. The script then iterates through the array attempting to hide each update in turn, using a try-catch statement to capture any errors. When all updates have been processed, an expression is defined to use schtasks.exe to delete the scheduled task. Lastly, a success/failure count is written to the output and a Read-Host prompt is used to keep the script on-screen until it is acknowledged by the user. When the here-string declaration is complete, it is written to a file in the $scriptroot filepath with the name HideGWXUpdates.ps1.

As the main script requires administrative permissions to run, the next step is to request the password for the administrator account under which the script is running. This is done using the AsSecureString parameter to ensure that passwords are not stored in plaintext unnecessarily. An expression is then defined to create the scheduled task, with the task trigger being specifid as the next logon of any user on the system, the task action being to open a PowerShell session and run the HideGWXUpdates.ps1 script. The password is decrypted by piping it through the ConvertFrom-SecureString cmdlet. The expression is executed using Invoke-Expression, and the $createtask variable is then removed. This action is run within a try-catch statement so that the outcome and any errors can be captured in
the log file.

Between the two functions above, step 3 and (most of) Step 6. Steps 3-7 are addressed in full in the remainder of this post via sections of the main script body, as follows:

    # Attempt to create restore point.
	Create-Checkpoint
    # Use status of "startcheckpoint" to determine whether to proceed or not.
    if ($global:checkpoint) {
		{...}
	} else {
		Write-Host "Unable to create initial restore point; no changes will be made." -foregroundcolor yellow
		"Unable to create initial restore point; no changes will be made." | Out-file -Filepath $global:logfile -append	
	}

To start with, the Create-Checkpoint function is invoked, and as shown above declares a global boolean variable depending on its success or failure. An if-else test is used to check the boolean variable and determine whether to proceed with any subsequent actions or not – hence the option in the Create-Checkpoint function to forcibly set the value of the boolean to “true” in the event of a checkpoint failure. This covers step 3.

# Check for and kill running GWX processes
$proclist=(Get-Process | Where-Object {$_.ProcessName -like "*GWX*"})
if ($proclist) {
	foreach ($p in $proclist) {
		try {
			Stop-Process $p.Id -Force -ErrorAction Stop
			Write-Host "Successfully terminated process $($p.Id)" -foregroundcolor green
			"Successfully terminated process $($p.Id)" | Out-file -Filepath $global:logfile -append
		} catch {
			Write-Host "Unable to stop process $($p.Id), error was:`n$($_.Exception.Message)" -foregroundcolor red
			"Unable to stop process $($p.Id), error was:`n$($_.Exception.Message)" | Out-file -Filepath $global:logfile -append
		}
	}
}

Next, Get-Process is piped through a Where-Object filter matching the ProcessName attribute to the “*GWX*” string fragment, and the results are stored in an array. If the array contains any values, the script iterates through them and uses Stop-Process with the Force parameter to attempt to terminate the process, again using try-catch statements to capture any errors for logging purposes. This covers step 4.

# Check for GWX-related updates, uninstall if necessary
$kbarray=("3068708","3022345","2952664","2976978","2977759","3075249",""3080149","3035583")
foreach ($kbnumber in $kbarray) {
	$present=Get-WMIObject -Class Win32_QuickFixEngineering | Where-Object {$_.HotFixID -match "^KB$($kbnumber)*"}
	if ($present) {
		$restart=$true
		Write-host "Found KB$($kbnumber) on system, attempting uninstallation..." -foregroundcolor yellow
		"Found KB$($kbnumber) on system, attempting uninstallation..." | Out-file -Filepath $global:logfile -append
		try {
			# Uninstall the update, supressing reboot.
			wusa.exe /uninstall /kb:$kbnumber /quiet /log /norestart
			Write-Host "Uninstalled update KB$($kbnumber) successfully." -foregroundcolor green
			"Uninstalled update KB$($kbnumber) successfully. A reboot will be required to complete the uninstallation process." | Out-file -Filepath $global:logfile -append
		} catch {
			Write-Host "Unable to remove update KB$($kbnumber), error was:`n$($_.Exception.Message)" -foregroundcolor red
			"Unable to remove update KB$($kbnumber), error was:","$($_.Exception.Message)" | Out-file -Filepath $global:logfile -append
		}
	} else {
		Write-Host "KB$($kbnumber) is not installed." -foregroundcolor green
		"KB$($kbnumber) is not installed." | Out-file -Filepath $global:logfile -append
	}
}

This section declares an array of update identifiers for updates related to GWX, then iterates through the array using Get-WMIObject to check the win32_QuickFixEngineering class and the HotFixID Attribute to determine whether each update is installed. An if-else statement is used to determine whether action is required, and the relevant details are written to the logfile. The list of updates in this section of the code is based on this post.

As a precaution, the script assumes that a reboot is required if any of the updates are present, though it could be refined to use the PSWindowsUpdate module and check if a reboot is required for each update that is found ot be installed on the system. For each update which is installed, wusa.exe is used to attempt a silent uninstallation of the update, with the norestart parameter specified to prevent an automatic reboot.

This covers step 5.

# Check if the PSWindowsUpdate module is available and if so use the Hide-WUUpdate command to hide all updates in $kbarray.
if ($global:loaded) {
	if (!$restart) {
		# No updates were uninstalled, so no reboot is required. 
		foreach ($kbnumber in $kbarray) {
			try {
					Hide-WUUpdate -KBArticleID $kbnumber -Confirm:$false -ErrorAction Stop
					Write-Host "Update KB$($kbnumber) should now be hidden." -foregroundcolor green
					"Update KB$($kbnumber) should now be hidden." | Out-file -Filepath $global:logfile -append
			} catch {
				Write-Host "An error occured while hiding update KB$($kbnumber); error message was:`n$($_.Exception.Message)" -foregroundcolor red
				"An error occured while hiding update KB$($kbnumber); error message was","$($_.Exception.Message)" | Out-file -Filepath $global:logfile -append
			}
		}
	} else {
		# One or more updates were uninstalled so a reboot is required. Thus a scheduled task is required to try and hide the updates in kbarray on the next boot.
		Schedule-HideUpdates -updatelist $kbarray -scriptroot $scriptroot
	}
} else {
	Write-Host "PSWindowsUpdate module is not loaded; you will need to manually hide the following GWX-related updates to prevent them from being (re)installed.`n$($kbarray)" -foregroundcolor yellow
	"PSWindowsUpdate module is not loaded; the following updates will need to be hidden manually to prevent them from being (re)installed.","$($kbarray)" | Out-file -Filepath $global:logfile -append
}

This section uses the state of the $global:loaded boolean variable (declared previously in the main script body, as described in the previous post) to check if the PSWindowsUpdate module has been loaded. The $restart boolean is then checked to determine whether updates can be hidden immediately or not. If no reboot is required, the script iterates through each update in the array and invokes the Hide-WUUpdate cmdlet. If a reboot is required, the Schedule-HideUpdates function is invoked. At the end of this script block, Step 6 is complete.

# Configure policy for preventing the display of the update via the Windows Update interface.
$exists=get-Item HKLM:\SOFTWARE\Policies\Microsoft\Windows\WindowsUpdate -ErrorAction SilentlyContinue
if ($exists) {
	if ((Get-ItemProperty HKLM:\SOFTWARE\Policies\Microsoft\Windows\WindowsUpdate).DisableOSUpgrade -eq "1") {
		# Key exists and has correct value, write confirmation to screen
		Write-Host "Policy to disable delivery of Windows 10 through the Windows Update interface is already configured." -foregroundcolor green    
		"Policy to disable delivery of Windows 10 through the Windows Update interface is already configured." | Out-file -Filepath $global:logfile -append
	} else {
		# Key exists with incorrect value.
		try {
			Set-ItemProperty -Path "HKLM:\SOFTWARE\Policies\Microsoft\Windows\WindowsUpdate" -Name "DisableOSUpgrade" -Value 1 -Force -Confirm:$false
			Write-Host "Configured existing registry key to enable policy disabling Windows 10 delivery through the Windows Update interface." -foregroundcolor yellow
			"Configured existing registry key to enable policy disabling Windows 10 delivery through the Windows Update interface." | Out-file -Filepath $global:logfile -append
		} catch {
			Write-Host "An error occured while setting an existing registry key to enable policy disabling Windows 10 delivery through the Windows Update interface; the error message was:`n$($_.Exception.Message)" -foregroundcolor yellow.
			"An error occured while setting an existing registry key to enable policy disabling Windows 10 delivery through the Windows Update interface. The error message was:","$($_.Exception.Message)" | Out-file -Filepath $global:logfile -append
		}
	}
} else {
	# Key doesn't exist.
	try {
		New-Item -Path "HKLM:\SOFTWARE\Policies\Microsoft\Windows\WindowsUpdate"
		New-ItemProperty -Path "HKLM:\SOFTWARE\Policies\Microsoft\Windows\WindowsUpdate" -Name "DisableOSUpgrade" -Value 1
		Write-Host "Created new registry key & property to enable policy disabling Windows 10 delivery through the Windows Update interface." -foregroundcolor yellow.
		"Created new registry key to enable policy disabling Windows 10 delivery through the Windows Update interface." | Out-file -Filepath $global:logfile -append
	} catch {
		Write-Host "Error creating new registry key & property to enable policy disabling Windows 10 delivery through the Windows Update interface, error was:`n$($_.Exception.Message)" -foregroundcolor yellow.
		"Error creating new registry key to enable policy disabling Windows 10 delivery through the Windows Update interface. Error was: ", "$($_.Exception.Message)" | Out-file -Filepath $global:logfile -append
	}
}

This final code block addresses Step 7, checking whether the registry keys governing Windows Update and the Windows 10 OS upgrade settings exist. If the keys exist, the relevant values are checked and updated if necessary; if not, they are created.

At this point the script is just over half-way through the list of steps required; the remaining steps will be covered in Part 3.

Disabling the “Get Windows 10” upgrade agent, part 1

I haven’t posted any updates recently because my opportunities to experiment with PowerShell have been reduced in the last few months, having moved into a new job far less Windows-based scripting.

I’ve been keeping my hand in working on a script designed to block the Get-Windows10 utility that has been a source of frustration for many Windows 7 & 8 users. I’m well aware that the peak time for such a script to be useful is past, but I thought that it would be a good exercise. More importantly, while there are many utilities out there written by (what I hope are) well-intentioned individuals which can achieve the same end result, the ones I have seen have not released their source code so you have to take them at their word that nothing else is being done by the binary they ask you to run with elevated permissions. Whereas using a script might be marginally more work, but allows more visibility and understanding of what’s being changed.

It’s turned out to be quite long, so I figured I’d split it across two posts.

To start with, I’ll post the list of steps that I’m using as the basis for the script – this was supplied by a former colleague and friend had been frustrated dealing with multiple instances of GWX and wanted to share his method for eradicating it:

  1. Uninstall KB3035583
  2. Take ownership of C:\Windows\System32\GWX, from TrustedInstaller: right-click on GWX folder, Properties->Security->Advanced->Owner->Edit->UAC->(choose owner account)->(tick Replace owner on subcontainers and objects->OK
  3. Give Administrators group full control: right-click on GWX folder, Properties->Security->(choose group)->Edit->(tick Full control in the Allow column)->OK
  4. Move it: right-click on GWX folder, Rename->UAC->(call it GWX_bye or whatever)
  5. Replace with null: Windows Explorer, right-click->New Folder, call it GWX and deny access to TrustedInstaller via process (3).
  6. Take a full backup of the whole registry now, to be paranoid. Then regedit again, find the GWX registry keys, for each one right click->Copy Key Name then right click->Export to back them up, then right click->Delete — there are 11 in HKLM/SOFTWARE/Microsoft/Windows NT/CurrentVersion/Schedule/TaskCache/Tasks with random identifiers, and 11 more under HKLM/SOFTWARE/Microsoft/Windows NT/CurrentVersion/Schedule/TaskCache/Tree/Microsoft/Windows/Setup which correspond to the scheduled tasks in (7).
  7. Task Scheduler->UAC and look under Task Schedule Library->Microsoft->Windows->Setup and there are folders gwx and GWXTriggers with scheduled tasks in, matching the structure under Tree in the registry. If you do (6) it will whinge. Delete the gwx and GWXTriggers tasks.

Looking at that list of steps, the variety of actions required should explain why the script needs to be reasonably lengthy. In the interests of sanity, I’ve split each discrete task into its own function for ease of reusability.

An additional requirement for the script is that it has to work across Windows 7 and Windows 8.1. Windows 7 ships with PowerShell 2.0 by default, and overall I decided it would be more effective to try and do everything using only PowerShell 2.0 cmdlets, rather than solving the problem twice.

Function Check-AdministratorAccess {
    if (([Security.Principal.WindowsPrincipal] [Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole] "Administrator")) {
        $global:admin=$true
        Write-Host "Script is running with required privileges."
        "Script is running with required privileges." | Out-file -Filepath $global:logfile -append
    } else {
        $global:admin=$false
        Write-Host "Not running with required privileges! Open a new PowerShell instance with administrator privileges and try again." -foregroundcolor red
        "Not running with required privileges! Open a new PowerShell instance with administrator privileges and try again." | Out-file -Filepath $global:logfile -append
    }
}

Function Check-OS {
    $OS=(Get-WMIObject -class Win32_OperatingSystem -property Name).Name
    switch ($OS) {
        {$_ -like "*Windows 7*"} {
            $OSVersion="7"
            Write-host "Detected OS: Windows 7..."
            "Detected OS: Windows 7..." | Out-file -Filepath $global:logfile -append
            break
        }
        {$_ -like "*Windows 8*"} {
            $OSVersion="8"
            Write-host "Detected OS: Windows 8/8.1..."
            "Detected OS: Windows 8/8.1..." | Out-file -Filepath $global:logfile -append
            break
        }
        default {
            $OSVersion="Invalid"
            Write-Host "Invalid OS detected..." -foregroundcolor red
            "Invalid OS detected..." | Out-file -Filepath $global:logfile -append
            break     
        }
    }
}

Before anything else, the script needs to be running with administrator rights on an installation of Windows 7 or Windows 8. So step 0 is to ensure both of these requirements are met. First, administrator rights. Searching for this question shows a [i]lot[/i] of posts and forum threads, so it’s a pretty common question. I won’t pretend that I figured it out for myself – I nabbed the central logic for this function from [url=https://blogs.technet.microsoft.com/heyscriptingguy/2011/05/11/check-for-admin-credentials-in-a-powershell-script/]this Hey, Scripting Guy![/url] post.

In terms of identifying the OS, we don’t actually need the OS name or version outside of a single check that it is one of the versions we consider valid. So a simple like-match against the string returned by a WMI query against the OperatingSystem class is enough for our purposes.

Now that the question of requirements is addressed, we can start on the list of tasks.

Step 1 in the list is actually a good bit more complicated than it looks, in terms of PowerShell. Thus far PowerShell has no native utilities for managing Windows Updates, but Michal Gadja has written the very useful PSWindowsUpdate module that enables this. Which means:
* download the Zip file of the module from the Technet Gallery
* extract the Zip file
* install/load the extracted module (and incidentally patch the module file to address an issue on PS2.0 systems)
* use the relevant module to uninstall KB3035583

As it happens, this translates into three functions I’ve unimaginatively named Get-UpdateModule, Extract-Zip and Install-UpdateModule.

Function Get-UpdateModule {
	param(
		[string]$outfilepath,
		[string]$URI
	)
	if ($URI -and $outfilepath) {
		# Check PS Major Version proceed accordingly
				   
		if ($PSVersionTable.PSVersion.Major -gt 2) {
			Write-Host "PowerShell version 3 or higher found: Attempting to download PSWindowsUpdate module..."
			"PowerShell version 3 or higher found: Attempting to download PSWindowsUpdate module..." | Out-file -Filepath $global:logfile -append
			try {
				Invoke-Webrequest -URI $URI -OutFile $outfilepath -ErrorAction Stop
				Write-Host "Successfully downloaded PSWindowsUpdate media." -foregroundcolor green
				"Successfully downloaded PSWindowsUpdate media." | Out-file -Filepath $global:logfile -append
			} catch {
				Write-Host "Unable to download PSWindowsUpdate media, error was:`n$($_.Exception.Message)." -foregroundcolor red
				"Unable to download PSWindowsUpdate media, error was:`",$_.Exception.Message | Out-file -Filepath $global:logfile -append
			}
		} else {
			if (Get-Module BITSTransfer -ListAvailable) {
				try {
					ipmo BITSTransfer
					Write-Host "Loaded BITSTransfer module successfully." -foregroundcolor green
					"Loaded BITSTransfer module successfully." | Out-file -Filepath $global:logfile -append
				} catch {
					Write-Host "Unable to load BITSTransfer module for file download." -foregroundcolor red
					"Unable to load BITSTransfer module for file download." | Out-file -Filepath $global:logfile -append
				}
				if (get-Module BITSTransfer) {
					try {
						Start-BITSTransfer -source $URI -Destination $outfilepath -ErrorAction Stop
						Write-Host "Successfully downloaded PSWindowsUpdate media." -foregroundcolor green
						"Successfully downloaded PSWindowsUpdate media." | Out-file -Filepath $global:logfile -append
					} catch {
						Write-Host "Unable to download PSWindowsUpdate media, error was:`n$($_.Exception.Message)." -foregroundcolor red
						"Unable to download PSWindowsUpdate media, error was:",$_.Exception.Message | Out-file -Filepath $global:logfile -append
					}
				}
			} else {
				Write-Host "Unable to download PSWindowsUpdate media, no suitable cmdlets or modules are available." -foregroundcolor red
				"Unable to download PSWindowsUpdate media, no suitable cmdlets or modules are available." | Out-file -Filepath $global:logfile -append
			}
		}
	} else {
		Write-Host "URI or outfilepath value missing, please try again!" -foregroundcolor red
		"URI or outfilepath value missing, please try again!" | Out-file -Filepath $global:logfile -append
	}
}

The key to this is the realisation that downloads on the Technet Gallery have static download links, which means that instead of having to tweak one of my previous scripts to scrape the links out of the Gallery page I can just hardcode the relevant link into the main body of the script and pass it as a parameter into the function. The output file location is also passed as a parameter.

Since this was the first thing I started doing for this script, I ended up using a version check to see if version 3 or higher of PowerShell was installed, on the basis that this makes Invoke-WebRequest available. It’s not strictly required, but using a single core cmdlet is more efficient than having to load the BITSTransfer module, so I left it this way – aside from anything else, it’s a useful illustration that there are several ways of downloading files with PowerShell natively, depending on the version available.

# A generalised function for extracting compressed files to a specified location.
Function Extract-Zip {
    param(
        [string]$file,
        [string]$location
    )
    if (!(Test-Path $location)) {
        try {
            mkdir $location | out-Null
        } catch {
            Write-Host "Unable to create folder $location, error was:`n$($_.Exception.Message)" -foregroundcolor red
			"Unable to create folder $location, error was:`n$($_.Exception.Message)" | Out-file -Filepath $global:logfile -append
        }
    }
    if ((Test-Path $file) -and (Test-Path $location)) {
        # Instantiate a new shell object and namespace
        $shell=New-Object -com Shell.Application
        $zip=$shell.NameSpace($file)
        # Iterate through each item in the zip file and copy it to the specified location.
        try {
            foreach ($item in $zip.items()) {
                $shell.Namespace($location).Copyhere($item)
            }
            Write-Host "Finished extracting contents of $file to $location." -foregroundcolor green
			"Finished extracting contents of $file to $location." | Out-file -Filepath $global:logfile -append
        } catch {
            Write-Host "An error occured while extracting the contents of $file to $location; the error message was:`n$($_.Exception.Message)" -foregroundcolor red
			"An error occured while extracting the contents of $file to $location; the error message was:", "`n", "$($_.Exception.Message)" | Out-file -Filepath $global:logfile -append
        }
    } else {
        Write-Host "Unable to proceed with extraction, invalid input specified!" -foregroundcolor red
		"Unable to proceed with extraction, invalid input specified!" | Out-file -Filepath $global:logfile -append
        if (!(Test-Path $file)) {
            Write-Host "Could not find file $file!" -foregroundcolor red
			"Could not find file $file!" | Out-file -Filepath $global:logfile -append
			
        }
        if (!(Test-Path $location)) {
            Write-Host "Could not find or create folder path $location!" -foregroundcolor red
			"Could not find or create folder path $location!" | Out-file -Filepath $global:logfile -append
        }
    }
}

I would be lying if I said I came up with this myself – this function is really just an expanded version of the [url=http://www.howtogeek.com/tips/how-to-extract-zip-files-using-powershell/]Expand-ZIPFile function[/url] someone wrote at HowToGeek. I may return to it at some point and expand it to handle creating as well as extracting zip files; it might also be useful to expand it to use 7-Zip to support additional functions and file formats.

Function Install-UpdateModule {
	param(
		[string]$source,
		[string]$ModuleName
	)
	Write-Host "Installing $($ModuleName) module." -foregroundcolor green
	"Installing $($ModuleName) module." | Out-file -Filepath $global:logfile -append
	$target="C:\Windows\system32\WindowsPowershell\v1.0\Modules\"+$ModuleName
	$exists=Test-Path $target
	if (!$exists) {
		Write-Host "Target directory doesn't exist, attempting to create..." 
		"Target directory doesn't exist, attempting to create..." | Out-file -Filepath $global:logfile -append
		try {
			New-Item -Path "C:\Windows\system32\WindowsPowershell\v1.0\Modules" -name $ModuleName -ItemType Directory -ErrorAction Stop | Out-Null
			Write-Host "Target directory created." -foregroundcolor green
			"Target directory created." | Out-file -Filepath $global:logfile -append
		} catch {
			Write-Host "Couldn't create target directory." -foregroundcolor red
			"Couldn't create target directory." | Out-file -Filepath $global:logfile -append
		}
	} else {
		Write-Host "Target directory already exists."
		"Target directory already exists." | Out-file -Filepath $global:logfile -append
	}
	if (Test-Path $target) {
		foreach ($f in (GCI $source -recurse)) {
			$fileexists=Test-Path ($target+"\"+$f.Name)
			if (!$fileexists) {
				if ($f.Name -like "*.psm1" -and $PSVersionTable.PSVersion.Major -lt 3) {
					"Get-ChildItem -Path `$PSScriptRoot\*.ps1 | Foreach-Object { . `$_.FullName}" | Out-File -FilePath $f.FullName
				}
				try {
					Copy-Item $f.FullName -Destination $target -Force -Confirm:$false | Out-Null
					Write-Host "Copied file $($f.name) to installation directory." -foregroundcolor green
					"Copied file $($f.name) to installation directory." | Out-file -Filepath $global:logfile -append
				} catch {
					Write-Host "Unable to copy file $($f.name) to target directory." -foregroundcolor red
					"Unable to copy file $($f.name) to target directory." | Out-file -Filepath $global:logfile -append
				}
			} else {
				Write-Host "File $($f.name) already exists in target location." -foregroundcolor green
				"File $($f.name) already exists in target location." | Out-file -Filepath $global:logfile -append
			}
		}
	} else {
		Write-Host "Unable to install module, target directory could not be found or created." -foregroundcolor red
		"Unable to install module, target directory could not be found or created." | Out-file -Filepath $global:logfile -append
	}
}

For the most part, a fairly straightforward function and easily re-used. All this function does is take a source and module name as strings, then attempt to copy all files from the source to a sub-directory (named for the module) of the default Modules directory for the host system. The only specific function is the section that matches against the psm1 file extension – and that’s to ensure PowerShell 2.0 compatibility. The module file for this module includes a line that uses the Unblock-File cmdlet, which is native to PowerShell 3.0 but will trigger an error when invoked under PowerShell 2.0. Thus my workaround is to manually populate the content of that file rather than copy the version included in the download.

For context, the relevant section of the script that uses all these functions looks like this so far:

# Main script body
$scriptroot=Split-Path -parent $MyInvocation.MyCommand.Definition
$global:logfile=$scriptroot+"\"+(Get-Date -format 'yyyy_MM_dd_HHmm')+"_Disable-GWX.log"
cls
Write-Host "(Get-Date -Format 'yyyy-MM-dd HH:mm'): Disable-GWX"
(Get-Date -Format 'yyyy-MM-dd HH:mm')+": Disable-GWX script started..." | Out-file -Filepath $global:logfile -append
Write-Host "PowerShell version: $($PSVersionTable.PSVersion.Major)"
Check-AdministratorAccess
Check-OS

if ($global:admin -and ($OS -notlike "Invalid")) {
    # Check if module is installed and download it if not.
    $found=Get-Module -ListAvailable | Where-Object {$_.Name -like "PSWindowsUpdate"}
    if (!$found) {
        Write-Host "PSWindowsUpdate Module is not currently installed." -foregroundcolor yellow
        "PSWindowsUpdate Module is not currently installed." | Out-file -Filepath $global:logfile -append
		# Invoke Get-UpdateModule to retrieve module files from Technet.
		Get-UpdateModule -outfilepath $($scriptroot+"\PSWindowsUpdate.zip") -URI "https://gallery.technet.microsoft.com/scriptcenter/2d191bcd-3308-4edd-9de2-88dff796b0bc/file/41459/43/PSWindowsUpdate.zip"
        # Extract module files.
        Extract-Zip -file ($scriptroot+"\PSWindowsUpdate.zip") -location $scriptroot
		# Install the module files.
		Install-UpdateModule -source ($scriptroot+"\PSWindowsUpdate") -ModuleName "PSWindowsUpdate"
		# Test loading the installed module.
		try {
            Import-Module -Name PSWindowsUpdate
            Write-Host "Successfully loaded PSWindowsUpdate module." -foregroundcolor green
            "Successfully loaded PSWindowsUpdate module." | Out-file -Filepath $global:logfile -append
        } catch {
            Write-Host "Unable to load PSWindowsUpdate module, error was:`n$($_.Exception.Message)" -foregroundcolor red
            "Unable to load PSWindowsUpdate module, error was:" | Out-file -Filepath $global:logfile -append
            "$($_.Exception.Message)" | Out-file -Filepath $global:logfile -append
        }
    } else {
        # Module is already installed; attempt to load it.
        try {
            Import-Module -Name PSWindowsUpdate
			$global:loaded=$true
            Write-Host "Successfully loaded PSWindowsUpdate module." -foregroundcolor green
            "Successfully load PSWindowsUpdate module." | Out-file -Filepath $global:logfile -append
        } catch {
			$global:loaded=$false
            Write-Host "Unable to load PSWindowsUpdate module, error was:`n$($_.Exception.Message)" -foregroundcolor red
            "Unable to load PSWindowsUpdate module, error was:" | Out-file -Filepath $global:logfile -append
            "$($_.Exception.Message)" | Out-file -Filepath $global:logfile -append
        }       
    }
	<...>
} else {
    Write-Host "Script is not running with administrator privileges.`nPlease launch a new administrator session and relaunch the script." -foregroundcolor yellow
    "Script does not have required privileges to proceed. Terminating." | Out-file -Filepath $global:logfile -append
}

The admin and OS checks are both tested at the very start. Next, the available modules on the host system are checked; if it is already installed, an attempt is made to load it. If not, the process of downloading, extracting and installing it is started; after which an attempt is made to load it.

I’m going to leave it at that for now, but I’ll have more on this in a week or so.

Generating Reports With PowerShell, Part 4: Generating A Visual Server Health Dashboard

My last three posts have described ways of collecting information and building reports using PowerShell in a variety of circumstances. In this post, I’ll be describing a revised and expanded version of the Active Directory health report I described in part 1. When I say “expanded”, I mean that this script’s scope is broader than the previous script – this script incorporates information on CPU & memory load, available space on the system drive, LDAP response times and AD replication status for domain controllers, rather than focusing exclusively on the Active Directory status itself. Our Exchange server status is also included for the non-AD performance aspects.

For the script to work, the following are required:

  • It must run under an account with domain admin rights (required for repadmin) and administrator access to the relevant servers.
  • The local system must have the ActiveDirectory PS module and AD DS Tools installed.
  • The local system must have .NET 3.5 installed.
  • The local system must have the Microsoft Chart Controls for Microsoft .NET Framework 3.5 installed.

The following pages were particularly helpful for me when assembling this script:

One last thing to note – the scripts below may be difficult to read as they appear here because some sections use strings to assemble HTML, and the WordPress code is automatically converting the less-than symbol to

"&amp;lt;"

and the greater-than symbol to

"&amp;gt;"

For legibility it may be easier to copy the script segments into your text editor of choice and ctrl-f the correct symbols back into place. Alternatively, I have uploaded a sanitised version of the entire script – a lot of this post is taken verbatim from the script comments, so it should be quite easy to follow. Note that due to WordPress restrictions, I have had to change the file extension to .doc.

To give you an idea of what it looks like, here’s a screenshot of the end result, with server names blurred out for obvious reasons:
Visual_Server_Health_Dashboard

With that being said, let’s get started!

Import-Module ActiveDirectory
$allservers = @()
$time=Get-Date
# Manually prepopulate the serverlist array with Exchange servers.
$serverlist="[Exchange Server 1]",[...],"[Exchange Server X]"
# Search for all domain controllers in the current domain and add them to the serverlist.
foreach ($s in (Get-ADDomainController -Filter *).Name) {
	$serverlist+=$s
}
# Sort the server list alphabetically.
$serverlist = $serverlist | Sort-Object

First, the AD module is imported. Next, an array is defined to hold information about all servers of interest, and a variable is declared to capture the current date & time.

Next comes a manually-populated array of all Exchange servers in the environment to be monitored. Ideally, this would be programmatically defined – unfortunately, the environment in which this script is used contains a significant number of systems which have all Exchange services installed, despite not being in use as production Exchange servers. If the target environment features a consistent naming convention that can exclusively identify the production Exchange servers, this array could be procedurally generated as the output of a Get-ADComputer query using the Filter switch and applying a suitable filter based on the Name attribute.

After manually adding the Exchange servers to the serverlist array, Get-ADDomainController is used to query the domain for all available DCs. The Name attribute is selected, and each name is added in turn to the serverlist array.

Finally, the serverlist array is sorted alphabetically.

$User="[Common username prefix]"+(Get-Random -Maximum 9999)

foreach ($s in $serverlist) {
	$Obj=New-Object System.Object
	$Obj | Add-Member -MemberType NoteProperty -Name ServerName -value $s -Force

	$CPU="{0:N2}" -f (Get-Counter -Computername $s -Counter '\Processor(_Total)\% Processor Time').CounterSamples.CookedValue
	$Obj | Add-Member -MemberType NoteProperty -Name CPU -value $CPU -Force
		
	$avail=(Get-Counter -Computername $s -Counter '\Memory\Available MBytes').CounterSamples.CookedValue
	$total=((Get-WMIObject -Computername $s -class Win32_Computersystem).TotalPhysicalMemory/(1024*1024))
	$usage="{0:N2}" -f (($total-$avail)/$total)*100

	$Obj | Add-Member -MemberType NoteProperty -Name RAMUsed -value $usage -Force

	$freespace="{0:N2}" -f ((Get-WMIObject -computername $s -query "Select * from Win32_logicaldisk where Drivetype='3' and DeviceID='C:'").FreeSpace/(1024*1024*1024))
	$Obj | Add-Member -MemberType NoteProperty -Name Freespace -value $freespace -Force
	
	$pingms="{0:N2}" -f ((Test-Connection $s -Count 4) | Measure-Object -Property ResponseTime -Average).Average
	$Obj | Add-Member -MemberType NoteProperty -Name PingTime -value $pingms -Force
	
	$dc= (Get-ADdomainController -filter *).Name | Where-Object {$_ -like $s}
	if ($dc) {
		$string="LDAP://"+$dc+"."+$($env:UserDNSDomain -replace ("&amp;lt;DOMAIN NAME&amp;gt;.",""))
		$root=[ADSI]($string)
		$Searcher = New-Object System.DirectoryServices.DirectorySearcher $Root
		$Searcher.Filter = "(cn=$User)"
		$ldapms= ((Measure-Command {$Container = $Searcher.FindAll()}).TotalMilliseconds)
		$Obj | Add-Member -MemberType NoteProperty -Name LDAPTime -value $ldapms -Force
		Remove-Variable -Name dc,string,root,searcher,ldapms -Force -ErrorAction SilentlyContinue
	} else {
		$Obj | Add-Member -MemberType NoteProperty -Name LDAPTime -value $null -Force
	}

	$allservers += $Obj
	Remove-Variable -Name Obj,CPU,RAM,avail,total,usage,freespace,pingms -Force -ErrorAction SilentlyContinue
}

First, a username is defined to be used for LDAP lookup timing tests. Randomly selecting a user from the target AD environment will add ~20 seconds to the script’s runtime, so as a compromise a random but feasible user ID is generated here and used for the LDAP lookup. The user account does not need to exist in order to be used for the search.

For each server in the serverlist, this loop will create a new object, query the server for a variety of values and counters, and add them as properties on the object. Certain values are only checked for domain controllers.

CPU usage is queried from the CPU usage counter, and the CookedValue is used because it is supplied as a percentage value. The use of “{0:N2}” -f when declaring the variable value ensures that we get the value to 2 decimal places.

RAM usage is slightly trickier, as there no counter for the percentage of memory in use. However, a counter for available (i.e. free) RAM is available, and a WMI query can retrieve the total system RAM. Note that this value must be divided by 1024*1024 to convert it from the returned byte value to the desired megabyte value. Both of these values are used in conjunction to work out the percentage memory usage. Again, the use of “{0:N2}” -f when declaring the variable ensures that we get each value to 2 decimal places.

Free space on the system disk is queried via WMI, using DriveType and DeviceID filters. DriveType 3 ensures that the drive being checked is a fixed internal drive. Using “C:” as the filter for the DeviceID is imperfect, as it is not guaranteed that this will be the systemdrive for all systems, but it is an acceptable initial configuration since C: is the default system drive for a standard Windows Server installation.

The average ping response time in milliseconds is found by Measure-Object cmdlet to take the average value of the response time for 4 pings to the server.

For domain controllers only, the LDAP response time in milliseconds is found by defining and executing an LDAP search. The elapsed time in milliseconds for the search is gathered using the Measure-Command cmdlet. For non-DC servers, a value of null is assigned to the object property.

After all the raw data is gathered for the server, the server object is added to the allservers array declared at the start of the script. All variables used in the data collection are then forcibly removed.

$repinfo=repadmin /replsummary
[string]$sourcetable="
&amp;lt;table border=`"0`" cellpading=`"2`" style=`"float: center`"&amp;gt;"
[string]$desttable="
&amp;lt;table border=`"0`" cellpading=`"2`" style=`"float: center`"&amp;gt;"

$sourcehead="
&amp;lt;tr&amp;gt;
&amp;lt;th bgcolor=`"#ADDFFF`"&amp;gt;Source DSA&amp;lt;/th&amp;gt;
&amp;lt;th bgcolor=`"#ADDFFF`"&amp;gt;Largest Delta&amp;lt;/th&amp;gt;
&amp;lt;th bgcolor=`"#ADDFFF`"&amp;gt;Fails/Total&amp;lt;/th&amp;gt;
&amp;lt;/tr&amp;gt;

"
$sourcetable+=$sourcehead
$desthead="
&amp;lt;tr&amp;gt;
&amp;lt;th bgcolor=`"#ADDFFF`"&amp;gt;Destination DSA&amp;lt;/th&amp;gt;
&amp;lt;th bgcolor=`"#ADDFFF`"&amp;gt;Largest Delta&amp;lt;/th&amp;gt;
&amp;lt;th bgcolor=`"#ADDFFF`"&amp;gt;Fails/Total&amp;lt;/th&amp;gt;
&amp;lt;/tr&amp;gt;

"
$desttable+=$desthead

$dest=$false
$reperr=@()
foreach ($line in $repinfo) {
	switch -regex ($line)	{
		"^Destination" {
			$dest=$true
			break;
		}
		"^*DC-[09]" {
			$dcrepinfo=@()
			foreach ($r in ($line -split "    ")) {
				if ($r -match '\S+') {
					while ($r.Contains(" ")) {
						$r = $r -replace " ",""
					}
					$dcrepinfo+=$r
				}
			}
			# DC Name
			$dcname="
&amp;lt;td&amp;gt;&amp;lt;b&amp;gt;$($dcrepinfo[0])&amp;lt;/b&amp;gt;&amp;lt;/td&amp;gt;

"
			# Largest Delta
			$delta="
&amp;lt;td&amp;gt;$($dcrepinfo[1])&amp;lt;/td&amp;gt;

"
			# Error count
			if ($dcrepinfo[2] -match "^0") {
				$bg="green"
			} else {
				$bg="red"				
			}
			$reperr+=$dcrepinfo[2]
			$errorcount="
&amp;lt;td bgcolor=`"$bg`" align=`"center`"&amp;gt;$($dcrepinfo[2])&amp;lt;/td&amp;gt;

"
			
			$dcstring="
&amp;lt;tr&amp;gt;"+$dcname+$delta+$errorcount+"&amp;lt;/tr&amp;gt;

";
			if (!$dest) {
				$sourcetable+=$dcstring
			} else {
				$desttable+=$dcstring
			}
			break;
		}
	}
}
# After parsing all lines in repinfo, add the end-of-table HTML.
$sourcetable+="&amp;lt;/table&amp;gt;

"
$desttable+="&amp;lt;/table&amp;gt;

"

DC replication summary data is gathered as text using repadmin /replsummary. This is then parsed on a line-by-line basis, using a switch statement with regular expression matching to assemble two strings. Each string contains the HTML to display a table of the collected values. This is done to allow for easy visual representation of the Source DSA and Destination DSA details.

The switch matches two values using regular expression matching:
“^Destination:” (a line starting with the word “Destination:”) This value only appears once in the output. Its appearance indicates that the Source DSA table is complete and the Destination DSA table needs to be started. Thus the action taken in this section is to initiate the Destination DSA table.

“^DC-[09]” If the line starts with any text followed by “DC-” and two digits, it needs to be parsed. Parse info for domain controller. The line is split using a 4-space separator. Each fragment remaining is then checked for non-space content. Non-empty fragments are then iterated until all spaces have been removed, and then each fragment is added to the $dcrepinfo array. HTML table code is then added to the string for each value; the error count is used to determine the background colour of the relevant cell to add visual emphasis.

$DCPingTotal=@()
[int]$DCPingcount=0

$LDAPTotal=@()

$ExchPingTotal=@()

$CPUTotal=@()

$RAMTotal=@()

$FreespaceTotal=@()

foreach ($s in $allservers) {
	if ($s.LDAPTime) {
		$DCPingcount++
		$DCPingTotal+=$s.pingTime
		$LDAPTotal+=$s.LDAPTime
	} else {
		$ExchPingTotal+=$s.pingTime
	}
	$CPUTotal+=$s.CPU
	
	$RAMTotal+=$s.RAM
	
	$FreespaceTotal+=$s.Freespace
}
[int]$DCless1=0
[int]$DCless5=0
[int]$DCrest=0
foreach ($p in $DCPingTotal) {
	switch ($p) {
		{$_ -lt 1} {
			$DCless1++
			break
		}
		{($_ -ge 1) -and ($_ -lt 5)} {
			$DCless5++
			break			
		}
		{$_ -gt 5} {
			$DCrest++
			break			
		}
	}
}
$DCgreen=($DCless1/$DCPingcount)*100
$DCyellow=($DCless5/$DCPingcount)*100
$DCred=($DCrest/$DCPingcount)*100

$LDAPTotal=$LDAPTotal | Sort-Object
$LDAPAvg="{0:N4}" -f ($LDAPTotal | Measure-Object -Average).Average
$LDAPmax=$LDAPTOTAL[$($LDAPTotal.count -1)]
$LDAPmin=$LDAPTotal[0]


[int]$Exless1=0
[int]$Exless5=0
[int]$Exrest=0
[int]$ExPingcount=$allservers.count - $DCPingCount
foreach ($p in $ExchPingTotal) {
	switch ($p) {
		{$_ -lt 1} {
			$Exless1++
			break
		}
		{($_ -ge 1) -and ($_ -lt 5)} {
			$Exless5++
			break			
		}
		{$_ -gt 5} {
			$Exrest++
			break			
		}
	}
}
$Exgreen=($Exless1/$ExPingcount)*100
$Exyellow=($Exless5/$ExPingcount)*100
$Exred=($Exrest/$ExPingcount)*100

[int]$CPU60=0
[int]$CPU80=0
[int]$CPU100=0
foreach ($c in $CPUTotal) {
	switch ($c) {
		{$_ -le 60} {
			$CPU60++
			break;
		}
		{($_ -gt 60) -and ($_ -le 80)} {
			$CPU80++
			break;
		}
		{$_ -gt 80} {
			$CPU100++
			break;
		}
	}
}

$CPUGreen=($CPU60/$CPUTotal.count)*100
$CPUYellow=($CPU80/$CPUTotal.count)*100
$CPURed=($CPU100/$CPUTotal.count)*100

[int]$RAM60=0
[int]$RAM80=0
[int]$RAM100=0
foreach ($r in $RAMTotal) {
    switch ($r) {
		{$_ -le 60} {
			$RAM60++
			break;
		}
		{($_ -gt 60) -and ($_ -le 80)} {
			$RAM80++
			break;
		}
		{$_ -gt 80} {
			$RAM100++
			break;
		}
	}
}
$RAMGreen=($RAM60/$RAMTotal.count)*100
$RAMYellow=($RAM80/$RAMTotal.count)*100
$RAMRed=($RAM100/$RAMTotal.count)*100

[int]$Freespace10=0
[int]$Freespace2=0
[int]$Freespace0=0
foreach ($f in $FreespaceTotal) {
	switch ($f) {
		{$_ -ge 10} {
			$Freespace10++
			break;
		}
		{($_ -ge 2) -and ($_ -lt 10)} {
			$Freespace2++
			break;
		}
		{$_ -lt 2} {
			$Freespace0++
			break;
		}
	}
}
$FreespaceGreen=($Freespace10/$FreespaceTotal.count)*100
$FreespaceYellow=($Freespace2/$FreespaceTotal.count)*100
$FreespaceRed=($Freespace0/$FreespaceTotal.count)*100

[int]$errs=0
[int]$count=0

foreach ($r in $reperr) {
	$errs=$errs+($r -split "/")[0]
	$count=$count+($r -split "/")[1]
}
$ReplGreen=(($count-$err)/$count)*(100)
$ReplRed=($err/$count)*(100)

This section processes the server object values into information that can be used to generate charts. To start with, individual arrays are declared for each property being checked. Following this, the arrays are populated with the values for each server. If an object has a non-null value assigned to LDAPTime it is treated as a DC, otherwise it’s treated as an Exchange server.

After all arrays have been populated, each array is used to define the chart values.

For the pie charts, an integer is declared for each category that the chart will feature. All but one of the pie charts uses three values, on a traffic-light colour system.

DC ping response times: Each server’s ping time is checked and the relevant integer incremented accordingly. The categories in use are “less than 1ms” (Green), “1-5ms” (Amber) and “>5ms” (Red).

LDAP response times: these are handled differently to other values. The initial LDAP Response Times chart used in this report does not require any processing of the information already in the array. However, the minimum, maximum and average values are declared here so that they can be added to the chart (or to a separate chart) in future if required.

Exchange response times: Each server’s ping time is checked and the relevant integer incremented accordingly. The categories in use are “less than 1ms” (Green), “1-5ms” (Amber) and “>5ms” (Red).

CPU chart: Three usage thresholds are defined as “0-60%” (Green), “61-80%” (Amber), and “81-100%” (Red). The integer for each category is incremented according to each server’s CPU utilisation.

RAM chart: Three usage thresholds are defined as “0-60%” (Green), “61-80%” (Amber), and “81-100%” (Red). The integer for each category is incremented according to each server’s RAM utilisation.

Free space chart: Three thresholds are defined as “>10GB free” (Green), “2-10GB free” (Amber), and ”

Replication status chart: This is a binary chart, so only two variables are used, Pass (Green) or Fail (Red).

The integer values for each chart are then converted into percentages through division by the array count, and the percentage values are used when generating the charts themselves.

[void][Reflection.Assembly]::LoadwithPartialName("System.Windows.Forms")
[void][Reflection.Assembly]::LoadwithPartialName("System.Windows.Forms.DataVisualization")

$DCChart=New-Object System.Windows.Forms.DataVisualization.Charting.Chart
$DCChart.Width=500
$DCChart.Height=300
$DCChart.Left=0
$DCChart.Top=0
$DCChartArea=New-Object System.Windows.Forms.DataVisualization.Charting.ChartArea
$DCChart.ChartAreas.Add($DCChartArea)

[void]$DCChart.Titles.Add("DC Ping Times")
$DCChart.Titles[0].Font="Arial Black,24pt"
$DCChart.Titles[0].Alignment="topCenter"

[Void]$DCChart.Series.add("Data")

$datapoint=New-Object System.Windows.Forms.DataVisualization.Charting.DataPoint(0,$DCGreen)
$Datapoint.Color="Green"
if ($DCgreen -lt 100) {
$label="{0:N0}" -f ($DCGreen)
    $Datapoint.Label="$($label)%"
    $label=$null
}
$Datapoint.LegendText="&amp;lt; 1 ms" $DCChart.Series["Data"].Points.Add($datapoint) $datapoint=New-Object System.Windows.Forms.DataVisualization.Charting.DataPoint(0,$DCYellow) $datapoint.Color="Yellow" if ($DCYellow -gt 0) { $label="{0:N0}" -f ($DCYellow) $Datapoint.Label="$($label)%" $label=$null } $Datapoint.LegendText="1 - 5 ms" $DCChart.Series["Data"].Points.Add($datapoint) $datapoint=New-Object System.Windows.Forms.DataVisualization.Charting.DataPoint(0,$DCRed) $Datapoint.Color="Red" if ($DCRed -gt 0) { $label="{0:N0}" -f ($DCRed) $Datapoint.Label="$($label)%" $label=$null } $datapoint.LegendText="&amp;gt; 5 ms"
$DCChart.Series["Data"].Points.Add($datapoint)

$DClegend=New-Object System.Windows.Forms.DataVisualization.Charting.Legend
$DClegend.Name="DC Ping Times"
$DClegend.Font="Arial Black"
$DCChart.Legends.Add($DCLegend)

$DCChart.Series["Data"].ChartType = [System.Windows.Forms.DataVisualization.Charting.SeriesChartType]::Pie
$DCChart.Series["Data"]["PieLabelStyle"] = "Outside"
$DCChart.Series["Data"]["PieLineColor"] = "Black"
$DCChart.Series["Data"]["PieDrawingStyle"] = "Concave" 

$DCChart.SaveImage("&amp;lt;file path&amp;gt;\&amp;lt;file name&amp;gt;.png","PNG")

Remove-Variable -Name DCChart,DCChartArea,datapoint,dclegend -Force -ErrorAction SilentlyContinue

The next section handles the data presentation & assembling the charts. The first step is to load the relevant .NET assemblies.

Several chart types are available, but only two are used in this report – pie and column charts. Accordingly, one chart of each type will be described, since the remaining charts are generated using the same approach. The first chart shown is a pie chart.

To start with, the chart itself is declared. After doing this size and location parameters can be defined. A Chart Area must also be defined separately that will contain the chart visualisation. A chart title can also be assigned, and parameters such as location, font face and font size can be defined.

Next, a new data series is defined in the chart. For each datapoint, a datapoint object is created. This can then be assigned attributes like colour, labels and legend text. For visual clarity, each datapoint assignment uses an if-check before assigning the labels – where a chart is 100% green no label is required, but where the yellow or red values are non-zero, a label is assigned with the percentage value for each segment. Once prepared, each datapoint is added to the data series for the chart.

After the datapoints have been defined, the legend is defined. The font face for the legend can be defined here, alongside other attributes if desired. Once the legend is prepared, it is added to the chart.

The chart is almost ready – the chart type is now defined, along with some other visualisation attributes such as the line style and label placement. After this is done, the chart is saved as an image file using the Chart SaveImage() method. This allows specification of the image location and type. In this report the PNG format is used.

Lastly, the variables used while creating the chart are cleaned up.

$LDAPChart=New-Object System.Windows.Forms.DataVisualization.Charting.Chart
$LDAPChart.Width=500
$LDAPChart.Height=300
$LDAPChart.Left=0
$LDAPChart.Top=0
$LDAPChartArea=New-Object System.Windows.Forms.DataVisualization.Charting.ChartArea
$LDAPchartarea.AxisY.Title = "Response time (ms)"

$LDAPchartarea.AxisY.Interval = [decimal]::ceiling(($LDAPData.Values | Measure-Object -Maximum).Maximum)
$LDAPChart.ChartAreas.Add($LDAPChartArea)

[void]$LDAPChart.Titles.Add("LDAP Query Time")
$LDAPChart.Titles[0].Font="Arial Black,24pt"
$LDAPChart.Titles[0].Alignment="topCenter"

$LDAPlegend=New-Object System.Windows.Forms.DataVisualization.Charting.Legend
$LDAPlegend.Name="LDAP Query Times"
$LDAPlegend.Font="Arial Black"

[Void]$LDAPChart.Series.add("Data")
$LDAPData=@{}
foreach ($a in $allservers) {
	if ($a.LDAPtime) {
		$LDAPData.Add("$($a.ServerName)","$($a.LDAPtime)")
	}
}
$LDAPChart.Series["Data"].Points.DataBindXY($LDAPData.Keys, $LDAPData.Values)

$colours=("Red","Orange","Aquamarine","Green","DeepSkyBlue","Yellow","Gray","Violet","Teal")
[int]$int=0
foreach ($p in $LDAPChart.Series["Data"].Points) {
    $color=$colours[$int]
    $p.Color="$($color)"
    $int++
    $color=$null
}

$LDAPChart.Series["Data"].ChartType = [System.Windows.Forms.DataVisualization.Charting.SeriesChartType]::Column
$LDAPChart.Series["Data"]["ColumnLineColor"] = "Black"

$LDAPChart.SaveImage("<path>\LDAPTime.png","PNG")

Remove-Variable -Name LDAPChart,LDAPChartArea,datapoint -Force -ErrorAction SilentlyContinue

AT first, the column chart is created in the same way as the pie chart. Initially, the core “LDAP Query Time” options are defined. These settings are largely the same as for pie charts in terms of the overall chart dimensions, but with some custom settings for the column chart. For example the Y-axis gets a title of its own, and the Y-axis interval is configured as a function of the largest value in the dataset so that deviations from the norm are more visibly apparent.

Next, the chart title and legend are defined, in much the same way as they are for the pie charts.

Unlike the pie charts, for the column chart each data point is added as a hashtable – this allows each server name to be listed along the X-xis. This in turn means that persistent problems can be quickly identified. To make the chart easier to read in visual terms, each column is assigned a different colour. The method used here is the first idea I had that would work; the Chart Controls actually offer a much wider range of colors than the default console colors, so another approach might be a variation on using $colours=[enum]::GetValues([System.ConsoleColor]) as the $colours array definition. While writing the script I didn’t want to get sidetracked onto something trivial, so I stuck with the simple approach shown above.

After this the chart type and appearance settings are assigned, then the chart is saved as a file, and the variables used are cleaned up.

	
$maintable=@"

&amp;lt;table border="0" cellpadding="2" style="float: center" style="width:100%"&amp;gt;

&amp;lt;tr style="height:300px"&amp;gt;

&amp;lt;td style="width:500px"&amp;gt;&amp;lt;img src="DCResponseTime.png" align="middle"&amp;gt;&amp;lt;/td&amp;gt;


&amp;lt;td style="width:500px"&amp;gt;&amp;lt;img src="LDAPTime.png" align="middle"&amp;gt;&amp;lt;/td&amp;gt;


&amp;lt;td style="width:500px"&amp;gt;&amp;lt;img src="ExResponseTime.png" align="middle"&amp;gt;&amp;lt;/td&amp;gt;

	&amp;lt;/tr&amp;gt;


&amp;lt;tr style="height:300px"&amp;gt;

&amp;lt;td style="width:500px"&amp;gt;&amp;lt;img src="CpuUsage.png" align="middle"&amp;gt;&amp;lt;/td&amp;gt;


&amp;lt;td style="width:500px"&amp;gt;&amp;lt;img src="RamUsage.png" align="middle"&amp;gt;&amp;lt;/td&amp;gt;


&amp;lt;td style="width:500px"&amp;gt;&amp;lt;img src="FreespaceUsage.png" align="middle"&amp;gt;&amp;lt;/td&amp;gt;

	&amp;lt;/td&amp;gt;

&amp;lt;/table&amp;gt;

"@

$repltable=@"

&amp;lt;table border="0" cellpadding="2" style="float: center" style="width:100%"&amp;gt;

&amp;lt;tr&amp;gt;

&amp;lt;td style="width:500px" align="center"&amp;gt;$($sourcetable)&amp;lt;/td&amp;gt;


&amp;lt;td style="width:500px" align="center"&amp;gt;$($desttable)&amp;lt;/td&amp;gt;


&amp;lt;td style="width:500px" align="center"&amp;gt;&amp;lt;img src="Replication.png" align="middle"&amp;gt;&amp;lt;/td&amp;gt;

	&amp;lt;/tr&amp;gt;

&amp;lt;/table&amp;gt;

"@

$timestamp=(Get-Date -format 'MM/dd HH:mm')
$header="

&amp;lt;font face=`"Arial Black`" size=`"6`"&amp;gt;&amp;lt;b&amp;gt;DC &amp;amp; Exchange Server Status:
Last refresh:`t$($timestamp)&amp;lt;/h1&amp;gt;

"

$HTML="&amp;lt;html&amp;gt;`n&amp;lt;head&amp;gt;" + "`n&amp;lt;/head&amp;gt;`n" + $header + "

" + $maintable + "`n" + $repltable + "

`n&amp;lt;/body&amp;gt;`n&amp;lt;/html&amp;gt;"

$outputfile="&amp;lt;path&amp;gt;\&amp;lt;filename&amp;gt;.html"
$HTML | Out-file -FilePath $outputfile

The final section deals with the comparatively simple task of assembling HTML for the report page. The tables are defined individually as here-strings, then a variable containing the full page HTML is declared and exported as a file.

In terms of the overall workflow, the images and report file are uploaded via FTP to the central server controlling the displays. A scheduled task runs on a set frequency to generate the output files, then run a batch file which opens an FTP connection to the server and uploads the new files. The displays are configured to refresh periodically and pull down the new files.

There are some things I’d refine if I ever revisit this script – amongst other things, for length alone I’d prefer to reuse a single method for generating all the pie-charts used, but again the initial form works well enough that it wasn’t a requirement – particularly since this report is intended only as an interim solution. I would also like to refine the overall output so that average, maximum and minimum values could be displayed underneath each chart. Similarly, I’d like to have some way of attaching server names to the labels for each pie chart, for example so that if one or more servers is showing >80% CPU usage they are identified by name on the chart.

A somewhat more involved and complex addition would be to enable tracking over time; a first approximation of this would probably involve adding code to output each server’s set of values to a file, and have a separate script run which can do a hour/day/week trend plot. This could be quite useful for performance visualisation but also starts to become complex enough to define and maintain that it may be easier to use a purpose-built tool to do it such as System Centre Operations Manager.