PowerShell

How to tell if you’re running on Windows Server Core

I have a bunch of scripts I use when I'm building a lab to install "stuff" (that's the Technical Term we IT Professionals use) that I need to manage and work with a virtual machine. Now, when I build from a SysPrep'd image, that's not an issue, but if I have to build from an ISO, I want to automate the process as much as possible. So I have a couple of Setup scripts I run that install gVim, HyperSnap (my screen capture tool), and various other things.

As I was building a new lab this week, I realized that those scripts were all designed to deal with full GUI installations, and had no provisions for not installing applications that make no sense and can't work when there's only a Server Core installation. So, time to find out how I can tell if I'm running as Server Core, obviously. A bit of poking around, and I came up with the following:

$regKey = "hklm:/software/microsoft/windows nt/currentversion"
$Core = (Get-ItemProperty $regKey).InstallationType -eq "Server Core"

(You could do that as a single line, obviously, but I broke it up to make it easier to see on the page. )

The result is stored as a Boolean value in $Core, and I can now branch my installation decisions based on the value of $Core. (Note there ARE other ways to determine whether you're running on Server Core, but they appear to all be programmatic ones not well suited to the avowedly non-programmer IT system administrator types like me. )

PowerShell: Rename an Active Directory User

This came up at work the other day. Another admin had attempted to rename an AD User account and it had only partially gotten renamed -- the SAM Account, Name and Display name were all correct, but the old user name was still showing up in a couple of places, including the login screen. The user was not happy, so I was asked to fix it, and provide a script that would handle it correctly. I poked around a bit and found the issue - even if you set all of the obvious properties correctly (and the other admin had missed UPN), it still won't show correctly on that logon screen -- you need to actually rename the AD object itself. So, after I fixed the problem user's account, I wrote up a script to solve the problem for the next time. I chose to use a CSV file as the input, but you could easily re-work this to work off either a CSV file or a set of command-line parameters. But honestly, I don't ever want to have to enter that many command-line parameters for a simple script. Especially if I have more than one to change.

 

The script uses Get-ADUser with the old name, then pipes it to Set-ADUser, and finally pipes it to Rename-ADObject to finish the process.  I even gave it basic help. :)

<#
.Synopsis
Renames the Active Directory users
.Description
Rename-myADUser reads a CSV file to identify an array of users. The users are then renamed to the new name in Active Directory.
.Example
Rename-myADUser
Renames the AD Accounts of the users in the default "ADUsers.csv" source file
.Example
Rename-myADUser -Path "C:\temp\ChangedUsers.txt"
Renames the AD accounts of the users listed in the file C:\temp\ChangedUsers.txt"
.Parameter Path
The path to the input CSV file of format:
OldSam,NewName,GivenName,Surname,DisplayName,SAMAccountName,UserPrincipalName,EmailAddress

The default value is ".\ADUsers.csv".  
.Inputs
[string]
.Notes
    Author: Charlie Russel
 Copyright: 2017 by Charlie Russel
          : Permission to use is granted but attribution is appreciated
   Initial: 03/09/2017 (cpr)
   ModHist: 
          :
#>
[CmdletBinding()]
Param(
     [Parameter(Mandatory=$False,Position=0)]
     [string]
     $Path = ".\ADUsers.csv" 
     )

$ADUsers = @()
If (Test-Path $Path ) {
   $ADUsers = Import-CSV $Path
} else { 
   Throw  "This script requires a CSV file with user names and properties."
}
$PDC = (Get-ADDomain).PDCEmulator
Write-Verbose "The PDC Emulator has been identified as $PDC"
Write-Verbose " "

