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:
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.
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.
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:
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.
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:
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:
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:
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.
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}
µ