2013 PowerShell Scripting Games Advanced Event 6 – The Grand Finale

For me, the Scripting Games have been a great learning experience this year. I've used many PowerShell features that I hadn't used before such as splatting, ADSI, Workflows, and producing html webpages with PowerShell. I plan to write detailed followup blog articles on each of these topics over the next few months.

Event 6 was definitely challenging since I hadn't used workflows before but I also knew that's what was really needed to accomplish the given task properly (In my opinion). While you could accomplish the task without a workflow, the scripting games are about learning. I decided that I could maximize the return of investment on my time that I would invest in event 6 by using a workflow since I knew very little about them.

I start out by specifying the version of PowerShell and the module that is required. The #Requires -Version 3.0 statement will prevent this function from being dot-sourced on a machine with anything less than PowerShell version 3. Using a requires version statement is going to be even more important in the future as additional versions of PowerShell are released in the future. PowerShell version 4 was announced at Microsoft TechEd North America 2013 this week.

Specifying that the DHCP module is required prevents the function from being dot-sourced on a machine that does not have the DHCPServer module installed which is part of the Remote Server Administration Tools. The additional benefit to this is that the module will be automatically imported when the function is dot-sourced which keeps you from having to manually import it if the $PSModuleAutoLoadingPreference happens to be set to none. Some people dislike the module auto-load feature and set this preference variable to none in their profile so you can't assume that modules auto-load when sharing tools such as this one with others.

The help is collapsed in the image of the script, but providing comment based help is extremely important. For more information on this topic, run help about\_Comment\_Based_Help from PowerShell:

2013sg-e6h-png.png

I validate the format of the values provided for the MAC address parameter and return a meaningful error message if they are not provided in the proper format.

I don't validate the computers with the Test-Connection cmdlet because a Windows Server 2012 machine blocks ICMP (ping) requests by default which means it would fail to validate all of the computers we are trying to rename and add to the domain.

I provide parameters and defaults for all of the different items specified in the scenario requirements.

I obfuscate the passwords so they're not just in plain text in the script for anyone to see, although this is not a secure method of storing a password, and if someone runs that portion of the code, they will see the password in clear text.

2013sg-e6a-png.png

The BEGIN Block:

The first thing I do in the begin block is to check to see if PowerShell is running as an administrator. If not, the function ends and the user receives a message with instructions on how to run PowerShell as an administrator since PowerShell is unable to participate in User Access Control (UAC) and ask for elevated privileges.

You may ask why do I need to run PowerShell as an administrator? Well, to start with you shouldn't be managing servers by logging into the console or remote desktoping into them. That means that this tool will be run from a client computer and by default on Windows 8, the WinRM service is not running. In order to access the trusted host list, WinRM has to be running. I retrieve what's in the trusted host list and store it in a variable for use later. Specifying the Force parameter of the Get-Item cmdlet starts the WinRM service without prompting the user if it's not already running.

Another best practice is that you should never be logged into your computer or running PowerShell as a domain administrator. In order to run PowerShell as an admin, you'll have to specify admin credentials when running it, but those should have local admin rights and be a domain user, not a domain admin. Specify elevated credentials on an as needed, per command basis. Remember, use the principal of least privilege.

This means the user you're running PowerShell as won't have access to the DHCP server and in order to specify a credential parameter with the Get-DHCPServerv4Lease cmdlet, you'll need to create a CimSession. The other benefit to creating a CimSession is when the MAC addresses are piped in, we can retrieve all of the IP Addresses over the single connnection instead of having the overhead of setting up and tearing down ten different connections in this scenario.

I use a nested Try/Catch which attempts to create the CimSession using WSMAN and then attempts to use DCOM if it is unable to connect with WSMAN.

2013sg-e6b-png.png

The Workflow:

I nested the actual workflow into my begin block since it only needs to run once to load it into memory and then it can be called as many times as necessary, almost like dot-sourcing a function.