ForEach ($User in $ADUsers ) {
   Write-Verbose "Modifying $user.OldSam to $user.NewName" 
   Sleep 3
   Get-ADUser -Identity $User.OldSam -Properties * | `
   Set-ADUser -Server $PDC `
              -DisplayName $user.DisplayName `
              -EmailAddress $User.EmailAddress `
              -SamAccountName $User.SamAccountName `
              -GivenName $User.GivenName `
              -Surname $User.Surname `
              -UserPrincipalName $user.UserPrincipalName `
              -PassThru | `
   Rename-ADObject -NewName $user.NewName -Server $PDC -PassThru
}

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.

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.

 

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: 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 1

In a perfect world, users would never forget their password, and never forget to change it before the expiration date. But we don't live in that perfect world. I covered how to unlock AD accounts earlier in this post, but now I'd like to talk about how to first find the users whose accounts are about to expire, and then email a warning to them. It turns out to be a fairly big script, so I'm going to break it up into a couple of posts. In this first post, I'll cover how to identify the users whose password will expire in the next n days. Then in the next post, we'll send them an email via the company's GMail account. Finally, in the third post in this series, I'll pull the whole thing together into a complete script, with comment-based help.

 

The process to find the users breaks down into several component parts:

  • Get domain credentials
  • Connect to the AD DS domain
  • Do some Date arithmatic
  • Query Active Directory for the users whose passwords will expire

The first part, getting domain credentials, you can use a simple Get-Credential if you want to be prompted every time, or store the credentials securely in a file, as I described here. Notice that I don't suggest that you should simply log in as a Domain Admin and run the script with your domain credentials - that's because I much prefer to always run as a limited user.

 

After we have those credentials, we connect to the Active Directory domain and query it for the MaxPasswordAge property of the domain.

$ADCred = Get-Credential -UserName TreyResearch\Domain.Admin `
                         -Message "Enter the domain admin's password"
$PDC = (Get-Domain -Identity 'TreyResearch.net' `
                   -Credential $ADCred).PDCEmulator
$maxPasswordAge = (Get-ADDefaultDomainPasswordPolicy `
                         -Server $PDC `
                         -Credential $ADCred).MaxPasswordAge

Next, we're going to have to do some Date arithmetic. Rarely fun, but needs must. We'll start by getting some initial values. Our final script will assume a week of warning, but we'll want to be able to change that with a DaysWarning parameter:

[CmdletBinding()]
Param([Parameter(Mandatory=$False,Position=0)]
      [int]$DaysWarning = 7)

And we'll need to know today's date, that's easy:

$today = (Get-Date)

The other bit of information we need is the number of days since the user last set their password. We query for all users (-Filter *), but then discard all the ones we don't need, storing (in $TreyUsers) only those whose password will expire between now and the DaysWarning value. We don't need to do anything with those users whose password is set to never expire, nor do we care about users whose password has already expired. They won't be able to read any emails we send them anyway. :) While we're getting a list of these users, we need to get some properties that aren't returned by default when we use Get-ADUser. We'll use the -Properties parameter to specify those.

$TreyUsers = Get-ADUser -Filter * `
                        -Server $PDC `
                        -Credential $ADCred `
                        -Properties Name,`
                                    PasswordNeverExpires,`
                                    PasswordExpired,`
                                    PasswordLastSet,`
                                    EmailAddress `
      | Where-Object {$_.Enabled -eq $True `
          -AND $_.PasswordNeverExpires -eq $False `
          -AND $_.passwordexpired -eq $False `
          -AND $_.EMailAddress `
          -AND (($today-$_.PasswordLastSet).Days -ge ($MaxPasswordAge.Days-$DaysWarning))
      }

Whew, that looks a right mess. But it's not as bad as it looks. I've tried to make it as efficient as I could, taking advantage of PowerShell's 'short-cut' processing. Yes, I have to query AD DS for all the users, but I quickly stop processing the user if their account is disabled, their password never expires, or their password has already expired. That gets us down to only those users who are actually active. On those, we do a bit of date math. This is slightly complicated by the PasswordLastSet and MaxPasswordAge properties which return an object with more information than we need or want. All we really want is the Days property of those values.

 

The math here is a bit convoluted, so let's work through it. First ($today-$_.PasswordLastSet) is the number of days since the user set their password. The users we want to send an email to are those for whom that is greater than, or equal to,  MaxPasswordAge-DaysWarning.

 

How does that work? Let's assume we have a policy that says you need to change your password at least every 90 days, and I want to start warning users a week ahead of time.  Therefore, MaxPasswordAge-DaysWarning is equal to 83 days. So we only want to send warnings to those users who set their password more than 83 days ago.

 

Next time, we'll send each of those users an email, warning them that they need to change their password.

Getting Large Files

Sooner or later, you're likely to have to "clean up" a disk that's running out of space. One of the simplest ways to do that is to find the really large files on the disk or in a directory and delete ones that you don't actually need, or move them to a location that has more space. Over the years, I've used multiple tools to find the large files, but these days I use PowerShell.

To start, we need to use Get-ChildItem with the -Recurse parameter, and use Sort-Object to sort by the Length property (and to avoid problems if there's any errors, we'll tell it to just ignore errors and keep right on going).

Get-ChildItem -Path $Home -Recurse -ErrorAction SilentlyContinue  `
       | Sort-Object -Property Length -Descending

That's good, but obviously we only need to only return the big files, not all of them, so let's grab only the 10 largest files:

Get-ChildItem -Path $Home -Recurse -ErrorAction SilentlyContinue  `
       | Sort-Object -Property Length -Descending `
       | Select-Object -First 10

OK, better, but kind of hard to read. So, let's do some formatting and cleanup...

Get-ChildItem -Path $Home -Recurse -ErrorAction SilentlyContinue  `
       | Sort-Object -Property Length -Descending  `
       | Select-Object -First 10 `
       | Select-Object Name, `
           @{Label='SizeMB';Expression={"{0:N0}" -f ($_.Length/1MB)}},`
           @{Label='LastWrite';Expression={"{0:d}" -f ($_.LastWriteTime)}}, `
           DirectoryName

Now we're getting closer. But my standard PowerShell window is only 120 characters wide, and that leaves the directory getting chopped off:

Name                                                                              SizeMB LastWrite  DirectoryName
----                                                                              ------ ---------  -------------
871790_001_spp-2016.10.0-SPP2016100.2016_1015.191.iso                             6,672  2016-11-23 C:\Users\Charlie...
en_windows_server_2016_x64_dvd_9327751.iso                                        5,392  2016-10-23 C:\Users\Charlie...
en_windows_server_2012_r2_with_update_x64_dvd_6052708.iso                         5,148  2015-01-09 C:\Users\Charlie...
14393.0.160715-1616.RS1_RELEASE_SERVER_EVAL_X64FRE_EN-US.ISO                      5,076  2016-09-27 C:\Users\Charlie...
en_windows_server_2016_essentials_x64_dvd_9327792.iso                             4,473  2016-10-23 C:\Users\Charlie...
en_windows_storage_server_2016_x64_dvd_9327790.iso                                4,363  2016-10-23 C:\Users\Charlie...
CDR-X10_1.10_for_Intel_X10_platform.iso                                           4,312  2015-12-29 C:\Users\Charlie...
en_windows_10_multiple_editions_version_1511_updated_apr_2016_x64_dvd_8705583.iso 4,252  2016-06-03 C:\Users\Charlie...
en_windows_10_multiple_editions_version_1607_updated_jul_2016_x64_dvd_9058187.iso 4,177  2016-09-21 C:\Users\Charlie...
en_windows_10_multiple_editions_x64_dvd_6846432.iso                               3,895  2015-07-30 C:\Users\Charlie...

Which isn't terribly helpful whenI start trying to actually identify the files PowerShell has found. So, let's take advantage of Format-Table's ability to wrap lines:

Get-ChildItem -Path $Home -Recurse -ErrorAction SilentlyContinue  `
     | Sort-Object -Property Length -Descending  `
     | Select-Object -First 10 `
     | Select-Object Name, `
         @{Label='SizeMB';Expression={"{0:N0}" -f ($_.Length/1MB)}},`
         @{Label='LastWrite';Expression={"{0:d}" -f ($_.LastWriteTime)}}, `
         DirectoryName `
     | Format-Table -auto -wrap

Now that works a bit better, but it ends up with an awful lot of column width for the filename, and a really narrow column for the DirectoryName on my machine. So, let's take it the last step and set some column widths. We can't do that with the Select-Object expressions we've been using, but we can do it with Format-Table expressions:

Get-ChildItem -Path $Home -Recurse -ErrorAction SilentlyContinue  `
     | Sort-Object -Property Length -Descending  `
     | Select-Object -First 10 `
     | Select-Object Name, `
         @{Label='SizeMB';Expression={"{0:N0}" -f ($_.Length/1MB)}},`
         @{Label='LastWrite';Expression={"{0:d}" -f ($_.LastWriteTime)}}, `
         DirectoryName `
     | Format-Table -Wrap `
                     @{Label='File Name';Expression={$_.Name};Width=50},`
                     @{Label='SizeMB';Expression={$_.SizeMB};Width=7},`
                     @{Label='Last Write';Expression={$_.LastWrite};Width=11},`
                     DirectoryName

Now that's a useful display. Notice that when we got to Format-Table, the object names we wanted for our columns now matched the calculated column names, not the original Get-ChildItem property names.

So, let's take the whole thing and wrap it up in a script, with comment-based help, of course. We'll use two parameters -- the number of files to return, and the starting path.

<#
.Synopsis
Find the 10 largest files in a directory tree

.Description
Get-myLargeFiles does a recursive search of a directory and its subdirectories
to find the largest files in that directory tree. By default, it searches from the 
top of the $home directory, and returns the 10 largest files, but you can specify
the starting directory and the number of files to return on the command line. 

.Example
Get-myLargeFiles 

Returns the 10 largest files in your personal directory tree ($home).

.Example
Get-myLargeFiles -Path 'C:\' -Number 20

Returns the 20 largest files on the C: drive. Note that this will not report any failures
caused by insufficient permissions to traverse a particular directory tree. 

.Parameter $Path
The path to the top of the search tree. Default value is $home

.Parameter $Number
The number of large files to return. Default value is 10.

.Inputs
[string]
[Int]

.Notes
    Author: Charlie Russel
 Copyright: 2016 by Charlie Russel
          : Permission to use is granted but attribution is appreciated
   Initial: 3/02/2016 (cpr)
   ModHist: 12/02/2016 (cpr) -- Set column widths in the Format-Table output. 
          :
#>
[CmdletBinding()]
Param(
     [Parameter(Mandatory=$False,Position=0)]
     [string]
     $Path = $Home,
     [Parameter(Mandatory=$False,Position=1)]
     [int]
     $Number = 10
     )

Get-ChildItem $path -recurse -ea SilentlyContinue  `
       | Sort-Object Length -Descending  `
       | Select-Object -first $Number  `
       | Select-Object Name, `
           @{Label='SizeMB';Expression={"{0:N0}" -f ($_.Length/1MB)}},`
           @{Label='LastWrite';Expression={"{0:d}" -f ($_.LastWriteTime)}}, `
           DirectoryName `
       | Format-Table -Wrap `
             @{Label='File Name';Expression={$_.Name};Width=50},`
             @{Label='SizeMB';Expression={$_.SizeMB};Width=7},`
             @{Label='Last Write';Expression={$_.LastWrite};Width=11},`
             @{Label='Directory Name';Expression={$_.DirectoryName}}

Defaulting to PowerShell instead of CMD

Beginning in Windows 8.1, you could set the Windows PowerUser menu (right-click on the Start button, or Win-X key) to show Windows PowerShell and Windows PowerShell (Admin) on the menu instead of Command Prompt and Command Prompt (Admin). But every single new machine you log on to, you had to change that. A nuisance, at least. So, I created a Group Policy Preference to set the registry key for this, and linked this to the Default Domain Policy.

Apparently, Microsoft is finally catching up, and this is going to be the default on Windows 10 beginning with the build that's coming down today. About time!

For those of you who are not on the Fast Ring of Windows Insider builds, the registry key you need to set is:

HKCU:\Software\Microsoft\Windows\CurrentVersion\Explorer\Advanced\DontUsePowerShellOnWinX=0

To set that with Windows PowerShell, use:

Set-ItemProperty HKCU:\Software\Microsoft\Windows\CurrentVersion\Explorer\Advanced `
                -Name DontUsePowerShellOnWinX `
                -Value 0 `
                -Type DWord

You can also use Group Policy Preferences to set that as part of a Group Policy Object (GPO).

    1. Open the Group Policy Management Console (gpmc.msc)
    2. Right-click the GPO you want to modify (I chose the Default Domain Policy for my domain)
    3. Select Edit from the right-click menu to open the Group Policy Editor
    4. Expand the User Configuration container, then Preferences and select Registry in the left pane.
    5. Right-click in the Registry details pane and select Registry Item from the New menu:
Adding a new registry item to Group Policy Preferences

Add a new registry item to Group Policy Preferences

6. In the New Registry Properties dialog, select Update for the Action, HKEY_CURRENT_USER for the Hive, and a Key Path of  \Software\Microsoft\Windows\CurrentVersion\Explorer\Advanced.

Set PowerShell as the WinX default

Set PowerShell as the WinX default

7. The Value Name is DontUsePowerShellOnWinX, with a Value Type of REG_DWORD and a Value Data of 0, as shown below.

8. Click OK, and then close GPEdit. The Group Policy will be applied to following the next reboot and logon of each user to whom the GPO applies.

For those of you who insist that they really want CMD instead of PowerShell, you can simply set the value of that registry item to one (1) instead of zero (0). Or let users manually control it. But as I've been saying for years: "Death to CMD". :)