Set Windows Update Schedule on Server 2012 Core with Local Policy

I'm seeing a lot more Server Core deployments as 2012 adoption increases. Which I think is awesome - I love Server Core. But there are still a couple things that were completely trivial on a GUI edition of Windows that are a tad tricky on Core.

For instance, here I am on Server 2012 Core using the sconfig utility to set up Windows Automatic Updates:

sconfig

This is just a lab environment, so I don't mind automatic updates and reboots. But I have two Active Directory domain controllers in this lab, and I don't want them rebooting at the same time. So how do I change the "every day at 3:00 AM" schedule so that I can stagger the patching and reboots? On a GUI install it would be trivial of course. Here, not as much.

The first thing I did was briefly look for a registry entry in HKLM:\Software\Microsoft\Windows\CurrentVersion\WindowsUpdate\, but I didn't see anything that looked like it would help me modify the Windows Update schedule like I wanted to.

These are domain members, so of course I could use Group Policy to do it, but I didn't want to create and link separate GPOs for each server that I wanted to have a different Windows Update schedule. Plus I wanted to figure out how to do it on non domain-joined machines as well.

Ah - Local Policies! To edit local policies on Core servers, we'll need to connect to them remotely from what I like to call "a tools machine." When you have a bunch of Server Core machines running out there, you should also keep one machine around that has a full GUI install. I personally like to install all my tools (like RSAT) on that one machine, and use it to centrally manage all of the Core machines remotely.

Here I am using mmc on my tools machine to add Group Policy Object Editor snapins for several of the Server Core computers: 

Local Policies

Again, the name of the snapin is Group Policy Object Editor, and target the remote machine as you add the snapin. You'll of course need RPC over TCP connectivity to the remote machine, and you'll need to modify Windows Firewall on the remote machine to allow the incoming connection. (I like to use domain Group Policies for that, so all my machines have consistent firewall settings.)

All that's left to do now is navigate to Computer Configuration > Administrative Templates > Windows Components > Windows Update, and configure the "Configure Automatic Updates" setting for the server. It allows you to modify the hour and the day of the week that Windows Update will download and install updates on that machine.

As per Active Directory Group Policy application precedence, remember that any conflicting domain-based GPO will override settings you make to a machine's Local Policy. 

Lastly - don't forget that automatic update option 4 - "Auto download and schedule the install" - is the only option here that applies to Server Core. The others won't work because Server Core can't "notify" the user of updates the way it could were the GUI installed.

My Entry for the Advanced Event #4 of the 2013 Scripting Games

We're on the downhill stretch now. Honestly I'm kind of glad.  These scripts are fun to write, and great practice, but it's work.  I can tell that I'm not the only one loosing steam, as the number of votes on other people's entries has gone way down.  Anyway, about the script I wrote: I like that the #Requires -Modules statement at the top automatically loads the AD module for you if it's not already loaded. I still didn't do the BEGIN/PROCESS/END blocks this time either, which I fail to see how it matters at all, since I'm not dealing with pipeline input... but I'm sure I'll still get crowd scores of 1 and 2 stars for it.  That and dudes with 640x480 monitors going "some of your code goes off the screen why don't you splat!?"  :P