Comment based help is not supported in a workflow so I've added some inline help where applicable. I didn't use a workflow instead of a function for this entire process because the way I understood the scenario, you need to accept the MAC addresses via pipeline input and pipeline input is not supported in workflows. Advanced parameter validation is also not supported per the information I found on the Hey, Scripting Guy Blog! article that I read, and per what I read in the PowerShell in Depth book.

A brand new machine can be added to the domain and renamed in one line using the Add-Computer cmdlet, but there's an issue where if you revert the VM to a snapshot and delete the computer account from Active Directory, the rename portion fails with a "Directory service is busy" error, although it is added to the domain with the existing computer name:

2013sg-e6g-png.png

For this reason, I decided to rename the computer in one step, reboot, and then add it to the domain and reboot again because that process works reliably every time and I'll take reliability over a little performance any day. I see having to reboot twice as a non-issue since the entire process is automated.

I was at Microsoft TechEd this week and I actually had a chance to speak to members of the PowerShell team about the "Directory service is busy" issue. They confirmed that it is an issue and said they had previously run into the same problem. They stated that they had spoken to the Active Directory team about the issue and were told to do the rename and the add to the domain in two steps as I had done in my workflow.

I started out with a sequence block in my workflow and had a difficult time finding the definitive answer on whether it was needed or not, but I finally found the answer on page 818 of Lee Holmes's new Windows PowerShell Cookbook, 3rd edition book:

"Unlike the parallel statement, the lines within the braces of the parallel -foreach statement are treated as a unit and are invoked sequentially."

This means the sequence statement was not needed. While it wouldn't have necessarily hurt anything to have it in there, I don't like having unnecessary items in my code.

2013sg-e6c-png.png

Here's the part of the script I discussed earlier where it gives you a warning if you're not running PowerShell as an administrator:

2013sg-e6d-png.png

The PROCESS Block:

The objective of the process block in my function is to translate the MAC addresses to IP addresses by querying the DHCP server. If the MAC addresses are provided via parameter input, it only runs once and the command can translate all of them in one shot so a foreach loop is not needed, but if the MAC addresses are specified via pipeline input, the process block will run once per MAC address and retrieve them one at a time which is not a problem either based on the way the command in the process block is written:

2013sg-e6e-png.png

The END Block:

One unique thing about my function is it performs a good portion of the work in the End block. This is because I need these items to run one time and only one time regardless of whether the MAC addresses are provided via parameter or pipeline input and that wouldn't be the case if this portion of the function was added to the PROCESS block.

It starts out with a foreach loop to build a hash table of server names and IP addresses. While it's looping through each individual IP address, it also adds just the specific IP addresses to the trusted host list along with not overwriting what's already in it to prevent issues with other applications that may have already modified this setting. The individual IP's are added to maximize security and I think of it like adding holes in a firewall, you want the holes to be as small as possible to reduce the risk on a security incident. You should NEVER add "*****" to the trusted host list.

It's also not possible to modify the trusted hosts list unless PowerShell is running as an administrator. This is just one more reason your tool needs to validate that PowerShell is running as an administrator, otherwise you're only wasting time by processing the entire function to only fail in the workflow because the computers aren't trusted:

2013sg-e6i-png.png

At the very end, I put the trusted host back the way it was to start with as I don't want to leave unnecessary items in the trusted host list because it reduces security. We do however, want to put the trusted host list back the way it was to start with to prevent any issues with applications that may have previously modified this setting.

2013sg-e6f-png.png

Update 02/09/14:

