Building a Lab in Hyper-V with PowerShell, Part 4

Creating a new forest

In the previous sections of this series, I've covered how to build VMs using PowerShell, but labs aren't much good if they don't actually have any structure. So, let's create a new forest and domain to manage our labs. I'm going to assume for this post that you've gotten started already and created a new Windows Server 2012R2 or Windows Server 2016 virtual machine. For this, it can be a graphical install or a Server Core installation and either Server Standard or Datacenter. Since we're going to be using only PowerShell to create the forest, there's no need for a GUI.

The things we'll need to have identified before we start are:

  • Server IP address
  • Server name
  • DNS namespace for the root domain of the forest
  • Domain name for the root domain of the forest
  • DNS Server type (AD-integrated or standalone)

Set Server IP Address

We need set our server to a fixed IP address. While not absolutely required, I think it's a really bad idea to not do this. And, since our lab doesn't yet have DHCP in it, you need to anyway. (We'll add a DHCP server in the next installment. )

To configure the network adapter for a static IP address, I need to know either the interface alias (name) or the interface index. To get those, use Get-NetAdapter from a PowerShell window. (Note: if you're doing this on a new Windows Server Core installation, you can open a PowerShell window with Start PowerShell.exe at the command prompt. To start a PowerShell window automatically for this user, at logon, see my May post. )

Get-NetAdapter | Format-Table -AutoSize Name,Status,IFIndex,MacAddress

Name       Status ifIndex MacAddress
----       ------ ------- ----------
Ethernet 2 Up           3 00-15-5D-32-0A-02
Ethernet   Up           5 00-15-5D-32-CE-02

Which tells us that the DC has two network adapters, and the one that is on the Local-10 switch (from New-myVM.ps1) is at an ifIndex of 3, while the one on the "199 Network" switch has an ifIndex of 5. Now, we'll set the static IP addresses for these two adapters. First, the NIC on Local-10:

# Set IPv4
$NIC2 = Get-NetAdapter -ifIndex 3
$NIC2 | Set-NetIPInterface -DHCP Disabled
$NIC2 | New-NetIPAddress -AddressFamily  IPv4 `
                         -IPAddress      192.168.10.2 `
                         -PrefixLength   24 `
                         -Type Unicast `
                         -DefaultGateway 192.168.10.1
# Set IPv6
$NIC2 | New-NetIPAddress -AddressFamily  IPv6 `
                         -IPAddress      2001:db8:0:10::2 `
                         -PrefixLength   64 `
                         -Type Unicast `
                         -DefaultGateway 2001:db8:0:10::1

# Set DNS Server Addresses to self
Set-DnsClientServerAddress -InterfaceIndex  $NIC2.ifIndex `
                           -ServerAddresses 192.168.10.2,2001:db8:0:10::2

#Now, for the 199 Network, which I use for internal communications between lab hosts, I want to set a pure IPv4 address with no IPv6, so instead of setting an IPv6 address for the NIC, I'll disable it with Disable-NetAdapterBinding.

$NIC = Get-NetAdapter -ifIndex 5

# Disable IPv6
Disable-NetAdapterBinding -Name $NIC.Name -ComponentID ms_tcpip6

# Set IPv4 to 192.168.199.2
$NIC | Set-NetIPInterface -Dhcp Disabled
$NIC | New-NetIPAddress -AddressFamily IPv4 `
                        -IPAddress     192.168.199.2 `
                        -PrefixLength  24 `
                        -Type Unicast
# Set DNS to self
Set-DnsClientServerAddress -InterfaceIndex  $NIC.ifIndex `
                           -ServerAddresses 192.168.199.2

(Note: Set-NetAdapterBinding is not available on Windows 7/Server 2008 R2)

 

Set Server Name

Next, let's set the name of the server to match our naming conventions for this lab. We do this now, knowing it will force a reboot before we go any further.

Rename-Computer -NewName trey-dc-02 -Restart -Force

This will give the computer a new name and restart it.

 

Create Forest and Install AD-integrated DNS

Now that we have static IP addresses for our network adapters, and we've set the name of the server, we can go ahead and create our AD forest. First, we install Active Directory and update the PowerShell Help files with:

Install-WindowsFeature -Name AD-Domain-Services -IncludeManagementTools
Update-Help -SourcePath \\labhost\PSHelp

This installs the ActiveDirectory and ADDSDeployment modules that we'll need to create the forest. Now, we promote the server to be the first domain controller in the new forest. Before we do the actual install, we test to make sure we don't have any issues with Test-ADDSForestInstallation:

Test-ADDSForestInstallation `
         -DomainName 'TreyResearch.net' `
         -DomainNetBiosName 'TREYRESEARCH' `
         -DomainMode 6 `
         -ForestMode 6 `
         -NoDnsOnNetwork `
         -SafeModeAdministratorPassword (ConvertTo-SecureString `
                                                  -String 'P@ssw0rd' `
                                                  -AsPlainText `
                                                  -Force) `
         -NoRebootOnCompletion

Even though this is a brand new forest in an isolated lab setting, it's still a good practice to test before you actually deploy. And it doesn't cost all that much time or annoyance. I've included the SafeModeAdministratorPassword parameter to avoid the prompts for it. This is a lab, not real life. :) Also note that we're setting the forest and domain modes to Server2012R2. If you need earlier versions of domain controllers in your lab, you can set the mode accordingly.

The results of the test are as expected:

WARNING: Windows Server 2016 domain controllers have a default for the security setting named "Allow cryptography
algorithms compatible with Windows NT 4.0" that prevents weaker cryptography algorithms when establishing security
channel sessions.

For more information about this setting, see Knowledge Base article 942564
(http://go.microsoft.com/fwlink/?LinkId=104751).

WARNING: A delegation for this DNS server cannot be created because the authoritative parent zone cannot be found or it
 does not run Windows DNS server. If you are integrating with an existing DNS infrastructure, you should manually
create a delegation to this DNS server in the parent zone to ensure reliable name resolution from outside the domain
"TreyResearch.net". Otherwise, no action is required.


Message                          Context                                  RebootRequired  Status
-------                          -------                                  --------------  ------
Operation completed successfully Test.VerifyDcPromoCore.DCPromo.General.3          False Success

With that confirmation, we can go ahead and finish creating the forest and configuring DNS with the command:

Install-ADDSForest `
    -DomainName 'TreyResearch.net' `
    -DomainNetBiosName 'TREYRESEARCH' `
    -DomainMode 6 `
    -ForestMode 6 `
    -NoDnsOnNetwork `
    -SkipPreChecks `
    -SafeModeAdministratorPassword (ConvertTo-SecureString `
                                                  -String 'P@ssw0rd' `
                                                  -AsPlainText `
                                                  -Force) `
    -Force

You'll notice that the options here match our test pass, except I chose to bypass a second test. If you want to keep your SafeMode Administrator password private you can eliminate that parameter and you'll be prompted at the command line. When this finishes and the server has rebooted, you can log in with the TREYRESEARCH\Administrator account and the local Administrator password you had before you promoted the VM to be a domain controller.  This may or may not be the same as the SafeModeAdministratorPassword you set during the installation.

Nested Hyper-V Networking

As I was trying to configure a new lab setup that takes advantage of nested Hyper-V so that I can build a lab to do Hyper-V host clustering, I ran into a problem with networking. Everything looked good on the "host1" virtual machine, but the domain controller I created for TreyResearch.net that runs as a nested VM on host1 couldn't connect to anything outside of host1. Which would end up being a pain fairly quickly. But after a good bit of poking around, I found the solution - either enable MAC Address Spoofing on host1, or configure a NAT switch on host1. For most of us, the MAC Address Spoofing is the simplest solution and works just fine. But if you're in a public cloud scenario, you'll likely have to go the NAT route.

To enable Nested Hyper-V, shutdown host1 and then run the following command on the top level host:

Set-VMProcessor -VMName host1 -ExposeVirtualizationExtensions $True

Start host1 and install the Hyper-V role with:

Install-WindowsFeature -Name Hyper-V -IncludeAllSubFeature -IncludeManagementTools

Once the reboots finish on host1, enable MAC Address Spoofing on the network adapter(s) of  host1:

Get-VMNetworkAdapter -VMName host1 | Set-VMNetworkAdapter -MacAddressSpoofing On

And you're done.

Building a Lab in Hyper-V with PowerShell, Part 3

Creating VMs with New-myVM.ps1 - Part 2

So, as I showed in the previous post, I've got my new VM built, but it's not really ready for use yet. For one thing, it needs a DVD attached and the boot order set, plus I want to add a second NIC, and change the number of processors assigned to it. First, setting up the memory, processors, static MAC address for the NIC and configuring the DVD if we're booting from DVD. (Which, I admit, I don't often do. Mostly I copy over a SysPrep'd VHDX file.)

To do this, I have a function, of course, called Set-myVMConfig, to do most of it, and a separate one that I use to configure the second NIC, Add-myNetAdapter

Function Set-myVMConfig {
   Write-Verbose "Setting Processor Count to 4 for $VMName"
   Set-VMProcessor      -VMName $VMName -Count 4
   Write-Verbose "Enabling Dynamic Memory"
   Set-VMMemory         -VMName $VMName -DynamicMemoryEnabled $True
   Write-Verbose "Assigning static MAC address of $MacAdd"
   Get-VMNetworkAdapter -VMName $VmName `
      | Set-VMNetworkAdapter -StaticMacAddress "$MacAdd"
   if ($DVD) {
      Write-Verbose "Building from DVD, so adding DVD drive, and configuring boot order"
      if (! $client) { 
         Add-VMDvdDrive -VMName $VmName
         Set-VMDvdDrive -VMName $VmName  -Path $ServerISO 
         $vmDVD     = Get-VMDvdDrive -VMName $VmName
         $vmDrive   = Get-VMHardDiskDrive -VMName $VmName 
         Set-VMFirmware -VMName $VmName  -FirstBootDevice $vmDVD 
         Set-VMFirmware -VMName $VmName -BootOrder $vmDVD,$vmDrive
      } else {
         Add-VMDvdDrive -VMName $VmName
         Set-VMDvdDrive -VMName $VmName -Path $ClientISO 
         $vmDVD     = Get-VMDvdDrive -VMName $VmName
         $vmDrive   = Get-VMHardDiskDrive -VMName $VmName 
         Set-VMFirmware -VMName $VmName   -FirstBootDevice $vmDVD 
         Set-VMFirmware -VMName $VmName   -BootOrder $vmDVD,$vmDrive
      }
   }
}

This sets the # of processors to 4, enables dynamic memory, sets a static MAC address on the first NIC, adds a DVD drive if appropriate, and sets the boot order to boot from the specified ISO file.

Almost done - now, all we need to do is add a second network adapter, and set it to a fixed MAC address as well.

Function Add-myNetAdapter {
   Write-Verbose "Adding second network adapter"
   Add-VmNetworkAdapter -VMName $VmName `
                        -SwitchName '199 Network' `
                        -StaticMacAddress "$199MacAdd" `
                        -Name '199 Ethernet'
}

Now, that we have all the functions, all we need to do is execute them, and that all happens with:

If (! ( Get-VM -Name $VmName -ErrorAction Continue 2>$NULL) ) {
   Test-SourcePath
   Test-Clean
   Copy-myVHD -wait
   Write-Verbose "VHD's copied if we were doing that, now creating the VM..."
   Create-myVM
   Write-Verbose "VM Created"
   $myVM = Get-VM -VMName $VMName
}
Set-myVMConfig
Add-myNetAdapter
$myVM | Format-List

And, since this whole thing has been broken up across a couple of posts, here's the whole script, including full Get-Help support.

New-myVM.ps1

<# 
.Synopsis
    Creates a new VM
.Description
    New-myVM and New-myClientVM make a new VM of Name $1 and MAC Address in the $MacBase 
    range of MAC addresses. If the command is run as New-myClientVM, then the -Client 
    parameter is assumed unless overridden at the command line. 
.Example 
   New-myVM -VMName Trey-DC-02 02
   Creates a new Server VM of name "Trey-DC-02" in the default MAC address range
   with the "02" as the final octet of MAC address. 
.Example 
   New-myVM -Name trey-client-22 -MacFinal 16 -DVD -Client $True
   Creates a new Client VM of name "trey-client-22" in the default MAC address range
   with 16 as the final octet of MAC address. The VM is installed from the default 
   Server 2016 DVD. 
.Example 
   New-myVM Trey-DC-02 02 -DVD
   Creates a new Server VM of name "Trey-DC-02" in the default MAC address range
   with the "02" as the final octet of MAC address. The VM is installed from the 
   default Server 2016 DVD. 
.Example
    New-myVM -VmName "Trey-WDS-11" -MacFinal "0B" -MacBase "00-15-5D-32-64-"
    Creates a new Server VM of name Trey-WDS-11 in a non-default MAC address range. 
.Example
   New-myClientVM -Name trey-client-01 
   Creates a new Windows 10 client VM, 'trey-client-01' using the default VHD, and 
   will prompt for the final 2 digits of the MAC address. 
.Example
   New-myVM -Name trey-client-01 -MACFinal 65 -Client $True -Source 'V:\Source' -Path 'Y:\'
   Creates a new Windows 10 client VM, 'trey-client-01' using the sysprep'd image at V:\Source, 
   and creating the VM at Y:\trey-client-01. The final two digits of the MAC address will 
   be 65. 
.Parameter VmName
   The name of the new VM
.Parameter MacFinal
   The last two digits in the MAC address of the VM
.Parameter MacBase
   The base MAC address for this VM. The default base is  "00-15-5D-32-10-"  
.Parameter DVD
   A switch that controls whether a DVD is added to the VM and used to mount an ISO for the 
   install. The default is to build the VM with no DVD drive. 
.Parameter Client
  A Boolean. When run as New-myVM, $Client defaults to False. If run as New-myClientVM, 
  the default is true. In either case, the command line parameter overrides the default. 
.Parameter Path
   The target path for the virtual machine. Default is to V:\. This is the base path, to 
   which the VMName is added to build the final path.
.Parameter Source
   The source path of the DVD or VHD used to build the virtual machine. Default is V:\Source. 
.Parameter vmSwitch
   The Hyper-V network switch to connect the VM to. New-myVM creates two network adapters. 
   One is connected to the 199 Network, and the second is controlled by the vmSwitch parameter. 
   The default is "Local-10", the internal lab switch. 
.Parameter 2012R2
   The 2012R2 switch specifies the use of the Server 2012 R2 image. 
.Inputs 
    [string]
    [string]
    [string]
    [switch]
    [Boolean]
    [string]
    [string]
    [string]
.Notes
    Author: Charlie Russel
 Copyright: 2017 by Charlie Russel
          : Permission to use is granted but attribution is appreciated
   ModHist: 1/1/2014 Initial
          : 1/31/2015 Mod for new parameter handling and comment header
          : 3/20/2015 Mod to use Sysprepped VHD and -10 MAC
          : 4/19/2015 Mod for verbose and running in a wrapper
          : 5/16/2016 Mod for new labhost 
          : 9/24/2016 Mod for New-myClientVM
          : 12/21/2016 Added additional parameters, updated help. (cpr)
          : 02/17/2017 Fixed problem with DVD and Gen2, updated help. (cpr)
#>

[CmdletBinding()]
Param ([Parameter(Mandatory = $True,Position = 0)][alias("Name")][string]$VmName,
       [Parameter(Mandatory = $True,Position = 1)][alias("Final")][string]$MacFinal,
       [Parameter(Mandatory = $False)][alias("Base")][string]$MacBase = "00-15-5D-32-0A-",
       [Parameter(Mandatory = $False)][string]$199MacBase = "00-15-5D-32-CE-",
       [Parameter(Mandatory=$False)][Switch]$DVD,
       [Parameter(Mandatory=$False)][Boolean]$Client=($myInvocation.myCommand.Name -match "Client"),
       [Parameter(Mandatory=$False)][alias("Target")][string]$Path = "V:",
       [Parameter(Mandatory=$False)][alias("VHDSource","DVDBase")][string]$Source = "V:\Source",
       [Parameter(Mandatory=$False)][alias("LocalSwitch","Network")][string]$vmSwitch = "Local-10",
       [Parameter(Mandatory=$False)][switch]$2012R2
       )

$MacAdd = $MacBase + $MacFinal
$199MacAdd = $199MacBase + $MacFinal
Write-Verbose "MacFinal is $MacFinal" 
Write-Verbose "MacAdd is $MacAdd on switch $vmSwitch" 
Write-Verbose "VMName is $VMName"
Write-Verbose "Client is $Client"
Write-Verbose "Path is $Path, Source is $Source, and 199 MAC address is $199MacBase + $MacFinal"
Write-Verbose "Sleeping for 5 seconds to give you a chance to exit..."
sleep 5

$VMBase     = "$Path\$VMName"
$VHDSource  = $Source
$DVDBase    = $Source
$VHDBase    = "$VMBase\Virtual Hard Disks"
$SysVHD     = "$VMBase\Virtual Hard Disks\$VmName-System.vhdx"
$MachineBase= "$VMBase\Virtual Machines"
$ServerISO  = "$DVDBase\en_windows_server_2016_x64_dvd_9718492.iso"
$ClientISO  = "$DVDBase\en_windows_10_enterprise_version_1607_updated_jan_2017_x64_dvd_9714415.iso"
$ClientVHD  = "$Source\Generalized-client.vhdx"
if ($2012R2) { 
   $ServerVHD = "$Source\Generalized-2012R2.vhdx"
} else {
   $ServerVHD  = "$Source\Generalized-System.vhdx"
}

Function Test-SourcePath () {
   if ($Client) {
      if ($dvd) {
         if (Test-Path $ClientISO) {
            Write-Verbose "Install DVD found at $ClientISO"
         } else {
            Throw "Client ISO not found at $ClientISO" 
         }
      } elseif (Test-Path $ClientVHD) { 
         Write-Verbose "Source VHD found at $ClientVHD"
      }
   } else {
      if ($dvd) {
         if (Test-Path $ServerISO) {
            Write-Verbose "Install DVD found at $ServerISO"
         } else {
            Throw "Server ISO not found at $ServerISO" 
         }
      } elseif (Test-Path $ServerVHD) { 
         Write-Verbose "Source VHD found at $ServerVHD"
      }
   }
}

if (! (Test-Path $VHDBase ) ) { 
   mkdir $VHDBase
}
if (! (Test-Path $MachineBase ) ) { 
   mkdir $MachineBase
}

Function Test-Clean () {
   If (Test-Path $VHDBase\*.vhdx ) {
      Throw "Found an existing VHD. Please clean up the target path and try again."
   }
}

function Copy-myVhd () {
      if ( $DVD ) {
         Write-Verbose "DVD specified. Not copying source VHD to $SysVHD"
      } else { 
         if ( $Client ) { 
            Write-Verbose "Creating VM from Sysprep'd VHD base $ClientVHD"
            cp $ClientVHD $SysVHD 
         } else { 
         Write-Verbose "Creating VM from Sysprep'd VHD base $ServerVHD"
            cp $ServerVHD $SysVHD
         } 
      }
}

function Create-myVM () { 
if ($DVD ) { 
  Write-Verbose "Creating $vmname from DVD with the following command:"
  Write-Verbose "New-VM -Name $VmName -MemoryStartupBytes 1024MB -BootDevice VHD -Generation 2 -SwitchName $vmSwitch -NewVHDPath $SysVHD -NewVHDSize 200GB -Path $MachineBase "
  Sleep 3
  New-VM -Name $VmName `
       -MemoryStartupBytes 1024MB `
       -BootDevice VHD `
       -Generation 2 `
       -SwitchName $vmSwitch `
       -NewVHDPath $SysVHD `
       -NewVHDSize 200GB `
       -Path $MachineBase
} else { 
  New-VM -Name $VmName `
       -MemoryStartupBytes 1024MB `
       -BootDevice VHD `
       -Generation 2 `
       -SwitchName $vmSwitch `
       -VHDPath $SysVHD `
       -Path $MachineBase
  }
}

Function Set-myVMConfig {
   Write-Verbose "Setting Processor Count to 4 for $VMName"
   Set-VMProcessor      -VMName $VMName -Count 4
   Write-Verbose "Enabling Dynamic Memory"
   Set-VMMemory         -VMName $VMName -DynamicMemoryEnabled $True
   Write-Verbose "Assigning static MAC address of $MacAdd"
   Get-VMNetworkAdapter -VMName $VmName `
      | Set-VMNetworkAdapter -StaticMacAddress "$MacAdd"
   if ($DVD) {
      Write-Verbose "Building from DVD, so adding DVD drive, and configuring boot order"
      if (! $client) { 
         Add-VMDvdDrive -VMName $VmName
         Set-VMDvdDrive -VMName $VmName  -Path $ServerISO 
         $vmDVD     = Get-VMDvdDrive -VMName $VmName
         $vmDrive   = Get-VMHardDiskDrive -VMName $VmName 
         Set-VMFirmware -VMName $VmName  -FirstBootDevice $vmDVD 
         Set-VMFirmware -VMName $VmName -BootOrder $vmDVD,$vmDrive
      } else {
         Add-VMDvdDrive -VMName $VmName
         Set-VMDvdDrive -VMName $VmName -Path $ClientISO 
         $vmDVD     = Get-VMDvdDrive -VMName $VmName
         $vmDrive   = Get-VMHardDiskDrive -VMName $VmName 
         Set-VMFirmware -VMName $VmName   -FirstBootDevice $vmDVD 
         Set-VMFirmware -VMName $VmName   -BootOrder $vmDVD,$vmDrive
      }
   }
}

Function Add-myNetAdapter {
   Write-Verbose "Adding second network adapter"
   Add-VmNetworkAdapter -VMName $VmName `
                        -SwitchName "199 Network" `
                        -StaticMacAddress "$199MacAdd" `
                        -Name "199 Ethernet"
}

If (! ( Get-VM -Name $VmName -ErrorAction Continue 2>$NULL) ) {
   Test-SourcePath
   Test-Clean
   Copy-myVHD -wait
   Write-Verbose "VHD's copied if we were doing that, now creating the VM..."
   Create-myVM
   Write-Verbose "VM Created"
   $myVM = Get-VM -VMName $VMName
}
Set-myVMConfig $myVM
Add-myNetAdapter $myVM
$myVM | Format-List

I hope you find this script useful, and I'd love to hear comments, suggestions for improvements, or bug reports as appropriate. As always, if you use this script as the basis for your own work, please respect my copyright and provide appropriate attribution.

 

Next up in the Building a Lab with PowerShell series will how to configure your DHCP server with PowerShell. This will take advantage of the fixed MAC addresses I create for all my lab machines and use these to populate DHCP Reservations.

Building a Lab in Hyper-V with PowerShell, Part 2

Creating VMs with New-myVM.ps1 - Part 1

As we saw in Part 1 of this series, I build my labs almost entirely with Windows PowerShell scripts. In that first post, I showed how to set the MAC address range on a Hyper-V host. I use this MAC address range to explicitly set my lab VMs to a specific MAC address for their NICs. This allows me to pre-configure all the IP addresses in DHCP by using reservations. (I showed you how to install and preconfigure the DHCP Server role in my post on Configuring Windows Server 2016 core as a DHCP Server with PowerShell. And I'll show you how to create all those reservations automatically in a later post in this series.)

For this post, and the couple, I want to share my "New-myVM.ps1" script. I use this script to create client and server VMs, from either DVD or sysprep'd VHDX files.

First, the parameters that New-myVM accepts:

[CmdletBinding()]
Param ([Parameter(Mandatory = $True,Position = 0)][alias("Name")][string]$VmName,
       [Parameter(Mandatory = $True,Position = 1)][alias("Final")][string]$MacFinal,
       [Parameter(Mandatory = $False)][alias("Base")][string]$MacBase = "00-15-5D-C8-0A-",
       [Parameter(Mandatory = $False)][string]$199MacBase = "00-15-5D-C8-CE-",
       [Parameter(Mandatory=$False)][alias("ISO")][Switch]$DVD,
       [Parameter(Mandatory=$False)][Boolean]$Client=($myInvocation.myCommand.Name -match "Client"),
       [Parameter(Mandatory=$False)][alias("Target")][string]$Path = "V:\",
       [Parameter(Mandatory=$False)][alias("VHDSource","DVDBase")][string]$Source = "V:\Source",
       [Parameter(Mandatory=$False)][alias("LocalSwitch","Network")][string]$vmSwitch = "10 Network",
       [Parameter(Mandatory=$False)][switch]$2012R2
       )

I have only two required parameters, the VMName and final two digits of the MAC addresses that the VM will use. Everything else defaults to some reasonable value for my labs. You'll notice that by default, I don't install from DVD, and I'm not installing Server 2012R2, but Server 2016. And, finally, a bit you might not have seen before, my default to determine if this is a client build or a server build.

[Parameter(Mandatory=$False)][Boolean]$Client=($myInvocation.myCommand.Name -match "Client")

The $Client parameter is a Boolean. I can specify it on the command line with -Client $True/$False, or I can allow it to accept the default value. The interesting thing is that the default value is dynamic. I use an NTFS hard link for New-myVM.ps1 to give it a second name, New-myClientVM.ps1.


Sidebar: NTFS Hard Links 

NTFS filesystem hard links take a single file and give it multiple names. The file is stored only once on the filesystem, but it has multiple names that can access the file. Each name for the file is completely equal. Even if you delete the original filename, all the linked versions are still present and completely unaffected by the deletion. You create a hard link in Server 2016 or Windows 10 with New-Item or in earlier versions of Windows with the built-in CMD command mklink. So, for example:

New-Item -Type HardLink `
         -Path 'C:\Build\New-myClientVM.ps1','C:\Build\New-myServerVM.ps1' `
         -Value 'C:\Build\New-myVM.ps1'

#Older OS Version:
cmd /c mklink /h C:\Build\New-myClientVM.ps1 C:\Build\New-myVM.ps1
cmd /c mklink /h C:\Build\New-myServerVM.ps1 C:\Build\New-myVM.ps1

I then use ($myInvocation.myCommand.Name -match "Client") to see if the filename I started the script with was New-myClientVM.ps1 , or one of the other names I have for this script.  ($myInvocation.myCommand.Name -match "Client") returns $True for New-myClientVM.ps1, but $False for filenames that don't include 'Client' in the filename.

 

Next, I set some basic variables used throughout the script. These are periodically tweaked as new builds become my default. Currently, they're set at:

$VMBase     = "$Path\$VMName"
$VHDSource  = $Source
$DVDBase    = $Source
$VHDBase    = "$VMBase\Virtual Hard Disks"
$SysVHD     = "$VMBase\Virtual Hard Disks\$VmName-System.vhdx"
$MachineBase= "$VMBase\Virtual Machines"
$ServerISO  = "$DVDBase\en_windows_server_2016_x64_dvd_9718492.iso"
$ClientISO  = "$DVDBase\en_windows_10_enterprise_version_1607_updated_jan_2017_x64_dvd_9714415.iso"
$ClientVHD  = "$Source\Generalized-client.vhdx"
if ($2012R2) { 
   $ServerVHD = "$Source\Generalized-2012R2.vhdx"
} else {
   $ServerVHD  = "$Source\Generalized-System.vhdx"
}

Nothing special there. Next, three functions to verify paths, etc. These are pretty basic Test-Path statements. If I need to create directories, I do. But if I don't find my source files where I expect them, or I find an already existing .vhdx where I'm not expecting one, then I use simple Throw statements to get me out and report the problem, since either of these failures will cause the script to fail, usually in an ugly way. ;)

Function Test-SourcePath () {
   if ($Client) {
      if ($dvd) {
         if (Test-Path $ClientISO) {
            Write-Verbose "Install ISO found at $ClientISO"
         } else {
            Throw "Client ISO not found at $ClientISO" 
         }
      } elseif (Test-Path $ClientVHD) { 
         Write-Verbose "Source VHD found at $ClientVHD"
      }
   } else {
      if ($dvd) {
         if (Test-Path $ServerISO) {
            Write-Verbose "Install ISO found at $ServerISO"
         } else {
            Throw "Server ISO not found at $ServerISO" 
         }
      } elseif (Test-Path $ServerVHD) { 
         Write-Verbose "Source VHD found at $ServerVHD"
      }
   }
}

if (! (Test-Path $VHDBase ) ) { 
   mkdir $VHDBase
}
if (! (Test-Path $MachineBase ) ) { 
   mkdir $MachineBase
}

Function Test-Clean () {
   If (Test-Path $VHDBase\*.vhdx ) {
      Throw "Found an existing VHD. Please clean up the target path and try again."
   }
}

You'll notice with these functions, and the ones that follow, everything builds on those original variables created at the top of the script, or as part of the script parameters. Yes, I need to keep those up to date. But there's only one place to make the changes.

Now, assuming I'm most likely going to be building from a sysprep'd VHD, I  copy that .vhdx file into my "Virtual Hard Disks" folder for this VM, changing the name as I do to reflect the new VM's name.

function Copy-myVhd () {
      if ( $DVD ) {
         Write-Verbose "DVD specified. Not copying source VHD to $SysVHD"
      } else { 
         if ( $Client ) { 
            Write-Verbose "Creating VM from Sysprep'd VHD base $ClientVHD"
            cp $ClientVHD $SysVHD 
         } else { 
         Write-Verbose "Creating VM from Sysprep'd VHD base $ServerVHD"
            cp $ServerVHD $SysVHD
         } 
      }
}

Now that we have all that setup work done, let's actually create the VM. We have to do this in two separate steps because Hyper-V doesn't give us a way to set the number of CPUs or configure some other stuff as part of the initial creation of the VM. We have to modify the VM after we first create it. Silly, but not all that hard to deal with in a script, though a real nuisance at the interactive command line.

Create-myVM {
if ($DVD ) { 
  New-VM -Name $VmName `
       -MemoryStartupBytes 1024MB `
       -BootDevice VHD `
       -Generation 2 `
       -SwitchName $vmSwitch `
       -NewVHDPath $SysVHD `
       -NewVHDSize 200GB `
       -Path $MachineBase
} else { 
  New-VM -Name $VmName `
       -MemoryStartupBytes 1024MB `
       -BootDevice VHD `
       -Generation 2 `
       -SwitchName $vmSwitch `
       -VHDPath $SysVHD `
       -Path $MachineBase
  }
}

And, we now have a VM. It's not quite what we want and need, yet, but we have a VM. One problem, I can't set the boot device to DVD, regardless of what the PowerShell help pages say, because I'm building Generation 2 virtual machines, and they don't allow you to specify 'CD' as the boot device during initial setup. So, we'll have to configure that, along with the other tweaks we need, as part of the next stage of the whole process. And for that, you'll have to wait until tomorrow, when I do Part 2 of New-myVM. :)

Configuring Windows Server 2016 core as a DHCP Server with PowerShell

As I mentioned last time, I'm setting up a new domain controller and DHCP server for my internal domain on Windows Server 2016 Core, and I'm exclusively using PowerShell to do it. For both the DHCP Server and AD DS roles, we need to configure a fixed IP address on the server, so let's do that first. From my Deploying and Managing Active Directory with Windows PowerShell book from Microsoft Press, here's my little very quick and dirty script to set a fixed IP address:

# Quick and dirty IP address setter

[CmdletBinding()]
Param ([Parameter(Mandatory=$True)][string]$IP4,
       [Parameter(Mandatory=$True)][string]$IP6 
      )
$Network = "192.168.10."
$Network6 = "2001:db8:0:10::"
$IPv4 = $Network + "$IP4"
$IPv6 = $Network6 + "$IP6"
$Gateway4 = $Network + "1"
$Gateway6 = $Network6 + "1"

Write-Verbose "$network,$network6,$IP4,$IP6,$IPv4,$IPv6,$gateway4, $gateway6"

$Nic = Get-NetAdapter -name Ethernet
$Nic | Set-NetIPInterface -DHCP Disabled
$Nic | New-NetIPAddress -AddressFamily IPv4 `
                        -IPAddress $IPv4 `
                        -PrefixLength 24 `
                        -type Unicast `
                        -DefaultGateway $Gateway4
Set-DnsClientServerAddress -InterfaceAlias $Nic.Name `
                           -ServerAddresses 192.168.10.2,2001:db8:0:10::2
$Nic |  New-NetIPAddress -AddressFamily IPv6 `
                         -IPAddress $IPv6 `
                         -PrefixLength 64 `
                         -type Unicast `
                          -DefaultGateway $Gateway6

ipconfig /all

I warned you it was a quick and dirty script. But let's quickly look at what it does. First, we get the network adapter into a variable, $Nic. Then we turn off DHCP with Set-NetIPInterface, and configure the IPv4 and IPv6 addresses with New-NetIPAddress. Finally, we use Set-DnsClientServerAddress to configure the DNS Servers for this server.

 

Next, let's join the server to the TreyResearch.net domain with another little script. OK, I admit, you could do this all as a simple one-liner, but I do it so often that I scripted it.

<#
.Synopsis
Joins a computer to the domain
.Description
Joins a new computer to the domain. If the computer hasn't been renamed yet, 
it renames it as well.
.Parameter NewName
The new name of the computer
.Parameter Domain
The domain to join the computer to. Default value is TreyResearch.net
.Example
Join-myDomain -NewName trey-wds-11
.Example
Join-myDomain dc-contoso-04 -Domain Contoso.com
.Notes
     Name: Join-myDomain
   Author: Charlie Russel
Copyright: 2017 by Charlie Russel
         : Permission to use is granted but attribution is appreciated
  ModHist:  9 Apr, 2014 -- Initial
         : 25 Feb, 2015 -- Updated to allow name already matches
         :
#>
[CmdletBinding()]
Param ( [Parameter(Mandatory=$true,Position=0)]
        [String]$NewName,
        [Parameter(Mandatory=$false,Position=1)]
        [String]$Domain = "TreyResearch.net"
       )

$myCred = Get-Credential -UserName "$Domain\Charlie" `
                         -Message "Enter the Domain password for Charlie."

if ($ENV:COMPUTERNAME -ne $NewName ) {
   Add-Computer -DomainName $Domain -Credential $myCred -NewName $NewName -restart
} else {
   Add-Computer -DomainName $Domain -Credential $myCred -Restart
}

After the server restarts, log in with your domain credentials, not as "Administrator".  The account you logon with should be at least Domain Admin or equivalent, since you're going to be adding DHCP to the server and promoting it to be a domain controller.

 

To add the necessary roles to the server, use:

Install-WindowsFeature -Name DHCP,AD-Domain-Services `
                       -IncludeAllSubFeature `
                       -IncludeManagementTools

Next, download updated Get-Help files with Update-Help. Once you've got those, go ahead and restart the server, and when it comes back up, we'll do the base configuration for DHCP to enable it in the domain, and create the necessary accounts. Creating scopes, etc., is the topic of another day. Probably as part of my Lab series.

 

First, enable the DHCP server in AD (this assumes the $NewName from earlier was 'trey-core-03'. )

Add-DhcpServerInDC -DnsName 'trey-core-03' -PassThru

And, finally, create the necessary local groups:

# Create local groups for DHCP
# The WinNT in the following IS CASE SENSITIVE
$connection = [ADSI]"WinNT://trey-core-03"
$lGroup = $connection.Create("Group","DHCP Administrators")
$lGroup.SetInfo()
$lGroup = $connection.Create("Group","DHCP Users")
$lGroup.SetInfo()

This uses ADSI to create a local group, since there's no good way built into base PowerShell to do it except through ADSI.

 

Finally, we'll use my Promote-myDC.ps1 script to promote the server to domain controller. Again, I could easily do this by hand, but I'm building and rebuilding labs often enough that I scripted it. I'm lazy! Do it once, use the PowerShell interactive command line. Do it twice? Write a script!

<#
.Synopsis
Tests a candidate domain controller, and then promotes it to DC.
.Description
Promote-myDC first tests if a domain controller can be successfully promoted,
and, if the user confirms that the test was successful, completes the
promotion and restarts the new domain controller.
.Example
Promote-myDC -Domain TreyResearch.net

Tests if the local server can be promoted to domain controller for the
domain TreyResearch.net. The user is prompted after the test completes
and must press the Y key to continue the promotion.
.Parameter Domain
The domain to which the server will be promoted to domain controller.
.Inputs
[string]
.Notes
    Author: Charlie Russel
 Copyright: 2017 by Charlie Russel
          : Permission to use is granted but attribution is appreciated
   Initial: 05/14/2016 (cpr)
   ModHist: 02/14/2017 (cpr) Default the domain name for standard lab builds
          :
#>
[CmdletBinding()]
Param(
     [Parameter(Mandatory=$False,Position=0)]
     [string]$Domain = 'TreyResearch.net'
     )

Write-Verbose "Testing if ADDSDeployment module is available"
If ( (Get-WindowsFeature -Name AD-Domain-Services).InstallState -ne "Installed" ) {
   Write-Verbose "Installing the ActiveDirectory Windows Feature, since you seem to have forgotten that."
   Install-WindowsFeature -Name AD-Domain-Services -IncludeManagementTools
   Write-Host ""
}

If ( (Get-WindowsFeature -Name AD-Domain-Services).InstallState -ne "Installed" ) {
   throw "Failed to install the ActiveDirectory Windows Feature."
}

Write-Verbose "Testing if server $env:computername can be promoted to DC in the $Domain domain"
Write-Host ""
Test-ADDSDomainControllerInstallation `
         -NoGlobalCatalog:$false `
         -CreateDnsDelegation:$false `
         -CriticalReplicationOnly:$false `
         -DatabasePath "C:\Windows\NTDS" `
         -DomainName $Domain `
         -LogPath "C:\Windows\NTDS" `
         -NoRebootOnCompletion:$false `
         -SiteName "Default-First-Site-Name" `
         -SysvolPath "C:\Windows\SYSVOL" `
         -InstallDns:$true `
         -Force
Write-Host ""
Write-Host ""
Write-Host ""

Write-Host -NoNewLine "If the above looks correct, press Y to continue...  "
$Key = [console]::ReadKey($true)
$sKey = $key.key

Write-Verbose "The $sKey key was pressed."
Write-Host ""
Write-Host ""
If ( $sKey -eq "Y" ) {
   Write-Host "The $sKey key was pressed, so proceeding with promotion of $env:computername to domain controller."
   Write-Host ""
   sleep 5
   Install-ADDSDomainController `
              -SkipPreChecks `
              -NoGlobalCatalog:$false `
              -CreateDnsDelegation:$false `
              -CriticalReplicationOnly:$false `
              -DatabasePath "C:\Windows\NTDS" `
              -DomainName $Domain `
              -InstallDns:$true `
              -LogPath "C:\Windows\NTDS" `
              -NoRebootOnCompletion:$false `
              -SiteName "Default-First-Site-Name" `
              -SysvolPath "C:\Windows\SYSVOL" `
              -Force:$true
} else {
   Write-Host "The $sKey key was pressed, exiting to allow you to fix the problem."
   Write-Host ""
   Write-Host ""
}

This uses a little trick I haven't talked about before -

$Key = [console]::ReadKey($true)
$sKey = $key.key

This reads in a single keystroke and gets the value of the key. Because of the way this works, "Y" and "y" are equivalent. Useful to give yourself a last chance out if something doesn't look right, though obviously you'll want to remove those bits if you're creating a script that needs to run without interactive input.

 

Configuring Windows Server 2016 Core with and for PowerShell

I know I owe you more on creating a lab with PowerShell, and I'll get to that in a few days. But having just set up a new domain controller running Server 2016 Core, I thought I'd include a couple of tricks I worked through to make your life a little easier if you choose to go with core.

First: Display Resolution -- the default window for a virtual machine connected with a Basic Session in VMConnect is 1024x768. Which is just too small. So, we need to change that. Now in the full Desktop Experience, you'd right click and select Display Resolution, but that won't work in Server Core, obviously. Instead we have PowerShell. Of course. The command to set the display resolution to 1600x900 is:

Set-DisplayResolution -Width 1600 -Height 900

This will accept a -Force parameter if you don't like being prompted. A key point, however, is that it ONLY accepts supported resolutions. For a Hyper-V VM, that means one of the following resolutions:

1920x1080     1600x1050     1600x1200
1600x900      1440x900      1366x768
1280x1024     1280x800      1280x720
1152x864      1024x768       800x600

Now that we have a large enough window to get something done, start PowerShell with the Start PowerShell (that space is on purpose, remember we're still in a cmd window.)  But don't worry, we'll get rid of that cmd window shortly.

Now that we have a PowerShell window, you can set various properties of that window by using any of the tricks I've shown before, such as Starting PowerShell Automatically which sets the Run key to start PowerShell for the current user on Login with:

 New-ItemProperty HKCU:\Software\Microsoft\Windows\CurrentVersion\Run `
                       -Name  "Windows PowerShell" `
                       -Value "C:\Windows\system32\WindowsPowerShell\v1.0\PowerShell.exe"

 

I also showed you how to set the PowerShell window size, font, etc in Starting PowerShell Automatically Revisited. And, of course, you can set the PowerShell window colour and syntax highlighting colours as described in Setting Console Colours. Of course, all my $Profile tricks work as well, so check those out.

 

So, now that we've configured the basics of our PowerShell windows, let's set PowerShell to replace cmd as the default console window. To do that, use the Set-ItemProperty cmdlet to change the WinLogon registry key:

Set-ItemProperty -Path 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon' `
                 -Name Shell `
                 -Value 'PowerShell.exe -NoExit'

Viola! Now, when we log on to our Server Core machine, it will automatically open a pair of PowerShell windows, one from WinLogon registry key and one from the Run registry key.

Building a Lab in Hyper-V with PowerShell, Part 1

Setting the MAC Address Range on a Hyper-V Host

 

Today I want to start a new series of posts on building a lab environment in Hyper-V, primarily using Windows PowerShell to do initial configuration. For some things, you'll need to use a non-PowerShell tool, such as SysPrep or even the Hyper-V Manager. But the vast majority of the process is going to be pure PowerShell. And, to be clear, this is exactly how I build labs. I do very little checkpointing (snapshotting), preferring to rebuild to a known point rather than assuming I've gotten the right snapshot. And with a good set of scripts, and properly configured .VHDXs for source files, that's not really hard.

 

To understand how I build the base environment, you'll need to understand some assumptions I make and why. The first thing I do is configure the MAC address range for the lab host in Hyper-V. This avoids potential conflicts with other machines that might be on the network, especially since I'm going to be manually configuring all the MAC addresses for the VMs I create to allow me to easily configure DHCP reservations for all VMs. All of which makes keeping track of things much, much easier.

 

When Hyper-V hands out MAC addresses dynamically, it creates a range that begins "00-15-5D". This is the Microsoft IEEE Organizationally Unique Identifier, and is used for all Hyper-V generated MAC addresses unless we do something to change that prefix. Don't, you risk conflicting with some other company's range of addresses.

 

The next two pairs in the MAC address range are based on the current IPv4 network address(es) on the host itself if we don't manually configure them.  For lab environments, I usually set the first pair (fourth pair overall) to C8, C9, or CA, depending on which host machine I'm on, and the next pair to a number related to the IPv4 network that will be the primary network for core VMs in the lab. So, when I use a simple 192.168.10/24 network, I set that 5th pair to 0A. This gives me a MAC address range from 00-15-5D-C8-0A-00 to 00-15-5D-C8-0A-FF unless I need a larger range. If I'm going to have multiple networks and multiple NICs on lab VMs, I'll set a larger MAC address range.

 

To set the MAC address range on a Hyper-V server, you could use the GUI, but where's the fun in that. Instead, use the Set-VMHost cmdlet, thus:

Set-VMHost -MacAddressMinimum 00155DC80A00 `
           -MacAddressMaximum 00155DC80AFF `
           -PassThru

Now, I set my new VM script to default to this range as well, and I edit the CSV file that creates DHCP addresses to the same range. But more on setting fixed or reserved IP addresses and configuring DHCP later in the series.  Next up, we'll start building the parts of New-myVM.ps1.

PowerShell v5.1 Released

Microsoft has released the Windows Management Framework (WMF) 5.1, including Windows PowerShell 5.1,  to the web. You can download it here. This is the final version that released with Windows Server 2016, though it doesn't include all the features of PowerShell 5.1 that are on Server 2016 because some are not supported on earlier versions of Windows. WMF 5.1 is available for Windows Server 2012 R2, Windows Server 2012, Windows 2008 R2 SP1, Windows 8.1, and Windows 7 SP1. (Note, this does NOT include Windows 8.0!)

 

Installation on Windows 7 and Windows Server 2008 R2 has updated installation requirements. Please carefully read the Release Notes before installing.

 

All that being said, I'm updating all my computers to the latest version. My Windows 10 and Server 2016 computers are already at the WMF 5.1 level, of course, but I still have some legacy servers that need updates. They'll be getting them over the next couple of weeks, and my lab image templates are getting updates as well.

PowerShell: Sending password expiration notices via GMail – Part 3

In Part 1 of this series, I showed you how to identify users whose password was about to expire. Then in Part 2 of the series, I took that list of users and sent email to them using gmail-hosted company email. This third part of the series pulls all that together into a single script, complete with comment-based help. As always, this and all my scripts are copyrighted, but you're welcome to use them as the basis for your own scripts. However, I do appreciate attribution. Thanks, and enjoy.

<#
.Synopsis
Sends a "Password Expiring" warning email through TreyResearch's gmail. 
.Description
Send-TreyPasswordExpiryNotice first creates a list of accounts whose password will expire in the 
near future (default is 1 week). It then emails the users to warn them that their password will expire soon. 

This initial version runs interactively only. 
.Example
Send-TreyPasswordExpiryNotice
Sends a warning notice to all TreyResearch users whose password will expire in the next 7 days or less.
.Example
Send-TreyPasswordExpiryNotice -Logging
Sends a warning notice to all TreyResearch users whose password will expire in the next 7 days or less, and 
creates a log file that is echoed to the console at the end. 
.Example
Send-TreyPasswordExpiryNotice -DaysWarning 14
Sends a warning notice to all TreyResearch users whose password will expire in the next 14 days or less.
.Example
Send-TreyPasswordExpiryNotice -DaysWarning 5 -Logging -Testing -Verbose
Does NOT send a warning notice to TreyResearch users, but rather processes the first user and sends a notice
to the admin user(s) and writes to the log file. The -Verbose switch will make it additionally chatty.
.Parameter DaysWarning
The number of days advanced warning to give users whose passwords are close to expiration. 
The default is 7 days or less. 
.Parameter Logging
Switch to enable logging. Logs are written to C:\Temp\emaillogs.csv. When this switch is true, 
Send-TreyPasswordExpiryNotice outputs a table with a list of accounts due to expire as well as 
writing to a log file. 
.Parameter Testing
Switch to enable testing. When enabled, email is sent to a list of Admin users and only a single account is processed. 
.Inputs
[int]
[switch]
[Switch]
.Notes
    Author: Charlie Russel
  ThanksTo: Robert Pearman (WSSMB MVP),Jeffrey Hicks (PS MVP)
 Copyright: 2016 by Charlie Russel
          : Permission to use is granted but attribution is appreciated
   Initial: 06 Sept, 2016 (cpr)
          : 09 Dec,  2016 (cpr) -(Ver 1.5) -- Reworked: Only process users who need reminding. Formatting changes
#>
[CmdletBinding()]
Param(
     [Parameter(Mandatory=$False,Position=0)]
     [int]
     $DaysWarning = 7, 
     [parameter(Mandatory=$false)]
     [Switch]
     $Logging,
     [parameter(Mandatory=$false)]
     [switch]
     $Testing
     )


#Set parameters for gmail.
$smtpServer  ="smtp.gmail.com"
$SMTPPort    = 587
$from        = "IT Notification <it-notification@TreyResearch.net>"
$AdminUser1  = "Charlie.Russel@TreyResearch.net"
$AdminUser2  = "admin.user2@TreyResearch.net"
$AdminUser3  = "admin.user3@TreyResearch.net"
$externalUser= "external.account@example.com"

# Cast this to a list of strings to allow for multiple test recipients
[string[]]$testRecipient = $AdminUser1,$AdminUser2,$AdminUser3

<#
 This uses a stored password sitting on a local hard drive. This is a reasonably
 secure way to work with passwords in a file, and is ONLY accessible by the user that created 
 it. Create the password with: 
   
   PSH> Read-Host -AsSecureString | ConvertFrom-SecureString | Out-File $home\Documents\TreyPW.txt

 See blog post at: http://blogs.msmvps.com/russel/2016/10/04/powershell-get-credential-from-a-file for
 full details. 

 Alternately, simply prompt for the credentials here with Get-Credential.

#>

$TreyUsr = "charlie.russel@TreyResearch.net"
$TreyPW = Get-Content $Home\Documents\TreyPW.txt | ConvertTo-SecureString
$Cred = New-Object System.Management.Automation.PSCredential -ArgumentList $TreyUsr, $TreyPW

 
# Check Logging Settings 
if ($Logging) { 
   $logFile = "C:\Temp\emaillogs.csv"
   if (! (Test-Path "C:\Temp") ) {
      Write-Verbose "No C:\Temp directory, so creating one..."
      New-Item -Path "C:\" -Name Temp -ItemType Directory
   }

    # Remove Logfile if it already exists
    If ( (Test-Path $logFile)) { 
      Remove-Item $logFile 
    }
    # Create CSV File and Headers 
    New-Item -Path $logfile -ItemType File 
    Add-Content $logfile "Date,Name,EmailAddress,DaysLeft,ExpiresOn,Notified" 
} 

# System Settings 
$textEncoding = [System.Text.Encoding]::UTF8 
$date = Get-Date -format "MM/dd/yyyy"

# Explicitly import the Active Directory module, but get rid of the noise if it's already loaded. 
Import-Module ActiveDirectory 4>$NULL


# Use the following to query the domain for who the PDC Emulator role holder is. 
$TreyDC = (Get-ADDomain -Identity "TreyResearch.net" -Credential $Cred).PDCEmulator

# Send a cc: to myself or a list of users
$AdminUser = "charlie.russel@TreyResearch.net"
$cclist = @($AdminUser)

# Do calculations outside the ForEach loop whenever possible
$maxPasswordAge = (Get-ADDefaultDomainPasswordPolicy -Server $TreyDC -Credential $Cred).MaxPasswordAge
$today = (get-date) 

# Notice this doesn't get Expired or NeverExpires users. Don't want to send them emails.  
$TreyUsers = Get-ADUser -filter * `
                    -properties Name,PasswordNeverExpires,PasswordExpired,PasswordLastSet,EmailAddress `
                    -Server $TreyDC `
                    -Credential $Cred `
         | where { $_.Enabled -eq $True `
             -AND  $_.PasswordNeverExpires -eq $False `
             -AND  $_.passwordexpired -eq $False `
             -AND  $_.EMailAddress `
             -AND  (($today - $_.PasswordLastSet).Days -ge ($MaxPasswordAge.Days - $DaysWarning))
         }
<# Get notification credentials. Prompt with Get-Credential if not using stored creds. 
$gCred = Get-Credential -UserName "it-notification@TreyResearch.net" `
                        -Message  "Enter Password for IT-Notification account"
#>
$gUsr = "it-notification@TreyResearch.net"
$gPW = Get-Content "$Home\Documents\itnotificationsPW.txt" | ConvertTo-SecureString
$gCred = New-Object System.Management.Automation.PSCredential -ArgumentList $gUsr, $gPW

# Now, we start to do the work. 
foreach ($user in $TreyUsers) { 
    Write-Verbose "Processing user $user"
    $Name = $user.Name 
    $Email = $user.emailaddress 
    $SAM = $user.SAMAccountName
    $sent = " " 
    $passwordSetDate = $user.PasswordLastSet 
    Write-Verbose "$SAM last set their password on $PasswordSetDate"

    $expiresOn = $passwordSetDate + $maxPasswordAge 
    $DaysLeft = (New-TimeSpan -Start $today -End $Expireson).Days 
 
    if (($DaysLeft) -gt "1") { 
        $MessageDays = "in " + "$DaysLeft" + " days." 
    } else { 
        $MessageDays = "today!" 
    } 
 
    # Email Subject Set Here 
    $subject="Your password will expire $messageDays" 
    Write-Verbose "$Name`'s password will expire $messageDays"
   
    # Email Body Set Here, Note You can use HTML, including Images. 
    # This uses PowerShell's here-string. 
$body =@" 
Dear $name, 
<p>Your TreyReseach.net Active Directory Domain credentials <b>will expire $messagedays</b> 
Please update your credentials as soon as possible! <br> </p>
 
<p>If you are using a Windows domain joined system and are connected to the intranet, 
press ctrl-alt-delete and select change password. Alternatively, if you are outside of the 
network, connect to the corporate VPN and reset your password with the same process.<br> </p>
 
<p>If you are not using a Windows based system, ensure you are on the intranet or connected to 
the corporate VPN.  Proceed to https://password.TreyResearch.net <https://password.TreyResearch.net> 
and reset your password.<br> </p>
 
<p>This process will also sync your newly created AD password to your Gmail password. Please 
allow up to 5 minutes for replication of the passwords to occur.<br><br> </p>
 
<p><br><b>Problems</b>? <br>Please open a Service Desk request by clicking on the 
Help Agent icon on your system. If you are NOT running a Help Agent, please contact a member
of the IT Team for instructions on how to install the agent. It is a strict TreyResearch 
company policy that all company-owned systems run the Help Agent. <br></p>
    
<p>Thanks, <br>  
IT Team
</P>
"@

    # If Testing Is Enabled - Email Administrator 
    if ($testing) { 
        $email = $testRecipient 
        $Subject = "PasswordExpiration Test Message"
        $cclist = $AdminUser2,$externalUser
    } 

   # Send Email Message 
    Write-Verbose "$SAM's password is due to expire in $DaysLeft which is less than the "
    Write-Verbose "DaysWarning Parameter setting of $DaysWarning days."

    # I've left this as a straight output to the host. If you want it quieter, make it a Write-Verbose
    "Sending Email Message to $email using $gUsr account"
    Send-Mailmessage -smtpServer $smtpServer `
                     -from $from `
                     -to $email `
                     -cc $cclist `
                     -subject $subject `
                     -body $body `
                     -bodyasHTML `
                     -priority High `
                     -Encoding $textEncoding `
                     -UseSSL `
                     -port $SMTPPort `
                     -Credential $gCred 
    $sent = "Yes"  # Used for logging
    if ($Logging) {
        Add-Content $logfile "$date,$Name,$email,$DaysLeft,$expiresOn,$Sent"  
    }
   if ($Testing) {
       "Sleeping 5, then breaking so we only process a single record"
       Sleep 5
       Break
    }
} 

If ($Logging) { 
   # Use the logging file to display a table of the accounts about to expire. 
   $expiringAccts = Import-Csv -Path $logfile
   $expiringAccts | Sort-Object -Property ExpiresOn `
                  | Format-Table -AutoSize `
                    @{Expression={$_.DaysLeft};`
                           Label="#Days";`
                           align="Right";`
                           width=7}, `
                    Name,`
                    @{Expression={(Get-Date -Date $_.ExpiresOn -Format 'MMMM dd')};`
                           Label="Expires On:"}
}

ETA: Minor bug fix (-Identity instead of -Identify. Sheesh!)

PowerShell: Sending password expiration notices via GMail – Part 2

In the first part of this trio of posts, I showed a way to identify users whose password was about to expire. Which is useful, but now you need to notify them. If your company email is in GMail, there's a few gotchas you'll need to watch out for, but I'll show you how to get it all working.

The process to email each of those users you identified in Part 1 has the following component parts:

  • Setup your log file if the -Logging switch was used
  • Get credentials for sending the email from a notification only address.
  • Extract the relevant user details for each user
  • Calculate the number of days before the user's password expires
  • Set the content of the email (subject, body, etc.)
  • Configure logging and testing behaviours
  • Send the actual email

First, to set up our logfile (a CSV file with details on each user we've sent email to):

if ($Logging) { 
   $logFile = 'C:\Temp\emaillogs.csv'
   if (! (Test-Path 'C:\Temp') ) {
      Write-Verbose 'No C:\Temp directory, so creating one...'
      New-Item -Path 'C:\' -Name 'Temp' -ItemType 'Directory'
   }

    # Remove Logfile if it already exists
    if ( (Test-Path $logFile)) { 
      Remove-Item $logFile 
    }
    # Create CSV File and Headers 
    New-Item -Path $logfile -ItemType File 
    Add-Content $logfile "Date,Name,EmailAddress,DaysLeft,ExpiresOn,Notified" 
}

Now there are certainly other was to do this, but this works, so I use it.

When you go to send the email, you'll likely want to use a notification only address, rather than your real one. But for that, you'll need to prompt for credentials. Or store them in a file, as described in an earlier post. To prompt, use:

$gCred = Get-Credential -UserName "it-notification@treyresearch.net" `
                        -Message  "Enter Password for IT-Notification account"

Now, let's start to build the ForEach loop you'll use to do the work of this process for each user. Start by extracting the relevant user details. (Note that $TreyUsers was built in Part 1 of this series).

foreach ($user in $TreyUsers) { 
    $Name  = $user.Name 
    $Email = $user.emailaddress 
    $Sam   = $user.SAMAccountName
    $sent  = "" # Reset Sent Flag 
    $passwordSetDate = $user.PasswordLastSet 
}

Now, continuing in that same ForEach loop, calculate how many days until their password expires, and set some text based on that:

$expiresOn = $passwordSetDate + $maxPasswordAge 
$DaysLeft  = (New-TimeSpan -Start $today -End $Expireson).Days 
         
if (($DaysLeft) -gt "1") { 
    $MessageDays = "in " + "$DaysLeft" + " days." 
} else { 
    $MessageDays = "today." 
}

You'll use that $MessageDays variable as you build your email message. That email message needs to tell them how long they have, and where and how to change their password. This last part is particularly important if you have users who regularly work remote, or whose primary work computer is a non-Windows computer. But even connected Windows users often have problems. :(

So, for the message subject you have:

$subject="Your password will expire $messageDays"

And for the message body, use something like this:

$body =@" 
Dear $name, 
<p>Your TreyReseach.net Active Directory Domain credentials <b>will expire $messagedays</b> 
Please update your credentials as soon as possible! <br> </p>

<p>If you are using a Windows domain joined system and are connected to the intranet, 
press ctrl-alt-delete and select change password. Alternatively, if you are outside of the 
network, connect to the corporate VPN and reset your password with the same process.<br> </p>

<p>If you are not using a Windows based system, ensure you are on the intranet or connected to 
the corporate VPN.  Proceed to https://password.TreyResearch.net <https://password.TreyResearch.net> 
and reset your password.<br> </p>

<p>This process will also sync your newly created AD password to your Gmail password. Please 
allow up to 5 minutes for replication of the passwords to occur.<br><br> </p>

<p><br><b>Problems</b>? <br>Please open a Service Desk request by clicking on the 
Help Agent icon on system. If you are NOT running a Help Agent, please contact a member
of the IT Team for instructions on how to install the agent. It is a strict TreyResearch 
company policy that all company-owned systems run the Help Agent. <br></p>
   
<p>Thanks, <br>  
IT Team
</P>
"@

Now you'll notice two things about that email body. First, it uses PowerShell's Here-String capability, making it easier to edit and even include double-quotes inside it if you want. You can do this without using a Here-String, but for any long text like this, it's easier in the long run. The second thing is that it includes some basic HTML codes. Gmail allows them, so use them for emphasis where appropriate.

 

Next, let's set up the processing of test messages and logging. First, for test messages, you'll want to override the user's email to only send it to yourself, or to your team. Still inside the ForEach loop, do that with:

[string[]]$testRecipient = $Charlie,$AdminUser2,$AdminUser3 
# If Testing Is Enabled--Email Admins, not real users, and cc an external address 
if ($testing) { 
    $email = $testRecipient 
    $Subject = "PasswordExpiration Test Message"
    $cclist = $externalUser
}

You'll also want to handle logging, so write the logging information to the log file you created earlier.

if ($Logging) {
    Add-Content $logfile "$date,$Name,$email,$DaysLeft,$expiresOn,$Sent"  
}

Finally, send the actual message:

Send-MailMessage -smtpServer $smtpServer `
                 -from       $from `
                 -to         $email `
                 -cc         $cclist `
                 -subject    $subject `
                 -body       $body `
                 -bodyasHTML `
                 -priority   High `
                 -Encoding   $textEncoding `
                 -UseSSL `
                 -port       $SMTPPort `
                 -Credential $gCred

You'll notice a couple of things there. One, you need to identify your SMTP server and the SMTP port to use. That's usually smtp.gmail.com and port 587 for mail sent this way. Next, you need to have set the $textEncoding variable somewhere. In most cases, this will be UTF8, and you'll set that with:

$textEncoding = [System.Text.Encoding]::UTF8

Adjust as necessary for your environment.

 

Now, let's finish off by handling testing - you only want to send a single message to yourself if you're testing, not 60 of them! Do that with:

if ($Testing) {
   "Sleeping 5, then breaking so we only process a single record"
   Sleep 5
   Break
}

Finally, outside the ForEach loop, if logging is enabled, display the results of the logging file in a way you can read:

if ($Logging) { 
   # Use the logging file to display a table of the accounts about to expire. 
   $expiringAccts = Import-Csv -Path $logfile
   $expiringAccts | Sort-Object -Property ExpiresOn `
                  | Format-Table -AutoSize `
                    @{Expression={$_.DaysLeft};`
                           Label="#Days";`
                           align="Right";
                           width=7}, `
                    Name,`
                    @{Expression={(Get-Date -Date $_.ExpiresOn -Format 'MMMM dd')};`
                           Label="Expires On:"}
}

I'll post the entire script in Part 3, shortly, complete with comment-based help and full details.