#Requires -Version 3
#Requires -Modules ActiveDirectory
Function Get-RandomADUser
{
<#
.SYNOPSIS
    Retrieves random users from Active Directory and generates an HTML report.
.DESCRIPTION
    Retrieves random users from Active Directory, generates an HTML report,
    and then returns the users to the pipeline. 
    Use the -Verbose switch if you want to see console output.
    This Cmdlet requires PS 3 and the Active Directory module. The AD module
    will be loaded automatically if it isn't already.
.PARAMETER Count
    The number of random users to get from Active Directory. Minimum is 1,
    maximum is Int16.MaxValue (32767) and the default is 20.
.PARAMETER Filename
    The filename to write the HTML report to. The filename must end in
    html or htm. The default is .\RandomADUsers.html.
.EXAMPLE
    Get-RandomADUser
 
    Gets 20 random users from AD, outputs a report to .\RandomADUsers.html.
.EXAMPLE
    Get-RandomADUser -Count 100 -Filename C:\reports\rpt.html.
 
    Gets 100 random users from AD, outputs a report to C:\reports\rpt.html.
#>
 
    [CmdletBinding()]
    Param([Parameter()]
            [ValidateRange(1, [Int16]::MaxValue)]
            [Int16]$Count = 20,
          [Parameter()]
            [ValidateScript({($_.ToLower().Split('.')[-1] -EQ "html" -OR $_.ToLower().Split('.')[-1] -EQ "htm") -AND (Test-Path -IsValid $_)})]
            [String]$Filename = ".\RandomADUsers.html") 
 
    Try
    {
        Write-Verbose "Retrieving users from Active Directory..."
        $Users = Get-ADUser -Filter * -Properties Department, Title, LastLogonDate, PasswordLastSet, Enabled, LockedOut -ErrorAction Stop | Get-Random -Count $Count
        Write-Verbose "$($Users.Count) users retrieved from Active Directory."
    }
    Catch
    {
        Write-Error "Unable to retrieve users from Active Directory: $($_.Exception.Message)"
        Return
    }   
    Try
    {
        Write-Verbose "Generating report $Filename..."
        $Header = @'
        <title>Random Active Directory User Audit</title>
            <style type=""text/css"">
                <!--
                    TABLE { border-width: 1px; border-style: solid;  border-color: black; }
                    TD    { border-width: 1px; border-style: dotted; border-color: black; }
                -->
            </style>
'@
        $Pre  = "<p><h2>Random Active Directory User Audit for $Env:USERDNSDOMAIN</h2></p>"
        $Post = "<hr><p style=`"font-size: 10px; font-style: italic;`">This report was generated on $(Get-Date)</p>"
        $Users | ConvertTo-HTML -Property SamAccountName, Department, Title, LastLogonDate, PasswordLastSet, Enabled, LockedOut -Head $Header -PreContent $Pre -PostContent $Post | Out-File $Filename     
        Return $Users
    }
    Catch
    {
        Write-Error "Unable to generate report: $($_.Exception.Message)"
    }
}

Active Directory List Object Mode

This is something I've been wanting to blog about for a long time, but have been putting it off because I knew it might turn in to a long, time-consuming post. Well it's time to bite the bullet and get started.

We were facing a bit of a problem in one of our managed hosting environments. We had this high-volume, multitenant Active Directory being used by dozens of different customers. There was a business requirement in this domain that customers not be able to read from one another's organization units for the sake of the mutual privacy of the customers. Things seemed to be working well for a while, but one day, it appeared that customer users logging on to many of the client computers were failing to process Group Policy upon logon:

Event ID: 1101
Source: Userenv
User: NT Authority\System
Description: Windows cannot access the object OU=Customers, DC=contoso, DC=com in Active Directory. The access to the object may be denied. Group Policy processing aborted.

To start troubleshooting, I copied one of the affected user accounts and used it to log in to one of their machines, and I was able to reproduce the issue. Upon trying to update Group Policy with gpupdate.exe, I noticed that the computer configuration was updating fine, while only the user portion of the update failed, and the event 1101 was produced.

The basic layout of the OU structure in the domain was this:

    
CONTOSO.COM
    |
    + Customers (OU)
          |
          + Customer1 (OU)
          |
          + Customer2 (OU)
          |
          + ...

Still using my customer-level user account, I noticed that I was able to browse the contents of my own Customer1 OU, but I was not able to browse the contents of any other OU. The permissions on these OUs had certainly been modified.

In fact, it was that the read permission for the Authenticated Users security group had been removed from the access control list on the Customers OU. That explains the event 1101s and the GPO processing failures. From Microsoft:

[GPO processing fails] when the Group Policy engine cannot read one of the OUs.

The Group Policy engine must be able to read all OUs from the level of the user object or the computer object to the level of the domain root object. Also, the Group Policy engine must be able to read the domain root object and the site object of the computer. This is because these objects may contain links to group policies. If the Group Policy engine cannot read one of these OUs, the events that are mentioned in the "Symptoms" section will be logged.

So in satisfying the business requirement that no customer be allowed to list the contents of another customer's OU, Group Policy processing had been broken. But simply giving Authenticated Users their read permissions back on the Customers OU, they get to browse all the other customers OUs as well.

We needed the best of both worlds.

This Microsoft article would lead you to believe that if a security principal just had the Read gpLink and Read gpOptions access control entries, then GPO processing should work fine:

But that's not enough. The four ACEs that were needed on the Customers OU were:

  • Read gpLink
  • Read gpOptions
  • Read cn
  • Read distinguishedName

Now we're making progress, but we're still not out of the woods. Giving Authenticated Users the List Contents permission on the Customers OU would allow them to see the names of all the other customer's OUs, although now they show up as "Unknown" object types and can't have their respective contents listed. But that's a messy solution in my opinion and doesn't fully satisfy the requirement. Customer1 shouldn't even be aware of Customer2's existence.

There's one last piece of the puzzle missing, and that brings me to List Object Mode.

List Object Mode is one strategy available to Active Directory administrators to allow for hiding certain bits of data from certain users. List Object mode has to be enabled manually; it's turned off by default. To enable it, set the value of the dsHeuristics property in the Configuration partition to 001 using ADSI Edit, like so:

dsHeuristics

Now you will have a new access control entry in the list on objects in your forest: List Object. The ACE was actually there before, but Active Directory doesn't enforce it by default.

List Object Mode is a form of Access Based Enumeration, (not to be confused with file system ABE,) where items are not displayed to users that do not have List Object permissions to them. By default, when a user has the List Contents permission on an OU, and queries that OU, he or she is given a list of all child OUs in that parent OU, even if the user doesn't have read access to those other child OUs.  They show up in ADUC as "Unknown" object types and get that little blank page for an icon which is the Microsoft universal symbol for "wth is this?"

By using List Object permissions after having enabled it as just described, Active Directory evaluates the permissions of all the child objects under the object that was queried before returning the results to the user. Unless the user has the List Object permission on the object, it is omitted from the results. So now we have a customer user who is able to read just his or her own OU, and the other Customer OUs are completely hidden from view.

And no more Group Policy failures due to access denied, either.

So are there disadvantages to enabling and using List Object mode in your domain? Yes there are. So even though it may be appropriate for your environment, List Object Mode is not for everybody and it's not a decision that should be made lightly:

  • Significantly increased access control checks on LDAP queries = busier domain controllers.
  • You may need to rethink your entire User and Computer organization strategy to accommodate for how the new permissions work.
  • It's a less common configuration that fewer people are familiar with. Administrative complexity++. You need to fully document the change and make sure every administrator is aware of it.

So there you have it. Now go impress your friends with your knowledge of AD List Object Mode!

My Entry for the Advanced Event #3 of the 2013 Scripting Games

Halfway done.  Here's my third entry for this year's Powershell games.  I used a workflow this time, mostly in an attempt to garner favor from the voters for using new features exclusive to PS3.  Even though the multithreading with jobs that I did in the last event is a neat idea, it really doesn't perform very well.  The workflow will likely perform better, though I don't know if it's going to handle the throttling of thread creation if I handed it a list of 500 computers.

#Requires -Version 3
Function New-DiskSpaceReport
{
	<#
		.SYNOPSIS
			Gets hard drive information from one or more computers and saves it as HTML reports.
		.DESCRIPTION
			Gets hard drive information from one or more computers and saves it as HTML reports.
			The reports are saved to the specified directory with the name of the computer in
			the filename. The list of computers is processed in parallel for increased speed.
			Use the -Verbose switch if you want to see console output, which is very useful if you
			are having problems generating all the desired reports.
		.PARAMETER ComputerName
			One or more computer names from which to get information. This can be a
			comma-separated list, or a file of computer names one per line. The alias
			of this parameter is -Computer. The default value is the local computer.
		.PARAMETER Directory
			The directory to write the HTML files to. E.g., C:\Reports. The directory
			must exist. The default is the current working directory.
		.INPUTS
			[String[]]$ComputerName
			This is an array of strings representing the hostnames of the computers
			for which you want to retrieve information. This can also be supplied by
			(Get-Content file.txt). This can be piped into the cmdlet.
		.INPUTS
			[String]$Directory
			The directory to save the HTML reports to. The directory must exist.
		.OUTPUTS
			HTML files representing the information obtained from all
			the computers supplied to the cmdlet.
		.EXAMPLE
			New-DiskSpaceReport
			
			This will generate a report for the local computer and output the HTML file to
			the current working directory.			
		.EXAMPLE
			New-DiskSpaceReport -ComputerName server01,server02,server03 -Directory C:\Reports
			
			This will generate three HTML reports for the servers and save them in the C:\Reports
			directory.
		.EXAMPLE
			New-DiskSpaceReport -Computer (Get-Content .\computers.txt)
			
			This will generate HTML reports for all the computers in the computers.txt file and
			save the reports in the current working directory.
		.EXAMPLE
			,(Get-Content .\computers.txt) | New-DiskSpaceReport -Directory C:\Reports
			
			This will generate HTML reports for all the computers in the computers.txt file and
			save the reports in C:\Reports. Please note the leading comma in this example.
		.NOTES
			Scripting Games 2013 Advanced Event 3
	#>
	[CmdletBinding()]
	Param([Parameter(ValueFromPipeline=$True)]
			[Alias('Computer')]
			[String[]]$ComputerName = $Env:Computername,
		  [Parameter()]
			[ValidateScript({Test-Path $_ -PathType Container})]
			[String]$Directory = (Get-Location).Path)
	
	Write-Verbose -Message "Writing reports to $Directory..."
	
	WorkFlow BuildReports
	{
		Param([String[]]$Computers, [String]$Directory)
		ForEach -Parallel ($Computer In $Computers)
		{			
			InlineScript
			{				
				Write-Verbose -Message "Generating report for $Using:Computer..."
				$Header = @'
				<title>Disk Free Space Report</title>
				<style type=""text/css"">
					<!--
						TABLE { border-width: 1px; border-style: solid;  border-color: black; }
						TD    { border-width: 1px; border-style: dotted; border-color: black; }
					-->
				</style>
'@
				$Pre  = "<p><h2>Local Fixed Disk Report for $Using:Computer</h2></p>"
				$Post = "<hr><p style=`"font-size: 10px; font-style: italic;`">This report was generated on $(Get-Date)</p>"
				Try
				{					
					$LogicalDisks = Get-WMIObject -Query "SELECT * FROM Win32_LogicalDisk WHERE DriveType = 3" -ComputerName $Using:Computer -ErrorAction Stop | Select-Object -Property DeviceID,@{Label='SizeGB';Expression={"{0:N2}" -F ($_.Size/1GB)}},@{Label='FreeMB';Expression={"{0:N2}" -F ($_.FreeSpace/1MB)}},@{Label='PercentFree';Expression={"{0:N2}" -F (($_.Freespace/$_.Size)*100)}};
					$LogicalDisks | ConvertTo-HTML -Property DeviceID, SizeGB, FreeMB, PercentFree -Head $Header -PreContent $Pre -PostContent $Post | Out-File -FilePath $(Join-Path -Path $Using:Directory -ChildPath $Using:Computer`.html)
					Write-Verbose -Message "Report generated for $Using:Computer."
				}
				Catch
				{
					Write-Verbose -Message "Cannot build report for $Using:Computer. $($_.Exception.Message)"
				}
			}
		}
	}
	
	If($PSBoundParameters['Verbose'])
	{
		BuildReports -Computers $ComputerName -Directory $Directory -Verbose
	}
	Else
	{
		BuildReports -Computers $ComputerName -Directory $Directory
	}
}

My Entry for the Advanced Event #2 of the 2013 Scripting Games

More Powershell! I'm somewhat proud of this script.

#Requires -Version 3
Function Get-ComputerInfo
{
	<#
		.SYNOPSIS
			Gets some basic system information about one or more remote Windows computers.
		.DESCRIPTION
			Gets some basic system information about one or more remote Windows computers.
			Specifically designed to be able to fetch information from any version of
			Windows computer from Windows 2000 up. This Cmdlet takes only one parameter,
			-ComputerName. ComputerName can be a single computer name or IP address, or it
			can be an array of computer names. You can also use a file of computer hostnames,
			one per line. This function will return the information gathered from all
			of the computers. Remember to use a leading comma when piping an array to
			this cmdlet. See the examples for more details. Powershell 3.0 is the minimum
			required on the machine that runs this cmdlet, though the target computers 
			do not need Powershell at all. Use Get-Help Get-ComputerInfo -Examples  to see
			usage examples. Example 8 is my favorite!
		.PARAMETER ComputerName
			One or more computer names from which to get information. This can be a
			comma-separated list, or a file of computer names one per line. The alias
			of this parameter is -Computer.
		.PARAMETER MaxThreads
			Default is 4. This is the maximum number of threads that are allowed to
			run simultaneously. This is useful because network operations can block
			for a long time, making threading desirable. However, when using a very 
			large list of computers, spawning a huge number of concurrent threads can
			be detrimental to the system, so thread creation should be throttled.
			The max is 32. The alias for this parameter is -Threads.
		.INPUTS
			[String[]]$ComputerName
			This is an array of strings representing the hostnames of the computers
			for which you want to retrieve information. This can also be supplied by
			(Get-Content file.txt). This can be piped into Get-ComputerInfo.
		.OUTPUTS
			A collection of objects representing the information obtained from all
			the computers supplied to the cmdlet.
		.EXAMPLE
			Get-ComputerInfo server1,server2,server3
		.EXAMPLE
			Get-ComputerInfo -ComputerName server1,server2,server3 | Format-Table
		.EXAMPLE
			Get-ComputerInfo -ComputerName (Get-Content .\computers.txt) -MaxThreads 8
		.EXAMPLE
			,(Get-Content .\computers.txt) | Get-ComputerInfo -Threads 12
			
			(Please note the leading comma in this example.)
		.EXAMPLE
			,("server1","server2","server3") | Get-ComputerInfo
			
			(Please note the leading comma in this example.)
		.EXAMPLE
			$Computers = @("server1","server2","server3")
			,$Computers | Get-ComputerInfo
		
			(Please note the leading comma in this example.)
		.EXAMPLE
			"server1" | Get-ComputerInfo
		.EXAMPLE
			Get-ComputerInfo -ComputerName ($(Get-ADComputer -Filter *).Name) | Out-GridView
		.NOTES
			Scripting Games 2013 Advanced Event 2
	#>
	[CmdletBinding()]
	Param([Parameter(Mandatory = $True, ValueFromPipeline=$True, HelpMessage = 'Computer names to scan, e.g. server01,server02,server03')]
			[Alias('Computer')]
			[String[]]$ComputerName,
		  [Parameter(Mandatory = $False)]
			[Alias('Threads')]
			[ValidateRange(1, 32)]
			[Int]$MaxThreads = 4)
	
	# This is the collection of objects that this function will eventually return.
	$ComputerInfoCollection = @()
	
	# By using the unique job name of "GetComputerInfo", we avoid interfering with any other
	# unrelated jobs that might be running by coincidence.
	$JobName = "GetComputerInfo"
	
	# Clear any old jobs with the same name before we begin. -EA Stop ensures that errors will be caught.
	Try
	{
		Get-Job -Name $JobName -ErrorAction Stop | Remove-Job -Force
	}
	Catch
	{
		# No jobs with the name $JobName were running. We don't care.
	}
	
	# This is the work to be performed by each thread in a Start-Job command.
	$Work = {
		$ComputerInfo = [PSCustomObject]@{ Name = $Args[0]; IPAddresses = $null; OSCaption = $null; MegaBytesRAM = $null; CPUSockets = $null; TotalCores = $null; }
		Try
		{			
			$ComputerInfo.IPAddresses = $([System.Net.Dns]::GetHostEntry($Args[0])).AddressList
		}
		Catch
		{
			# The hostname did not resolve to an IP address, so there is no reason to keep going.
			$ComputerInfo.IPAddresses = "Could not resolve name!"
			Return $ComputerInfo
		}
		Try
		{
			$ComputerInfo.OSCaption = $(Get-WMIObject Win32_OperatingSystem -ComputerName $Args[0] -ErrorAction Stop).Caption
		}
		Catch
		{
			$ComputerInfo.OSCaption = "$($_.Exception.Message)"
		}
		Try
		{
			$ComputerInfo.MegaBytesRAM = [Math]::Round($(Get-WMIObject Win32_ComputerSystem -ComputerName $Args[0] -ErrorAction Stop).TotalPhysicalMemory / 1MB, 0)
		}
		Catch
		{
			$ComputerInfo.MegaBytesRAM = "$($_.Exception.Message)"
		}
		Try
		{
			$CPUInfo = Get-WMIObject Win32_Processor -ComputerName $Args[0] -ErrorAction Stop
			
            # SocketDesignation does not exist on Server 2000
            # $ComputerInfo.CPUSockets = $CPUInfo.SocketDesignation.Count
            # Also, Win 2000 does not care about Hyperthreading and does not distinguish
            # cores from sockets AFAIK, so TotalCores will be null if Win 2000. Not a big deal IMO.
            $ComputerInfo.CPUSockets = $CPUInfo.DeviceID.Count
			ForEach($CPU In $CPUInfo)
			{
				$Cores += $CPU.NumberOfCores
			}
			$ComputerInfo.TotalCores = $Cores
		}
		Catch
		{
			$ComputerInfo.CPUSockets = "$($_.Exception.Message)"
			$ComputerInfo.TotalCores = "$($_.Exception.Message)"
		}
		
		Return $ComputerInfo
	}
	
	ForEach($Computer In $ComputerName)
	{
		While($(Get-Job -State "Running" | Where-Object Name -EQ $JobName).Count -GE $MaxThreads)
		{
			# Max number of concurrent running threads reached - sleep until one is available.
			Start-Sleep -Milliseconds 500
		}
		Start-Job -Name $JobName -ScriptBlock $Work -ArgumentList $Computer | Out-Null
	}
	
	# Wait for all jobs to finish.
	# Get-Job -State "Running" -Name $JobName does not work for some reason, so let's do it in two steps.
	While(Get-Job -State "Running" | Where-Object Name -EQ $JobName)
	{
		Start-Sleep -Milliseconds 500
	}
	
	# Jobs are done, let's collect the results and store it in our collection.
	ForEach($Job In Get-Job -Name $JobName)
	{
		$ComputerInfoCollection += Receive-Job $Job
	}
	
	Return $ComputerInfoCollection
}