Posting my solution here as well since I've received some requests for it:

  1#Requires -Version 3.0
  2#Requires -Modules DhcpServer
  3function Add-DomainComputer {
  4
  5<#
  6.SYNOPSIS
  7    Adds computers running Windows Server 2012 to a domain based on MAC Address for computers that receive their IP
  8address via DHCP.
  9
 10.DESCRIPTION
 11    Add-DomainComputer is a function that adds computers running Windows Server 2012 to a domain. PowerShell 3.0 is
 12required to run this tool.The Windows Server 2012 computer that you want to add to the domain must receive its IP
 13address via DHCP. This tool accepts the MAC address of the computer(s) you wish to add to the domain via pipeline
 14or paramter input. Those MAC addresses are translated to IP addresses using the Get-DhcpServerv4Lease function that
 15is part of the DhcpServer module which is installed as part of the Remote Server Administration Tools (RSAT):
 16http://www.microsoft.com/en-us/download/details.aspx?id=28972.
 17    The current list of trusted hosts for the machine running this tool is captured, and then all of the IP addresses
 18of the machines you are adding to the domain are added to the trustedhosts list. The last step once this tool
 19completes is to restore the trusted host list to the state it was in prior to this tool being run to prevent any
 20issues with machines that may have already existed in the trusted host list and to prevent the permenant reduction
 21of security for the machine running this tool.
 22    A workflow is used to add all of the machines to the domain in parallel.
 23
 24.PARAMETER MACAddress
 25    The Media Access Control Address of the network card in the Windows Server 2012 computer that is receiving its
 26IP address via a DHCP server that you're attempting to add to the domain. The value(s) for this parameter must be
 27specified in MAC-48 format which is six groups of two hexadecimal digits separated by dashes: 01-23-45-67-89-AB.
 28
 29.PARAMETER NewNameBase
 30    The base name for the new name to be used for the Servers. Numbers starting with 1 are appended to this base
 31name and used to rename the server when adding them to the domain. The default is "SERVER" which will cause the
 32servers to be named "SERVER1", "SERVER2", etc.
 33
 34.PARAMETER DHCPServer
 35    The name of the DHCP Server that is providing IP addresses to the servers you wish to add to the domain. The
 36default attempts to obtain the DHCP information from a server named "DHCP1".
 37
 38.PARAMETER DHCPScopeId
 39    The name of the DHCP Scope on the server specified via the DHCPServer parameter. The default attempts to obtain
 40DHCP information from a scope named "10.0.0.0".
 41
 42.PARAMETER DomainName
 43    The name of the domain that you are attempting to add the new servers to. The default is to attempt to add the
 44new servers to a domain named "Company.local".
 45
 46.PARAMETER LocalAdminCredential
 47    A local account on the servers that you're attempting to add to the domain that has local admin privileges. The
 48default obfuscates the password provided in the requirements for this scenario to prevent it from being displayed in
 49clear text, but this is not a secure method of storing a password.
 50
 51.PARAMETER DomainAdminCredential
 52    A domain account in the domain that you're attempting to add to the servers to that has domain admin privileges (As
 53specified in the scenario requirements). The default obfuscates the password provided in the requirements for this
 54scenario to prevent it from being displayed in clear text, but this is not a secure method of storing a password.
 55
 56.EXAMPLE
 57    Add-DomainComputer -MACAddress '01-23-45-67-89-AB', '01-23-45-67-89-A1'
 58
 59.EXAMPLE
 60    '01-23-45-67-89-AB', '01-23-45-67-89-A1' | Add-DomainComputer
 61
 62.EXAMPLE
 63    Get-Content .\server-macs.txt | Add-DomainComputer
 64
 65.EXAMPLE
 66    Get-Content .\server-macs.txt | Add-DomainComputer -BaseName 'SERVER'
 67
 68.EXAMPLE
 69    Get-Content .\server-macs.txt | Add-DomainComputer -BaseName 'SERVER' -DHCPServer 'DHCP1'
 70
 71.EXAMPLE
 72    Get-Content .\server-macs.txt | Add-DomainComputer -BaseName 'SERVER' -DHCPServer 'DHCP1' -DHCPScopeId '10.0.0.0'
 73
 74.EXAMPLE
 75    Get-Content .\server-macs.txt | Add-DomainComputer -BaseName 'SERVER' -DHCPServer 'DHCP1' -DHCPScopeId '10.0.0.0' -DomainName 'Company.local'
 76
 77.EXAMPLE
 78    Get-Content .\server-macs.txt | Add-DomainComputer -LocalAdminCredential (Get-Credential Administrator) -DomainAdminCredential (Get-Credential company\Admin)
 79
 80.INPUTS
 81    String
 82
 83.OUTPUTS
 84    None
 85#>
 86
 87    [CmdletBinding()]
 88    param (
 89        [Parameter(Mandatory,ValueFromPipeline)]
 90        [ValidateScript({
 91            If ($_ -match '^([0-9a-fA-F]{2}[:-]{0,1}){5}[0-9a-fA-F]{2}$') {
 92                $True
 93            }
 94            else {
 95                Throw "$_ is either not a valid MAC Address or was not specified in the required format. Please specify one or more MAC addresses in the follwing format: '01-23-45-67-89-AB'"
 96            }
 97        })]
 98        [string[]]$MACAddress,
 99
100        [ValidateNotNullorEmpty()]
101        [string]$NewNameBase = 'SERVER',
102
103        [ValidateNotNullorEmpty()]
104        [string]$DHCPServer = 'DHCP1',
105
106        [ValidateNotNullorEmpty()]
107        [string]$DHCPScopeId = '10.0.0.0',
108
109        [ValidateNotNullorEmpty()]
110        [string]$DomainName = 'Company.local',
111
112        [pscredential]$LocalAdminCredential = (
113            New-Object System.Management.Automation.PSCredential -ArgumentList administrator, (
114            -join ("5040737377307264" -split "(?<=\G.{2})",19 |
115            ForEach-Object {if ($_) {[char][int]"0x$_"}}) |
116            ConvertTo-SecureString -AsPlainText -Force)
117        ),
118
119        [pscredential]$DomainAdminCredential = (
120            New-Object System.Management.Automation.PSCredential -ArgumentList company\admin, (
121            -join ("5040737377307264" -split "(?<=\G.{2})",19 |
122            ForEach-Object {if ($_) {[char][int]"0x$_"}}) |
123            ConvertTo-SecureString -AsPlainText -Force)
124        )
125    )
126
127    BEGIN {
128        Write-Verbose -Message "Determining if PowerShell is running as an Admin. Terminating the tool execution and returning a message to the user if not."
129        if (([Security.Principal.WindowsPrincipal] [Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {
130
131            $IPNameMatrix = @{}
132
133            Write-Verbose -Message "Obtaining the list of currently trusted hosts, -Force will start the WinRM service if it in not already running without prompting."
134            $TrustedHost = Get-Item -Path WSMan:\localhost\Client\TrustedHosts -Force | Select-Object -ExpandProperty Value
135            Write-Verbose -Message "The Trusted Host list currently contains: $TrustedHost"
136
137            Write-Verbose -Message "Creating CimSession to DHCP Server using Domain Admin credential. Current user may not have access to the DHCP Server."
138            try {
139                Write-Verbose -Message "Attempting to create CimSession to the DHCP Server: $DHCPServer using WSMAN."
140                $CimSession = New-CimSession -ComputerName $DHCPServer -Credential $DomainAdminCredential -ErrorAction Stop
141            }
142            catch {
143                try {
144                    Write-Verbose -Message "WSMAN failed, attempting to create CimSession to the DHCP Server: $DHCPServer using DCOM."
145                    $Opt = New-CimSessionOption -Protocol Dcom
146                    $CimSession = New-CimSession -ComputerName $DHCPServer -SessionOption $Opt -Credential $DomainAdminCredential -ErrorAction Stop
147                }
148                catch {
149                    $Problem = $true
150                    Write-Warning -Message "Error creating CimSession to DHCP Server: $DHCPServer. Error Details: $_.Exception.Message"
151                }
152            }
153
154            Workflow Join-Domain {
155             #Comment based help and advanced parameter validation are not supported in workflows: http://blogs.technet.com/b/heyscriptingguy/archive/2013/01/02/powershell-workflows-restrictions.aspx
156                param(
157                    [hashtable]$IPNameMatrix,
158                    [string]$DomainName,
159                    [pscredential]$DomainAdminCredential
160                )
161                    foreach -parallel ($Computer in $PSComputerName) {
162
163                        #This could be done in a single command with a single reboot, but if the machine has ever been in the domain, even if it has been reverted and the computer account was removed
164                        #from AD, it will cause directory service busy errors on the rename so I chose to rename the computer in one command and add it to the domain in another which requires two
165                        #reboots. Here's the single command to do this: Add-Computer -DomainName $DomainName -NewName $IPNameMatrix[$Computer] -Credential $DomainAdminCredential -PSActionRetryCount 3
166
167                        Rename-Computer -NewName $IPNameMatrix[$Computer] -Force
168                        Restart-Computer -Wait -For WinRM -Protocol WSMan -Force
169
170                        Add-Computer -DomainName $DomainName -Credential $DomainAdminCredential -PSActionRetryCount 3
171                        Restart-Computer -Wait -For WinRM -Protocol WSMan -Force -PSCredential $DomainAdminCredential
172
173                        #Design Note: Unlike the parallel statement, the lines within the braces of the parallel -foreach statement are treated as a unit and are invoked sequentially
174                    }
175            }
176
177        }
178        else {
179            $Problem = $true
180            Write-Warning -Message "PowerShell must be run as an Administrator in order to use this tool. Right click PowerShell and select 'Run as Administrator' and then try again."
181        }
182    }
183
184    PROCESS {
185        if (-not($Problem)) {
186                try {
187                    Write-Verbose -Message "Attempting to translate the MAC addresses to IP addresses via the DHCP Server: $DHCPServer"
188                    [array]$IPAddress+= (Get-DhcpServerv4Lease -CimSession $CimSession -ScopeId $DHCPScopeId -ClientId $MACAddress -ErrorAction Stop |
189                    Select-Object -ExpandProperty IPAddress).IPAddressToString
190                }
191                catch {
192                    $Problem = $true
193                    Write-Warning -Message "Error translating MAC Addresses to IP Addresses. Error Details: $_.Exception.Message"
194                }
195        }
196    }
197
198    END {
199        if (-not($Problem)) {
200            foreach ($IP in $IPAddress) {
201                $i++
202
203                Write-Verbose -Message "Adding IPAddress: $IP and ServerName: $NewNameBase$i to the IPNameMatrix HashTable."
204                $IPNameMatrix.Add($IP, "$NewNameBase$i")
205                Write-Verbose -Message "IPNameMatrix now contains: $($IPNameMatrix.Count) items"
206
207                Write-Verbose -Message "Adding the IP Addresses to the local computers trusted hosts list."
208                Set-Item -Path WSMan:\localhost\Client\TrustedHosts -Value $IP.ToString() -Concatenate -Force
209            }
210
211            $Params = @{
212                PSComputerName = $($IPNameMatrix.Keys)
213                IPNameMatrix = $IPNameMatrix
214                DomainName = $DomainName
215                DomainAdminCredential = $DomainAdminCredential
216                PSCredential = $LocalAdminCredential
217            }
218
219            try {
220                Write-Verbose -Message "Calling the Join-Domain Workflow."
221                Join-Domain -PSParameterCollection $Params
222
223                Write-Verbose -Message "Restoring the list of trusted hosts to the state that it was in prior to running this tool to prevent a reduction in system security."
224                $TrustedHost | ForEach-Object {Set-Item -Path WSMan:\localhost\Client\TrustedHosts -Value $_.ToString() -Force}
225            }
226            catch {
227                Write-Warning -Message "Please contact your system administrator. Reference error: $_.Exception.Message"
228            }
229
230            Write-Verbose -Message "Cleanup: Removing the single CimSession that was created by this tool since it is no longer needed."
231            Remove-CimSession -CimSession $CimSession -ErrorAction SilentlyContinue
232        }
233    }
234}

µ