Forcing a Server’s Active Directory Site

In January 2010 I wrote a blog post Where oh where, did my AD site go…[Alternate title: It’s the DNS, stupid.]. In that blog post I discussed a situation where an incorrect DC locator record could cause a server to report itself as a member of an improper Active Directory site. That can cause a number of issues with Exchange.

I am in the process of migrating that same customer to Exchange 2013 (the prior blog post was written when migrating a particular customer to Exchange 2010).

The first Exchange 2013 server was brought online after the OS was installed. I went through the normal process of installing Exchange 2013 role and feature pre-requisites, installed Ucma 4.0, etc. etc. When it came time to do the first actual step in installing Exchange 2013, PrepareSchema, setup.exe reported that the Schema Master FSMO was not in the same Active Directory site as the computer running setup.

Huh?

Of course it was. I know this requirement and made certain it was satisfied! The Schema Master FSMO was in the AD site named “10-129-59”. The new server was in the same subnet.

However, when executing “nltest /dsgetsite”, nltest reported that the AD site was “Default-First-Site-Name”. Uh, wow.

I immediately reviewed AD Sites and Services to ensure that AD Subnets and AD Sites were properly configured. Indeed, they were. Next, I reviewed the customer’s DNS, in detail, as described in the above blog post. The DNS was correct.

Finally, with little hope of success, I tried resetting the secure channel to the proper FSMO DC. That succeeded.

So, I rebooted. After the reboot, the secure channel was again reset to a DC in “Default-First-Site-Name”. OK, I tried the same thing again (resetting the secure channel and then rebooting) with no change in behavior.

No need to try a third time. That would meet a classical definition of insanity. 🙂

I spent a limited amount of time investigating the particular reasons for why this should occur. But when it comes down to it, as a consultant, my job is to accomplish this project. So, I went out to find ways to ensure that a particular computer is a member of a particular AD site.

It turns out to be pretty simple. You must set a registry value for this key:

HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Services\Netlogon\Parameters

The value is called SiteName and is of type REG_SZ (the name is case sensitive).

In my case, I set SiteName to “10-129-59” and closed regedit.exe (of course you can set this value in many ways – you can use PowerShell, .NET, Win32, reg.exe – whatever you wish to use). Documentation says that restarting the NetLogon service should correct everything, but that is not my experience. After rebooting the server, the computer came up in the proper AD site and I was able to proceed with installing Exchange Server 2013.

Follow me on Twitter: @essentialexch

Finding Services Using non-System Accounts With PowerShell, v3

In 2008,I authored a PowerShell blogpost/script Finding Services Using non-System Accounts With PowerShell. It was a rewrite of an earlier VBscript blogpost/script from 2006, Finding Services Using non-System Accounts. That was a very basic rewrite and a very basic PowerShell script.

With this blogpost, I update the script to process the System Accounts present in Windows Server 182008 R2 and later (which is a super-set of those present in Windows Server 2003), as well as take full advantage of PowerShell’s capabilities, including detailed in-script help, using CIM, providing output as objects instead of text, and using Verbose to control detailed non-object output.

Unlike prior versions of this script, you may choose to provide a list of computers for processing via a file (the traditional ListOfComputers.txt), via a comma-separated command-line parameter, or specify an organizational unit within Active Directory which will be scanned for computer objects. Examples are provided within the in-script help.

Without further ado:

 

# Get-ServicesWithAccounts

#region help

<#
.SYNOPSIS
	For a given computer or list of computers, output the list of services which are running under specifically assigned accounts, which may be either a local account or a domain account.
.DESCRIPTION
	For a given computer or list of computers, examine the services installed on that computer and output a list of the services which are:

		[1] Not running as a privileged non-account local process
		[2] Not running as a service authority (NT Service\*)
		[3] Not running as the default ASP.Net user

	In short, the list of services which are running under specifically assigned accounts, which may be either a local account or a domain account.

	To use this script to evaluate remote computers, the remote computers must have Remote Management enabled and available through the remote computer's firewall.

	CIM cmdlet support was introduced as part of Windows Management Framework 3.0 (WMF 3.0), which included PowerShell 3.0. For computers running earlier versions of WMF/PowerShell, you must use WMI instead of CIM.

	There are three different ways to specify lists of computers (FileName, Computers, OrganizationalUnit). You can use all three at once,  if you wish.
.PARAMETER FileName
	This string parameter accepts a filename. The filename is opened and the contents are read. Each line is assumed to continue a single computer name.

	Before a line is evaluated, white-space characters which precede any text, and those which suffix any text are removed. White-space characters are determined by the System.Char.IsWhiteSpace() method.

	Empty lines are accepted, but ignored.

	Lines which begin with '#' are ignored.

	If filename cannot be opened, then a warning is issued to the script's warning stream (using Write-Warn).

	The FileName parameter defaults to 'not set'.
.PARAMETER Computers
	This string array parameter accepts a comma-separated list of computer names. Duplicate names are not removed. If you specify a computer name twice, it will be evaluated twice.

	Both WINS names (short names) the FQDNs (fully qualified domain names) are supported.

	Computer names which begin with '#' are ignored.

	The Computers parameter defaults to 'not set'.
.PARAMETER OrganizationalUnit
	This parameter has an alias of 'OU'.

	This string parameter accepts the name of an organizational unit, in distinguished name format.

	Active Directory is searched within this organizational unit, and all computer objects are returned. These computer objects are then evaluated.

	The SubTree parameter is used to indicate whether the search is only for this level of Active Directory, or if the search should include all subtrees of the current level (as well as the current level).

	The OrganizationalUnit parameter defaults to 'not set'.
.PARAMETER SubTree
	This boolean parameter accepts $true or $false. If the OrganizationalUnit parameter has not been specified, this parameter is ignored.

	If the SubTree parameter is not set, or if is set to $false, then subtrees of the current OrganizationalUnit are not searched for computer objects. Only the current level is searched.

	If the SubTree paramter is set to $true, then all available subtrees of the current OrganizationalUnit are also searched for computer objects.

	The SubTree parameter defaults to $false.
.PARAMETER NoErrors
	This switch parameter should either be set, or not set. The parameter defaults to 'not set'.

	If the NoErrors switch is set, and a computer cannot be contacted using either CIM or WMI (as appropriate), then a PSObject will be output for that computer indicating that the computer cannot be contacted.

	If the NoErrors switch is not set, then an error will be written to the script's error stream (using Write-Error).
.PARAMETER UseWMI
	This switch parameter should either be set, or not set. The parameter defaults to 'not set'. However, this script will detect whether it is being executed on PowerShell v1 or PowerShell v2 - in that case, UseWMI will be set automatically.

	CIM cmdlet support was introduced as part of Windows Management Framework 3.0 (WMF 3.0), which included PowerShell 3.0. For computers running earlier versions of WMF/PowerShell, you must use WMI instead of CIM.

	This script cannot feasibly detect whether a remote computer is using WMF 3.0 or later. If your human knowledge is aware that pre-3.0 versions of WMF are being used - then set this parameter.

	Please be aware that this script was not tested against computers using PowerShell v1.0 (I don't have any in my test environment). However, it was tested against computers using PowerShell v2.0. I believe that (with UseWMI) it should work against computers using PowerShell v1.0.
.PARAMETER Verbose
	Extensive execution-time information, including summary statistics, is provided if you set Verbose. The parameter defaults to 'not set'.
.INPUTS
	None.  You cannot pipe objects into this script.
.OUTPUTS
	This script outputs a series of PSObjects which contain the following properties: Computer, Account, ShortName, and FullName.

	A PSObject will only be output for a computer under two conditions:

		[1] The computer cannot be contacted and the NoErrors switch has been set.
		[2] One or more services running under specifically assigned accounts have been found on the computer. There will be one PSObject for each service which meets that criteria.
.NOTES
	NAME: Get-ServicesWithAccounts
	AUTHOR: Michael B.Smith
	LASTEDIT: December 17, 2014
	EMAIL: michael at TheEssentialExchange dot com
	VERSION: 3.0

	No warranties, express or implied, are available. This script is offered "as is".

	I hope this script works for you. If it does not, please tell me. I will attempt to figure out what is going on. However - no promises.

	Replaces and expands check-services.ps1

	Setting the Verbose switch will generate lots of processing information in the cmdlet, including interesting summary data.

	Since this script will operate against destination computers running PowerShell v1.0 and PowerShell v2.0, using AsJob and/or WorkFlows is not feasible.

	The accounts excluded from consideration by this script are show below. If a service is executing under any other account, the service will be reported. Excluded accounts:

		'NT Authority\LocalService',
		'NT Authority\Local Service',
		'NT Authority\System',
		'NT Authority\Network Service', 
		'NT Authority\NetworkService',
		'LocalSystem',
		'.\ASPNET',
		'NT Service\*'
.LINK
	http://essential.exchange/blog/2014/12/17/finding-services-using-non-system-accounts-with-powershell-v3.aspx
.EXAMPLE
	C:\> Get-ServicesWithAccounts
	C:\>

	If none of the Computer, FileName, or OrganizationalUnit parameters are specified, then this cmdlet does nothing.
.EXAMPLE
	C:\> Get-ServicesWithAccounts -FileName FileDoesntExist.txt
	WARNING: Could not read file
	C:\>

	If the specified filename cannot be opened and read, then this cmdlet outputs a warning.
.EXAMPLE
	C:\> Get-ServicesWithAccounts -Computers ., localhost, georgie, Win8-L7.example.local
	checkServicesOnComputer : The WinRM client cannot process the request. If the authentication scheme is different from
	Kerberos, or if the client computer is not joined to a domain, then HTTPS transport must be used or the destination
	machine must be added to the TrustedHosts configuration setting. Use winrm.cmd to configure TrustedHosts. Note that
	computers in the TrustedHosts list might not be authenticated. You can get more information about that by running the
	following command: winrm help config.
	At C:\Scripts\Get-ServicesWithAccounts.ps1:338 char:2
	+     checkServicesOnComputer $computer
	+     ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
	    + CategoryInfo          : NotSpecified: (:) [Write-Error], WriteErrorException
	    + FullyQualifiedErrorId : Microsoft.PowerShell.Commands.WriteErrorException,checkServicesOnComputer
	C:\>

	In this case, all of ".", "localhost", and "Win8-L7" refer to the same computer - which has no special services. "Georgie" is a computer that does not exist.

	The UseWMI parameter was not specified, so the script checked the current OS. The current OS is Windows 8.1, so CIM was used instead of WMI.
.EXAMPLE
	C:\> Get-ServicesWithAccounts -Computers ., localhost, georgie, Win8-L7.example.local -UseWMI
	checkServicesOnComputer : The RPC server is unavailable. (Exception from HRESULT: 0x800706BA)
	At C:\Scripts\Get-ServicesWithAccounts.ps1:338 char:2
	+     checkServicesOnComputer $computer
	+     ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
	    + CategoryInfo          : NotSpecified: (:) [Write-Error], WriteErrorException
	    + FullyQualifiedErrorId : Microsoft.PowerShell.Commands.WriteErrorException,checkServicesOnComputer
	C:\>

	To compare against the non-UseWMI case, accessing Georgie still fails when using WMI, but the error is different.
.EXAMPLE
	C:\> Get-ServicesWithAccounts -Computers ., localhost, georgie, Win8-L7.example.local -NoErrors
	Computer                      Account                       ShortName                     FullName
	--------                      -------                       ---------                     --------
	georgie                       
	C:\>

	In this case, all of ".", "localhost", and "Win8-L7" refer to the same computer - which has no special services. "Georgie" is a computer that does not exist. With the NoErrors switch, the output is much easier to comprehend.

	If UseWMI had been set, the output would have been the same.
#>

#endregion

[CmdletBinding( SupportsShouldProcess = $false, ConfirmImpact = 'None' )]

Param (
	[Parameter( Mandatory = $false )]
	[string] $FileName = $null,

	[Parameter( Mandatory = $false )]
	[string[]] $Computers = $null,

	[Parameter( Mandatory = $false )]
	[Alias( 'OU' )]
	[string] $OrganizationalUnit = $null,

	[Parameter( Mandatory = $false )]
	[bool] $SubTree = $false,

	[Parameter( Mandatory = $false )]
	[switch] $NoErrors,

	[Parameter( Mandatory = $false )]
	[switch] $UseWMI
)

Set-StrictMode -Version 2.0

$arrExclude = 
	'NT Authority\LocalService',
	'NT Authority\Local Service',
	'NT Authority\System',
	'NT Authority\Network Service', 
	'NT Authority\NetworkService',
	'LocalSystem',
	'.\ASPNET'

$iAdmin    = 0
$iTotal    = 0
$iCount    = 0
$iError    = 0
$iEmpty    = 0
$iComment  = 0
$iExcluded = 0

$psversion = 1
if( Test-Path Variable:PSVersionTable )
{
	$psversion = $PSVersionTable.PSVersion.Major
}
Write-Verbose "Using PSVersion = $psversion"

function searchOU( [string] $ou, [bool] $subtree )
{
	Write-Verbose "About to search '$ou'"

	$objDomain              = New-Object System.DirectoryServices.DirectoryEntry( 'LDAP://' + $ou )
	$objSearcher            = New-Object System.DirectoryServices.DirectorySearcher
	$objSearcher.SearchRoot = $objDomain
	$objSearcher.Filter     = "(objectCategory=Computer)"
	if( $subtree )
	{
		Write-Verbose 'SearchScope is SubTree'
		$objSearcher.SearchScope = [System.DirectoryServices.SearchScope]::SubTree
	}
	else
	{
		Write-Verbose 'SearchScope is OneLevel'
		$objSearcher.SearchScope = [System.DirectoryServices.SearchScope]::OneLevel
	}

	$results = $objSearcher.FindAll()
	Write-Verbose "OU search returned $($results.Count) results"

	[string[]]$array = $null

	foreach( $result in $results )
	{
		Write-Verbose "Result added '$($result.Properties.dnshostname.Item(0))'"
		$array += $result.Properties.dnshostname.Item( 0 )
	}

	return $array
}

function makeObject( [string] $computer, [string] $account, [string] $shortname, [string] $fullname )
{
	$obj = '' | Select Computer, Account, ShortName, FullName

	$obj.Computer  = $computer.ToLower()
	$obj.Account   = $account.ToLower()
	$obj.ShortName = $shortname
	$obj.FullName  = $fullname

	return $obj
}

function checkServicesOnComputer( [string] $strComputer )
{
	[bool] $bResult = $false
	[object] $results = $null

	Write-Verbose "Checking computer $strComputer" 

	$error.Clear()
	if( $useWMI -or ( $psversion -lt 3 ) )
	{
		$results = Get-WmiObject Win32_Service -ComputerName $strComputer -Property Name, StartName, Caption -EA 0 | 
			Select Name, StartName, Caption
		$bResult = $?
	}
	else
	{
		$results = Get-CimInstance Win32_Service -ComputerName $strComputer -Property Name, StartName, Caption -EA 0 | 
			Select Name, StartName, Caption
		$bResult = $?
	}

	if( -not $bResult )	# if( $bResult -eq $false )
	{
		$Script:iError++
		if( -not $noErrors )
		{
			Write-Error $error[ 0 ].ToString()

			### If we use 'throw' instead of write-error, then the error becomes terminating,
			### when the user does not use -noerrors. That is a non-optimal behavior.
			### throw $error[ 0 ]
		}
		else
		{
			makeObject $strComputer '' '' ''
			Write-Verbose "....An error has occurred, but was suppressed; no results were returned from computer $strComputer"
		}

		return
	}

	if( $null -eq $results )
	{
		Write-Verbose "No error occurred, but no results were returned on computer $strComputer"

		return
	}

	Write-Verbose "I have found $($results.Count) services on computer $strComputer"

	$iIncluded = 0
	foreach( $result in $results )
	{
		$account = $result.StartName
		$accName = $result.Name

		if( [String]::IsNullOrEmpty( $account ) )
		{
			Write-Verbose "No account was specified for service $accName"

			$Script:iExcluded++
			continue
		}

		if( $arrExclude -contains $account )
		{
			### this is too verbose
			### Write-Verbose "Account $account excluded for service $accName"

			$Script:iExcluded++
			continue
		}

		$acctLen   = $account.Length

		$ntService = "NT Service\"
		$ntServLen = $ntService.Length

		if( ( $acctLen -gt $ntServLen ) -and ( $account.SubString( 0, $ntServLen ) -eq $ntService ) )
		{
			Write-Verbose "NT Service account excluded: $account for $accName"

			$Script:iExcluded++
			continue
		}

		$iIncluded++

		### i should actually check the SID for '-500'. but i don't.

		$adminR = "\administrator";  ## admin-from-the-right
		if( ( $acctLen -ge $adminR.Length ) -and
		    ( $account.SubString( $acctLen - $adminR.Length ) -eq $adminR ) )
		{
			Write-Verbose "Account $account is an administrator account (1)"
			$Script:iAdmin++
		}

		$adminL = "administrator@";  ## admin-from-the-left
		if( ( $acctLen -ge $adminL.Length ) -and
		    ( $account.SubString( 0, $adminL.Length ) -eq $adminL ) )
		{
			Write-Verbose "Account $account is an administrator account (2)"
			$Script:iAdmin++
		}

		Write-Verbose "Account $account, computer $strComputer, service $accName"

		makeObject $strComputer $account $accName $result.Caption
	}

	$Script:iTotal += $iIncluded
}

function doProcessSingleComputer( [string] $computer )
{
	if( [String]::IsNullOrEmpty( $computer) )
	{
		Write-Verbose 'Computer name is null or empty (1)'

		$Script:iEmpty++
		return
	}

	$computer = $computer.Trim()
	if( [String]::IsNullOrEmpty( $computer ) )
	{
		Write-Verbose 'Computer name is null or empty (2)'

		$Script:iEmpty++
		return
	}

	if( '#' -eq $computer.SubString( 0, 1 ) )
	{
		Write-Verbose 'Computer name is a comment'

		$Script:iComment++
		return
	}

	Write-Verbose "About to process $computer"
	checkServicesOnComputer $computer
	$Script:iCount++
}

function doProcessArray( [string[]] $computerArray )
{
	if( ( $null -eq $computerArray ) -or ( $computerArray.Count -le 0 ) )
	{
		return
	}

	Write-Verbose "About to process computerArray containing $($computerArray.Count) items"

	foreach( $computer in $computerArray )
	{
		doProcessSingleComputer $computer
	}
}

function doProcessFile( [string] $filename )
{
	if( [String]::IsNullOrEmpty( $filename ) )
	{
		return
	}

	Write-Verbose "filename = $filename"
	$computers = Get-Content $filename -EA 0
	if( !$? -or ( $null -eq $computers ) -or ( $computers.Count -le 0 ) )
	{
		Write-Warning "Could not read file"
		Write-Verbose "Could not read file"
		return
	}
	Write-Verbose "$filename contains $($computers.Count) lines"

	doProcessArray $computers
}

function doProcessOU( [string] $ou, [bool] $subtree )
{
	if( [String]::IsNullOrEmpty( $ou ) )
	{
		return
	}

	$computerArray = searchOU $ou $subtree
	if( $computerArray -and ( $computerArray.Count -gt 0 ) )
	{
		doProcessArray $computerArray
	}
}

	###
	### Main
	###

	$start = Get-Date
	Write-Verbose "Script starts: $(Get-Date $start -Format u)"

	doProcessFile  $fileName
	doProcessArray $computers
	doProcessOU    $organizationalUnit $subtree

	Write-Verbose ""
	Write-Verbose "Processing complete."
	Write-Verbose "Total computers processed: . . $iCount"
	Write-Verbose "Total excluded services: . . . $iExcluded"
	Write-Verbose "Total Administrator services:  $iAdmin"
	Write-Verbose "Total special services:  . . . $iTotal"
	Write-Verbose "Total empty lines: . . . . . . $iEmpty"
	Write-Verbose "Total comment lines: . . . . . $iComment"
	Write-Verbose "Total errors:  . . . . . . . . $iError"

	$end = Get-Date
	Write-Verbose "Script end: $(Get-Date $end -Format u)"
	$diff = $end - $start
	Write-Verbose "Script elapsed: $($diff.ToString())"

 

 

Follow me on Twitter: @essentialexch

Exchange Server 2013 Service Pack 1 Released!

Just a quick note…. Exchange Server 2013 Service Pack 1 has been released.

Among other changes, this version of Exchange Server provides support for installation on Windows Server 2012 R2 and provides support for Windows Server 2012 R2 domain controllers.

The blog post announcing the release is here. You can download the release here.

At the same time were releases for two legacy versions of Exchange: Update Rollup 5 for Exchange 2010 Service Pack 3 and Update Rollup 13 for Exchange 2007 Service Pack 3.

The announcement for those releases is here. At the time of this writing, no information is available about the contents of those rollups.

More information coming soon!

Follow me on twitter: @essentialexchange

 

Reporting on primary and secondary SMTP addresses on Exchange objects

Most companies have a set of primary Internet domains (Exchange accepted domains) that they use to assign to users. However, a constant is that most companies also assign a secondary email address that has a domain which is identical for all of their users. That is true in my environment, and in that of all customers I have worked with in the past.

However, Exchange tends to generate (especially if you have migrated from legacy Exchange versions) far more email addresses than that for any given Exchange object (user, group, contact).

The script below reports on each user's primary SMTP address, plus a secondary SMTP address whose domain is specified as a parameter.

The script uses the Active Directory PowerShell module, which must be installed on the computer where this script is executed. The script does not use any Exchange specific features, thus the Exchange Management Shell is not required. I tested the script on Exchange 2010 and Exchange 2013, but it should work on Exchange 2007 as well.

A couple of techniques worth noting are used here. First, instead of a function, I use a filter. A filter is a very special kind of a function optimized for working with pipelines of objects. Inputs from the pipeline are passed to the filter using $_.

Second, I parse the proxyAddresses attribute. This attribute contains a list of all addresses that are assigned to a given user. It is an array (a collection) of all those addresses. Importantly, for each item in the collection, the address itself is prefixed by an address type. For SMTP addresses, the address type is "smtp". For FAX addresses, the address type is "fax". For X500 addresses, the address type is "x500". Etc. Also importantly, if the address type is capitalized, then a particular address is the primary address of that type – the default. If the address type is not capitalized, then the address is a secondary address. There may be any number of secondary addresses of a particular type. There may be only one primary address of a particular type.

In order to detect primary addresses, I use the "-ceq" operator in PowerShell. This is "cased equal". That means that the case of the letters is significant. By default, comparisons in PowerShell are not case significant.

Finally, Exchange objects will always have the proxyAddresses attribute populated. This fact is used to build LDAP query utilized to find Exchange objects.

I hope you find this useful!

##
## Get-PrimaryAndSecondary
##
## Michael B. Smith
## April, 2013
##
Param(
	[string]$secondaryDomain = "@TheEssentialExchange.com"
)

[int]$secondaryDomainLen = $secondaryDomain.Length

filter strip-Addresses
{
	$proxies = $_.proxyAddresses

	$primary   = ""
	$secondary = ""

	$object = "" | Select GivenName, Surname, sAMAccountName, PrimarySmtp, SecondarySmtp

	$object.GivenName      = $_.GivenName
	$object.SurName        = $_.SurName
	$object.sAMAccountName = $_.sAMAccountName

	foreach( $proxy in $proxies )
	{
		$len = $proxy.Length

		## note: "SMTP:".Length == 5

		## note: The primary SMTP address has a CAPITALIZED "SMTP:" prefix
		## all secondary SMTP addresses have a lowercase "smtp:" prefix

		## note: any interesting secondary proxy address will be longer than 
		## "SMTP:".Length + $secondaryDomainLen

		if( $len -gt 5 )
		{
			$prefix = $proxy.SubString( 0, 5 )
			$temp   = $proxy.SubString( 5 )	##strip off "smtp:", if present

			if( $prefix -ceq "SMTP:" )
			{
				$primary = $temp
				if( $secondary.Length -gt 0 )
				{
					break   ## we have both primary and secondary, 
						## we don't need to look any more
				}
			}
			elseif( $prefix -ceq "smtp:" -and $len -gt ( 5 + $secondaryDomainLen ) )
			{
				if( $temp.EndsWith( $secondaryDomain ) )
				{
					$secondary = $temp
					if( $primary.Length -gt 0 )
					{
						break   ## we have both primary and secondary, 
							## we don't need to look any more
					}
				}
			}
		}
	}

	$object.PrimarySmtp   = $primary
	$object.SecondarySmtp = $secondary

	$object
}

Import-Module ActiveDirectory

Get-AdUser -LDAPFilter "(&(objectCategory=user)(proxyAddresses=*))" `
	-Properties GivenName, SurName, proxyAddresses -ResultSetSize $null | 
	strip-Addresses

Follow me on twitter, @essentialexch

 

PowerShell Quick Script: Finding the Exchange Schema Version

Every major Exchange release comes with updates to the Active Directory schema. In this case, "major release" means new major version (at RTM), every service pack, and (probably) every Cumulative Update with the new servicing model introduced for Exchange 2013.

Each update is unique to that particular release and, in general, they are cumulative. A notable exception to this was when Exchange Server 2007 SP3 had a higher schema version than that of Exchange Server 2010 RTM.

Over the lifetime of modern Exchange (since the integration to Active Directory with Exchange 2000), there have been a number of issues making it important to know the current schema version of Exchange. Most instructions on the web suggest using ADSIEdit to examine the relevant variable and value.

However, that is potentially risky (because ADSIEdit can be a dangerous tool) and can be a little confusing to use.

Here is a quick little PowerShell script to report on the proper value:

$root = [ADSI]"LDAP://RootDSE"
$name = "CN=ms-Exch-Schema-Version-Pt," + $root.schemaNamingContext
$value = [ADSI]( "LDAP://" + $name )
"Exchange Schema Version = $( $value.rangeUpper )"

The ms-Exch-Schema-Version-Pt attribute is never assigned to a class in the schema, it is used exclusively to identify the value of the Exchange Schema Version.

To anyone who has used ADSI in PowerShell or VBScript before, the little four-line script will appear very familiar. The PowerShell ADSI accelerator syntax allows for the corresponding PowerShell script to be shorter than the equivalent VBScript script.

In order for this script to work, it must be executed on a computer joined to an Active Directory domain. The execution context for the script (that is, the user account) requires no special privileges.

Oh, and if you prefer PowerShell one-liners, here is the same script as a one-liner for you:

"Exchange Schema Version = " + ([ADSI]("LDAP://CN=ms-Exch-Schema-Version-Pt," + ([ADSI]"LDAP://RootDSE").schemaNamingContext)).rangeUpper

Follow me on Twitter @essentialexch

 

Processing Large and Embedded Groups in PowerShell

I'm working with a client that has – over an extended period of time – accumulated thousands of security groups, most of which are mail enabled for use by both Exchange and for setting rights on various objects.

Becasue they use embedded groups (that is, placing a group as a member of another group), they have a horrible time keeping straight who is actually a member of those groups.

Recently an unintentional disclosure at the client has caused them to desire to clean this mess up. 🙂

They found that many tools weren't robust enough to deal with their environment. Their groups may contain thousands of members and dozens of embedded groups. And sometimes, the groups are recursive (or nested). That is, GroupA includes GroupB, GroupB includes GroupC, and GroupC includes GroupA. Also, due to historial reasons, many of their users have non-standard primary group memberships and those needed to also be included as part of the evaluation process.

Note: be aware – most tools will only return 1,500 members for a group (1,000 if your FFL is Windows 2000 mixed or Windows 2000 native). This includes most tools from Microsoft (e.g., dsquery and dsgroup). Some of the tools that handle that properly will go into an infinite loop if there are nested groups. Since the primary group is stored differently than other group memberships, most tools simply ignore it (the RID of the group is stored in the primaryGroupId attribute of a user object, instead of using a memberOf backlink or the member attribute forward link from the group itself).

We were unable to find a tool (which doesn't mean one isn't out there!) that handled all of these issues properly.

So, I wrote one. In PowerShell.

Note that performance is not great when you are scanning nested groups. This is because it is necessary to evaluate every member to determine the type of the member – user, group, contact, etc. That adds significant additional processing overhead.

Each individual piece of this script is pretty obvious (except for the "range" processing required for looking at large group memberships). But after putting it all together, it's a thing of magic. 🙂

Enjoy!


###
### Get-GroupMember
###
### This function processes LARGE groups. Most normal utilities are limited
### to returning a maximum of 1,500 members for a group. To get all members
### of a group requires using a "ranged" member attribute. Few programs,
### including many from Microsoft, go to that much trouble. This one does.
###
### Also, retrieving membership from embedded groups, while avoiding the
### problems that can occur with group recursion, is something that many
### programs do not handle properly. This one does.
###
### Also, some programs do not handle empty groups properly (including the 
### example range program on MSDN from Microsoft). This one does.
###
### Also, some programs do not also check for the primaryGroupID membership, 
### and thus cannot return the membership of, for example, 'Domain Users'. 
### This one does.
###
### The ADSpath for each member of the group is written to the pipeline.
###
### Michael B. Smith
### michael at TheEssentialExchange dot come
### May, 2012
###
### Parameters:
###
###	-group 			The short name for the group. This is looked
###				up to find the distinguishedName of the group.
###
###	-ExpandEmbeddedGroups	Whether to recurse and get the membership of
###				groups contained within the parent group. If
###				this option is specified, all embedded groups
###				are scanned (including groups embedded within
###				groups embedded within groups,etc. etc.).
###
###	-Verbose		Display to the host function entry/exit and
###				status information.
###
###	-VeryVerbose		Display to the host the ADSpath of each member
###				of the group (as well as write it to the pipe).
###
###	-Statistics		Display to the host some basic statistics about
###				the query (number of users, number of embedded
###				groups, number of contacts).
###

Param(
	[string]$group	= (throw "group must be specified"),
	[switch]$ExpandEmbeddedGroups,
	[switch]$Statistics,
	[switch]$Verbose,
	[switch]$VeryVerbose
)

### for the Statistics option

$script:groupUsers    = 0
$script:groupGroups   = 0
$script:groupContacts = 0

function msg
{
	if( -not $Verbose )
	{
		return
	}

	$str = ''
	foreach( $arg in $args )
	{
		$str += $arg
	}
	write-host $str
}

function vmsg
{
	if( -not $VeryVerbose )
	{
		return
	}
	msg $args
}

function Get-PrimaryGroupID
{
	Param(
		[string]$indent,
		[string]$ADSpath
	)

	msg "${indent}Get-PrimaryGroupId: enter, ADSpath = $adspath"

	[string]$pgToken = 'primaryGroupToken'

	### format of argument: LDAP://CN=Domain Users,CN=Users,DC=smithcons,DC=local

	$groupDE  = New-Object System.DirectoryServices.DirectoryEntry( $ADSpath )
	$searcher = New-Object System.DirectoryServices.DirectorySearcher( $groupDE )
	$searcher.Filter = "(objectClass=*)"

	$searcher.PropertiesToLoad.Add( $pgToken ) | Out-Null

	$result = $searcher.FindOne()
	if( $result -ne $null )
	{
		if( $result.Properties.Contains( $pgToken ) -eq $true )
		{
			msg "${indent}Get-PrimaryGroupId: exit, token = $($result.Properties.primarygrouptoken)"

			return $result.Properties.primarygrouptoken
		}
	}

	msg "${indent}Get-PrimaryGroupId: exit, token not found"
	return 0
}

function Search-PrimaryGroupID
{
	Param(
		[string]$indent,
		[string]$namingContext,
		[int]$primaryGroup,
		[hashtable]$dictionary
	)

	msg "${indent}Search-PrimaryGroupId: enter, namingcontext = '$namingContext', primaryGroup = $primaryGroup"

	$ldapFilter = "(primaryGroupID=$primaryGroup)"

	$directorySearcher = New-Object System.DirectoryServices.DirectorySearcher
	$directorySearcher.PageSize    = 1000
	$directorySearcher.SearchRoot  = ( "LDAP://" + $namingContext )
	$directorySearcher.SearchScope = "subtree"
	$directorySearcher.Filter      = $ldapFilter

	### load the properties we want

	$directorySearcher.PropertiesToLoad.Add( "distinguishedName" ) | Out-Null
	$directorySearcher.PropertiesToLoad.Add( "objectClass" )       | Out-Null

	$results = $directorySearcher.FindAll()
	if( $results -ne $null )
	{
		msg "${indent}Search-PrimaryGroupId: found $($results.Count) results"
		foreach( $result in $results )
		{
			$myadspath   = $result.Path
			$objCount    = $result.Properties.objectclass.count
			$objectClass = $result.Properties.objectclass[ $objCount - 1 ]

			if( $objectClass -eq 'user' )
			{
				if( $dictionary.$myadspath -eq 1 )
				{
					msg "${indent}Search-PrimaryGroupID: continue duplicate user"
					return
				}
				$dictionary.$myadspath = 1
				$script:groupUsers++
				write-output $myadspath
				vmsg "${indent}Search-PrimaryGroupId: $myadspath"
			}
			else
			{
				write-error "Invalid objectclass for primarygroupid: $objectClass"
			}
		}
	}
	else
	{
		msg "${indent}Search-PrimaryGroupID: result from FindAll() was null"
	}

	msg "${indent}Search-PrimaryGroupId: exit"
}

function Search-Group
{
	Param(
		[string]$indent,
		[string]$ADSpath,
		[hashtable]$dictionary
	)

	### based originally on http://msdn.microsoft.com/en-us/library/bb885125.aspx
	### but has bug-fixes and enhancements

	msg "${indent}Search-Group: enter, $ADSpath"

	$groupDE  = New-Object System.DirectoryServices.DirectoryEntry( $ADSpath )
	$searcher = New-Object System.DirectoryServices.DirectorySearcher( $groupDE )
	$searcher.Filter = "(objectClass=*)"

	[bool]$lastLoop = $false
	[bool]$quitLoop = $false

	[int]$step = 999
	[int]$low  = 0
	[int]$high = $step

	do {
		if( $lastLoop -eq $false )
		{
			[string]$member = 'member;range=' + $low.ToString() + '-' + $high.ToString()
		}
		else
		{
			[string]$member = 'member;range=' + $low.ToString() + '-' + '*'
		}
		msg "${indent}Search-Group: member = $member"

		$searcher.PropertiesToLoad.Clear()        | Out-Null
		$searcher.PropertiesToLoad.Add( $member ) | Out-Null

		$result = $searcher.FindOne()
		if( $result -eq $null )
		{
			### not sure what to do here
			msg "${indent}Search-Group: searcher failure"
			break
		}

		if( $result.Properties.Contains( $member ) -eq $true )
		{
			$entries = $result.Properties.$member
			msg "${indent}Search-Group: entries.Count = $($entries.Count)"
			foreach( $entry in $entries )
			{
				if( $ExpandEmbeddedGroups )
				{
					$memberObj   = [ADSI] "LDAP://$entry"
					$objectClass = $memberObj.objectClass.Item( $memberObj.objectClass.Count - 1 )
					$myadspath   = $memberObj.Path
					$memberObj   = $null
				}
				else
				{
					$myadspath   = $entry
					$objectClass = 'user'
				}
				write-output $myadspath ### output to pipeline

				switch( $objectClass )
				{
					'group'
						{
							if( $dictionary.$myadspath -eq 1 )
							{
								msg "${indent}Search-Group: continue duplicate group"
								continue
							}
							$dictionary.$myadspath = 1
							$script:groupGroups++
							vmsg "${indent}Search-Group: group $myadspath"
							Search-Group ( $indent + '  ' ) $myadspath $dictionary
						}
					'contact'
						{
							if( $dictionary.$myadspath -eq 1 )
							{
								msg "${indent}Search-Group: continue duplicate contact"
								continue
							}
							$dictionary.$myadspath = 1
							$script:groupContacts++
							vmsg "${indent}Search-Group: contact $myadspath"
						}
					'user'
						{
							if( $dictionary.$myadspath -eq 1 )
							{
								msg "${indent}Search-Group: continue duplicate user"
								continue
							}
							$dictionary.$myadspath = 1
							$script:groupUsers++
							vmsg "${indent}Search-Group: user $myadspath"
						}
					'foreignSecurityPrincipal'
						{
							### do nothing
						}
					default
						{
							write-error "Search-Group: unhandled objectClass as member of group: $objectClass"
						}
				}
			}

			### could just say: $quitLoop = $lastLoop
			### but it's not a worthwhile optimization
			### (due to a loss of clarity in WHY)

			if( $lastLoop -eq $true )
			{
				msg "${indent}Search-Group: set quitLoop = true"
				$quitLoop = $true
			}
		}
		else
		{
			if( $lastLoop -eq $true )
			{
				msg "${indent}Search-Group: set quitLoop = true"
				$quitLoop = $true
			}
			else
			{
				msg "${indent}Search-Group: set lastLoop = true"
				$lastLoop = $true
			}
		}

		if( $lastLoop -eq $false )
		{
			msg "${indent}Search-Group: old low = $low, old high = $high"
			$low  = $high + 1
			$high = $low  + $step
			msg "${indent}Search-Group: new low = $low, new high = $high"
		}

	} until( $quitLoop -eq $true )

	$object   = $null
	$searcher = $null
	$groupDE  = $null

	$primaryID = Get-PrimaryGroupId $indent $ADSpath
	if( $primaryID -gt 0 )
	{
		Search-PrimaryGroupId $indent $script:defaultNC $primaryId $dictionary
	}

	msg "${indent}Search-Group: exit, $ADSpath"
}

function Search-ADForGroup
{
	Param(
		[string]$indent,
		[string]$group
	)

	msg "${indent}Search-ADForGroup: enter, group = $group"

	### build the LDAP search to find the group distinguishedName from the provided short name

	$rootDSE    = [ADSI]"LDAP://RootDSE"
	$defaultNC  = $rootDSE.defaultNamingContext
	$ldapFilter = "(&(objectCategory=group)(name=$group))"
	$rootDSE    = $null

	$directorySearcher = New-Object System.DirectoryServices.DirectorySearcher
	$directorySearcher.PageSize    = 1000
	$directorySearcher.SearchRoot  = ( "LDAP://" + $defaultNC )
	$directorySearcher.SearchScope = "subtree"
	$directorySearcher.Filter      = $ldapFilter

	### Define the property we want (if we don't specify at least one property,
	### then "all default properties" get loaded - that's slower).

	$directorySearcher.PropertiesToLoad.Add( "distinguishedName"  ) | Out-Null

	$groups = $directorySearcher.FindAll()
	if( $groups -eq $null )
	{
		write-error "Search-ADForGroup: No such group found: $group"
	}
	elseif( $groups.Count -eq 1 )
	{
		$script:defaultNC = $defaultNC
		msg "${indent}Search-ADForGroup: exit, $($groups.Item( 0 ).Path)"

		return $groups.Item( 0 ).Path ### same as ADSpath in VBScript
	}
	else
	{
		write-error "Search-ADForGroup: Multiple groups were found that match: $group"
	}

	msg "${indent}Search-ADForGroup: exit, null"
	return $null
}

	###
	### Main
	###

	if( $VeryVerbose )
	{
		$Verbose = $true
	}

	$result = Search-ADForGroup '' $group
	if( $result -ne $null )
	{
		$dictionary = @{}

		Search-Group '' $result $dictionary

		if( $Statistics )
		{
			write-host " "
			write-host "Users: $($script:groupUsers)"
			write-host "Groups: $($script:groupGroups)"
			write-host "Contacts: $($script:groupContacts)"
		}

		$dictionary = $null
	}

 

Finding Duplicate IP Addresses and Duplicate Names in a DNS Zone

One of the traditional issues associated with cleaning up an Active Directory Directory Services (AD DS) domain in DNS is to ensure that duplicate names in DNS are removed (this is typically an issue caused by not having DNS Scavenging enabled, or by having hosts forcefully removed from the domain and not properly cleaning up DNS). As a corollary, this can also lead to duplication of manually assigned IP addresses, regardless of whether those IP addresses are IPv4 or IPv6.

Duplications can cause issues for many different servers and services, including AD DS, Exchange, SharePoint, etc.

I've written a PowerShell script that can help you determine the duplicates in order to clean those up. See the script below!

## ## build-dns-objects.ps1 ## ## ## Michael B. Smith ## michael (at) TheEssentialExchange.com ## April, 2012 ## ## ## Primary functionality: ## ## Based on either an input file or the output of a default command: ## ## dnscmd ( $env:LogonServer ).SubString( 2 ) /enumrecords $env:UserDnsDomain "@" ## ## Create an array containing all of the DNS objects describing the input. ## ## ---- ## ## Secondary functionality: ## ## Find all the duplicate IP addresses and the duplicate names ## contained within either the file or the command output. ## ## By specifying the -skipRoot option, all records for the root of ## the domain are ignored. ## ## ## General record format returned by DNScmd.exe: ## ## name ## [aging:xxxxxxxx] ## TTL ## resource-record-type ## value ## [optional additional values] ## ## Fields may be separated by one-or-more spaces or one-or-more tabs ## [aging:xxxxxxxx] fields are optional ## Param( [string]$filename, [switch]$skipRoot ) function new-dns-object { return ( "" | Select Name, Aging, TTL, RRtype, Value ) } function tmpFileName { [string] $strFile = ( Join-Path $Env:Temp ( Get-Random ) ) + ".txt" if( ( Test-Path -Path $strFile -PathType Leaf ) ) { rm $strNetworkFile -EA 0 if( $? ) { ## write-output "...file was deleted" } else { ## write-output "...couldn't delete file, error: $($error[0].ToString())" } } return $strFile } if( $filename -and ( $filename.Length -gt 0 ) ) { $tmp = $filename } else { $tmp = tmpFileName dnscmd ( $env:LogonServer ).SubString( 2 ) /enumrecords $env:UserDnsDomain "@" >$tmp } $objects = @() $records = gc $tmp $script:zone = '' ## Primary functionality: foreach( $record in $records ) { ## write-output "Processing: $record" if( !$record ) { continue } if( $record -eq "Returned records:" ) { continue } if( $record -eq "Command completed successfully." ) { continue } $firstChar = $record.SubString( 0, 1 ) $record = $record.Trim() if( $record.Length -eq 0 ) { continue } $object = new-dns-object $index = 0 $record = $record.Replace( "`t", " " ) $array = $record.Split( ' ' ) if( ( $firstchar -eq " " ) -or ( $firstchar -eq "`t" ) ) { $object.Name = $script:Zone } else { $object.Name = $array[ 0 ] $script:Zone = $array[ 0 ] $index++ } if( ( $array[ $index ].Length –ge 3 ) –and ( $array[ $index ].SubString( 0, 3 ) –eq “[Ag” ) ) ## [Aging:3604987] { $object.Aging = $array[ $index ] $index++ } $object.TTL = $array[ $index ] $object.RRType = $array[ $index + 1 ] $object.Value = $array[ $index + 2 ] $objects += $object } ## Secondary functionality: ## There are more efficient ways to do this, but this is easy. ## search for duplicate names $hash = @{} foreach( $o in $objects ) { if( $o.RRtype -eq "A" ) { $name = $o.Name if( $skipRoot -and ( $name -eq "@" ) ) { continue } if( $hash.$name ) { "Duplicate name: $name, IP: $($o.Value), original IP: $($hash.$name)" } else { $hash.$name = $o.Value } } } $hash = $null ## search for duplicate IP addresses $hash = @{} foreach( $o in $objects ) { if( $o.RRtype -eq "A" ) { if( $skipRoot -and ( $o.Name -eq "@" ) ) { continue } $ip = $o.Value if( $hash.$ip ) { "Duplicate IP: $ip, name: $($o.Name), original name: $($hash.$ip)" } else { $hash.$ip = $o.Name } } } $hash = $null " " "Done"

 

Enumerating networks and building routes with PowerShell

I've been working with a company that is in the process of setting up a remote datacenter for disaster recovery. They brought me in to help design their Exchange cross-site resilience, and I've been helping them with a few other things too.

Primary connectivity between the primary datacenter and the remote datacenter is via an L2TP tunnel, nailed up and encrypted, across the public Internet, using RRAS.

With over 300 servers in the primary datacenter and over 100 servers targeted in the remote datacenter, we needed a way to set up the proper routes for each server farm to access the other (in both cases, the default gateway is a TMG array).

I decided that I could do that in a few lines of PowerShell. Truth be told, the basic functionality is pretty simple – you iterate through the computers you are examining, look at the wired network interfaces on those computers, see those that have a particular source IP address, and give them a route to the destination network.

However – and as always, the devil is in the details – It Ain't That Simple (IATS – HAH!).

First of all, Active Directory was used as the source of computers. That's great! However, there are computers in AD that aren't in DNS. I have no clue how that happened, but one can surmise that the computers were cut off, their IP addresses reassigned, and the computer object never removed from AD. That's one condition we have to deal with.

Secondly, as a corollary to the above, workgroup computers aren't found at all. With the solution presented here, there were already DNS entries for these computers (and there were only a handful of them), so we temporarily created computer objects for them in AD.

Third, we found some computers have bad IP addresses (specifically 0.0.0.0). Again, I have no clue how that happened, but those have to be filtered out.

Fourth, group policies in this organization aren't applied very logically. That's not what they called me in for, so I had little say. Regardless, a number of computer did not have remote management enabled, thus preventing remote examination of their configuration and remote changes to their configuration. Detection of computers without remote management thus became something the script had to deal with.

Fifth, we had first decided to use 'ping' to determine the accessibility of the computers as part of the discovery process. Well, fewer than half of the computers allowed 'ping' through their firewalls, so that solution had to be summarily discarded. In order to determine accessibility, we actually had to attempt to access the computers.

Sixth, and this is actually a downstream symptom of the issue above, on some servers remote management generates an error. Those servers need to be detected and repaired.

Seventh, and finally, some servers have multiple IP addresses. This may mean that they are already being used as RRAS servers and they need human intelligence to determine whether a route change should be applied.

I didn't use "route add" because in some situations "route add" is known to fail, and "netsh interface ipv4 add route" is preferred.

The resulting utility script is not particularly pretty. But it's very useful and the techniques illustrated are likely to be useful for you in your scripting needs too. So, I present it for your pleasure. 🙂


[string] $nl = "`r`n"

$HKCR = 2147483648
$HKCU = 2147483649
$HKLM = 2147483650

$script:badIP         = @()
$script:commands      = @()
$script:cannotping    = @()
$script:cannotresolve = @()
$script:cannotmanage  = @()
$script:warningmsg    = @()

function msg
{
	$str = ''

	foreach( $arg in $args )
	{
		$str += $arg + ' '
	}
	Write-Host $str
}

function RegRead
{
	Param(
		[string]$computer,
		[int64] $hive,
		[string]$keyName,
		[string]$valueName,
		[ref]   $value,
		[string]$type = "REG_SZ"
	)

	$wmi = [wmiclass]"\\$computer\root\default:StdRegProv"
	if( $wmi -eq $null )
	{
		$script:cannotmanage += $computer
		return 1
	}

	$r = $wmi.GetStringValue( $hive, $keyName, $valueName )
	$value.Value = $r.sValue

	$wmi = $null

	return $r.ReturnValue
}


function test-ping( [string]$server )
{
	[string]$routine = "test-ping:"

	trap 
	{
		# we should only get here if the New-Object fails.

		write-error "$routine Cannot create System.Net.NetworkInformation.Ping for $server."
		return $false
	}

	$ping = New-Object System.Net.NetworkInformation.Ping
	if( $ping )
	{
		trap [System.Management.Automation.MethodInvocationException] 
		{
			###write-error "$routine Invalid hostname specified (cannot resolve $server)."
			msg "...cannot resolve $server in DNS"
			$script:cannotresolve += $server
			return $false
		}

		for( $i = 1; $i -le 2; $i++ )
		{
			$rslt = $ping.Send( $server )
			if( $rslt -and ( $rslt.Status -eq [System.Net.NetworkInformation.IPStatus]::Success ) )
			{
				### msg "$routine Can ping $server. Successful on attempt $i."
				$ping = $null
				return $true
			}
			sleep -seconds 1
		}
		$ping = $null
	}

	###write-error "$routine Cannot ping $server. Failed after 5 attempts."
	msg "...cannot ping $server"
	$script:cannotping += $server

	return $false
}

### 
### Main
###

$start = (get-date).DateTime.ToString()
msg 'Begin' $start

ipmo ActiveDirectory
$computers = Get-AdComputer -Filter * -SearchBase "OU=Servers,DC=example,DC=com" -ResultSetSize $null | Select DnsHostName
msg "There are $($computers.Count) computers to check"
$computerCount = 0
foreach( $computer in $computers)
{
	$computerName = $computer.DnsHostName
	$computerCount++
	msg "Checking $computername... ($computerCount of $($computers.Count))"

#	if( -not ( test-ping $computerName ) )
#	{
#		###msg '...cannot resolve or ping'
#		msg ' '
#		continue
#	}

	### server IP addresses - I only care about IPv4
	$serverIPv4   = @()
	$serverIPname = @()

	$nicSettings = gwmi Win32_NetworkAdapterSetting -EA 0 -ComputerName $computerName
	if( $nicSettings -eq $null )
	{
		msg "...cannot access $computername"
		msg " "
		$script:cannotresolve += $computername
		continue
	}

	foreach( $nicSetting in $nicSettings )
	{
		### msg "Element=" $nicSetting.Element ## is of type Win32_NetworkAdapter
		if( $nicSetting.Element -eq $null )
		{
			msg "...netSetting.Element is null"
			continue
		}

		$nicElement = [wmi] $nicSetting.Element
		if( $nicElement -eq $null )
		{
			msg "...nicElement is null"
			continue
		}

		if( $nicElement.AdapterType -eq "Ethernet 802.3" )
		{
			### msg "Setting=" $nicSetting.Setting
			$nicConfig = [wmi] $nicSetting.Setting ## is of type Win32_NetworkAdapterConfiguration
			$nicGUID = $nicConfig.SettingID
			### msg "NicGUID=" $nicGUID
			### msg "NIC IPEnabled=" $nicConfig.IPEnabled.ToString()
			if( $nicConfig.IPEnabled -eq $true )
			{
				$returnValue = $true ## there is at least one valid NIC

				$hive = $HKLM
				$keyName = "System\CurrentControlSet\Control\Network\" +
					"{4D36E972-E325-11CE-BFC1-08002BE10318}\" + 
					$nicGUID + 
					"\Connection"
				$valueName = "Name"

				$name = ''
				$result = RegRead $computerName $hive $keyName $valueName ( [ref] $name ) 'reg_sz'
				if( $result -ne 0 )
				{
					$name = ""
				}

				###msg "NIC name:" $name

				$script:arrNicName += $name
				foreach( $ip in $nicConfig.IPAddress )
				{
					if( $ip.IndexOf( ':' ) -ge 0 )
					{
						$script:arrIPListPublic_v6 += $ip
						###msg "IPv6 address " $ip
					}
					else
					{
						$script:arrIPListPublic_v4 += $ip
						$serverIPv4   += $ip
						$serverIPname += $name
						###msg "IPv4 address:" $ip
					}
				}
			}
			$nicConfig = $null
		}
		$nicElement = $null
	}
	$nicSettings = $null

	$limit = $serverIPv4.Count - 1

	$allowedAddresses = 0
	for( $i = 0; $i -le $limit; $i++ )
	{
		$ip = $serverIPv4[ $i ]
		$name = $serverIPname[ $i ]

		if( $ip.Length -lt 10 )
		{
			$script:badIP += "$computerName $name $ip"
		}
		elseif( $ip.SubString( 0, 10 ) -eq "10.129.59." )
		{
			msg "*** YES - $computerName is on the server room secure network using NIC '$name' ***"
			$cmd = 'netsh -r ' + $computerName + 
				' interface ipv4 add route 10.129.68.0/22 "' + $name + '" 10.129.59.104'
			$script:commands += $cmd
			$allowedAddresses++
		}
		elseif( $ip.SubString( 0, 10 ) -eq "10.129.68." )
		{
			msg "*** YES - $computerName is on the datacenter network using NIC '$name' ***"
			$cmd = 'netsh -r ' + $computerName + 
				' interface ipv4 add route 10.129.59.0/20 "' + $name +'" 10.129.68.7'
			$script:commands += $cmd
			$allowedAddresses++
		}
	}
	if( $allowedaddresses -gt 1 )
	{
		msg "*** WARNING ***"
		$m = "$computerName has more than one IPv4 address. It may require extra configuration."
		msg $m
		$script:warningmsg += $m
		msg "*** WARNING ***"
	}

	if( $allowedaddresses -eq 0 )
	{
		msg "...no 10.129.59.0/24 or 10.129.68.0/24 IP addresses found on this computer (out of $($serverIPv4.Count))"
	}

	msg " "
}

msg "List of computers and NICs with bad IP addresses ($($script:badIP.Count))"
$script:badIP
' '

msg "List of computers in AD who cannot be accessed ($($script:cannotresolve.Count))"
$script:cannotresolve
' '

#msg "List of computers in AD who cannot be pinged ($($script:cannotping.Count))"
#$script:cannotping
#' '

msg "List of computer in AD who cannot be managed ($($script:cannotmanage.Count))"
$script:cannotmanage
' '

msg "Warning messages ($($script:warningmsg.Count))"
$script:warningmsg
' '

msg "Full list of routing commands ($($script:commands.Count))"
$script:commands
' '

msg 'Began at' $start
msg 'Done at' (get-date).DateTime.ToString()

 

Sending an email to users whose password is about to expire, a PowerShell Rewrite

In September of 2005, I wrote a blog post named “Sending an e-mail to users whose password is about to expire“. Written in VBScript, it was one of my most popular blog posts of all time. I still have clients of mine that use it and I get occaisional email questions regarding it.

However, it is certainly showing its age!

There are other solutions available now, for free. However, the other solutions don’t meet all of my needs. (As always, I encourage you to choose the solution that best meets your needs.)

In my case, I need to be able to support:

  • Fast and efficient searching of Active Directory
  • Support for Fine Grained Password Policies (FGPPs, also known as Password Settings Objects or PSOs)
  • Authenticated SMTP
  • SSL/TLS SMTP
  • Report to Administrative User only (via SMTP)
  • Report to Administrative User only (via console)
  • Report to end-user (via SMTP)

The script in this blog post meets all of those needs. And, it is now written in PowerShell instead of VBScript.

I also chose to use the .Net Framework (System.DirectoryServices) for access to Active Directory (as well as some ADSI), instead of using the Active Directory PowerShell cmdlets. This makes it possible to execute the script on pretty-much any domain-joined computer, instead of one that requires RSAT-ADDS to be installed. It also avoids some weirdness around certain values returned by the AD cmdlets not matching older cmdlets or the AD itself.

This script is designed to work with PowerShell v2.0. The only PowerShell v2.0 feature is use of the Send-MailMessage cmdlet and using splatting to call Send-MailMessage. If you need this on PowerShell v1.0, you must just write a replacement for Send-MailMessage (use System.Net.Mail – it’s not a big deal).

The script will work on any domain functional level (DFL). The DFL is relevant to whether Fine-Grained Password Policies (FGPP, also known as Password Settings Objects – PSOs) are in use or not. FGPPs can be used when the domain level is at Windows 2008 or higher.

Coming in at 767 lines, this is the longest single PowerShell script I believe I’ve posted. But it’s well documented and hopefully self-descriptive. There are some fairly advanced capabilities demonstrated in this script, so you may find it worthwhile to study it a bit. If you have questions, let me know.

###
### Send-MailToUsersWithExpiringPasswords
###
### The top third of the script is data acquisition (and well documented).
### The bottom two-thirds is simple email-sending and report writing.
###
### This is PowerShell v1 compatible EXCEPT for using Send-MailMessage. You can
### easily replace that using System.Net.Mail if you wish.
###
### Parameter information:
###	daysForEmail   - how many days before a password expires should a user receive warning emails
###	adminEmail     - the administrator's email address
###	adminEmailOnly - do not send email to users, only report to the administrator
###	SMTPfrom       - the From address for the SMTP message(s)
###	SMTPserver     - the server to be used for sending the SMTP message(s)
###	SMTPuser       - if credentials are required, the user for authenticating to the SMTP server
###	SMTPpassword   - if credentials are required, the password for the SMTPuser
###	anr            - instead of searching all users, only search for users matching the specified ANR string
###	SMTPuseSSL     - use an SSL/TLS connection, not a clear-text SMTP connection
###	Quiet          - if NOT set, a copy of the admin report is dumped to the pipeline as text
###	DontSendEmail  - Email is never sent to either users or admin
###

Param(
	[int]$daysForEmail = 14,
	[string]$adminEmail = "michael@smithcons.com",
	[switch]$adminEmailOnly,
	[string]$SMTPfrom,
	[string]$SMTPserver,
	[string]$SMTPuser,
	[string]$SMTPpassword,
	[string]$anr,
	[switch]$SMTPuseSSL,
	[switch]$Quiet,
	[switch]$DontSendEmail
)

### Using Set-StrictMode helps protect against wonky errors that get caught by the
### compiler in compiled languages. Specifically (from the helpfile for the cmdlet):
### -- Prohibits references to uninitialized variables (including uninitialized 
###    variables in strings).
### -- Prohibits references to non-existent properties of an object.
### -- Prohibits function calls that use the syntax for calling methods.
### -- Prohibits a variable without a name (${}).
###
### However, using strict mode means that extra care has to be taken when using
### hashtables and property value collections. You see that in this script every
### time you see the Item() accessor method being used.

Set-StrictMode -Version 2.0

### For information about ANR, see "Ambiguous Name Resolution" in
### http://technet.microsoft.com/en-us/library/cc978014.aspx

$domainObject = [System.DirectoryServices.ActiveDirectory.Domain]::GetCurrentDomain()
$domainName   = $domainObject.Name
$domainRoot   = "LDAP://" + $domainName
$domainADSI   = [ADSI]$domainRoot
$domainMode   = $domainADSI.'msDS-Behavior-Version' ## Windows2008Domain is 3.

### domainMode is a PITA. System.DirectoryServices.ActiveDirectory.Domain.DomainMode and
### Microsoft.ActiveDirectory.Management.ADDomainMode have different values for the same
### enums!
###
### The first type is returned by System.DirectoryServices, the second type by the
### Get-ADDomain PowerShell cmdlet.
###
### That is why I ignore both of those potential access methods and use ADSI to access the
### value directly from the domain object. For specific information about those values, see:
### http://msdn.microsoft.com/en-us/library/cc223742(v=prot.10).aspx

[System.Int64]$Script:MaxPasswordAge = 0

function GetMaximumPasswordAge
{
	###
	### GetMaximumPasswordAge
	###
	### Retrieve the maximum password age that is set on the domain object. This is
	### normally set by the "Default Domain Policy".
	###

	if( $Script:MaxPasswordAge )
	{
		### Cache the value so that it only has to be retrieved once, converted
		### to an int64 once, and converted to days once. Win-win-win.

		return $Script:MaxPasswordAge
	}

	### Dealing with ADSI unfortunately also means dealing with COM objects.
	### Using ConvertLargeIntegerToInt64 takes the COM object and converts 
	### it into a native .Net type.

	[System.Int64]$Script:MaxPasswordAge = $domainADSI.ConvertLargeIntegerToInt64( $domainADSI.maxPwdAge.Value )

	### Convert to days
	### 	there are 86,400 seconds per day (24 * 60 * 60)
	### 	there are 10,000,000 nanoseconds per second

	[System.Int64]$Script:MaxPasswordAge = ( -$Script:MaxPasswordAge / ( 86400 * 10000000 ) )

	return [System.Int64]$Script:MaxPasswordAge
}

function newSecurePassword( [string]$password )
{
	###
	### newSecurePassword
	###
	### Take the normal string password provided and turn it into a 
	### secure string that can be used to set credentials.
	###
	### In PowerShell v2.0, this can be done with ConvertTo-SecureString.
	### That cmdlet isn't available in v1.0 though.
	###

	$secure = New-Object System.Security.SecureString

	$password.ToCharArray() |% { $secure.AppendChar( $_ ) }

	return $secure
}

function newPSCredential( [string]$username, [string]$password )
{
	###
	### newPSCredential
	###
	### Create a new PSCredential object containing the provided
	### username and plain-text password.
	###

	$pass = newSecurePassword $password

	$cred = New-Object System.Management.Automation.PSCredential( $username, $pass )
	$pass = $null

	return $cred
}

###
### We need to find all those user's who:
###	Are normal users                       0x00000200 ADS_UF_NORMAL_ACCOUNT
###	Are not disabled                       0x00000002 ADS_UF_ACCOUNTDISABLE
###	Do not have "password never expires"   0x00010000 ADS_UF_DONT_EXPIRE_PASSWD
###	Do not have "no password required"     0x00000020 ADS_UF_PASSWD_NOTREQD
###
###	Once we have the users, determine whether the user has a PSO
###	by examining msDS-PSOApplied (if the domain mode of the executing domain
###	is at Windows2008Domain or higher).
###
###	If the user has a PSO, load the PSO (and store it to a hashtable)
###	and evaluate the user's password against the PSO.
###
###	If the user does not have a PSO, evalute the user's password
###	against the Default Domain Policy Maximum Password Age (which
###	is found by the function GetMaximumPasswordAge above).
###
### The most efficient way to find users is to do an LDAP search. But building the
### proper search isn't that easy. We need a number of filters:
###
###	objectCategory=Person
###	(userAccountControl & ADS_UF_NORMAL_ACCOUNT)     <> 0
###	(userAccountControl & ADS_UF_ACCOUNTDISABLE)     == 0
###	(userAccountControl & ADS_UF_DONT_EXPIRE_PASSWD) == 0
###	(userAccountControl & ADS_UF_PASSWD_NOTREQD)     == 0
###
### Since userAccountControl is a BIT-FLAG attribute (meaning that individuals bits
### of the value control these options) then we need to be able to do a bit-wise
### LDAP search. It's important to realize that with a bit-wise search, the result
### of a particular filter is either not-one (!1, which is false) or not-zero (!0,
### which is true).
###
### So the next step in building our query is to use the LDAP bit-wise AND filter:
###
###	1.2.840.113556.1.4.803
###
### This is used to do a bit-wise AND of the attribute on the left to the value
### on the right. For example:
###
###	attribute:1.2.840.113556.1.4.803:=1024
###
### This means a bit-wise AND is done of the value of the attribute to the value
### 1024 (which is 0x400 in hexadecimal). If the result of that bit-wise AND is
### zero, then the value of the filter is false. If the result is non-zero, then
### the value of the filter is true. Putting a "!" in front of a false result
### makes the result true. Putting a "!" in front of a true result, makes it false.
###
### A combination of these two techniques makes it possible to scan for zero and
### non-zero bits (that is, those which are set to one and those which are set to
### zero).
###
### LDAP also supports a bit-wise OR filter, using the special value of:
###
###	1.2.840.113556.1.4.804
###
### Given the presence of AND and OR filters, it is possible to build very complex
### combination filters.
###
### A combination filter is built up of individual filters combined with either a
### logical OR ("|") or a logical AND ("&") and surrounded by parentheses.
###
###	(&
###		(objectCategory=Person)
###		(userAccountControl:1.2.840.113556.1.4.803:=512)
###		(!userAccountControl:1.2.840.113556.1.4.803:=2)
###		(!userAccountControl:1.2.840.113556.1.4.803:=65536)
###		(!userAccountControl:1.2.840.113556.1.4.803:=32)
###	)
###
### So, in pseudo-C code this is:
###
###	if( ( objectCategory == Person ) AND
###	    ( ( userAccountControl & ADS_UF_NORMAL_ACCOUNT     ) != 0 ) AND
###	    ( ( userAccountControl & ADS_UF_ACCOUNTDISABLE     ) == 0 ) AND
###	    ( ( userAccountControl & ADS_UF_DONT_EXPIRE_PASSWD ) == 0 ) AND
###	    ( ( userAccountControl & ADS_UF_PASSWD_NOTREQD     ) == 0 ) )
###	{
###		### we've got a matching user!
###	}
###
$ldapFilter =   "(&"							      +
			"(objectCategory=Person)"                             +
			"(userAccountControl:1.2.840.113556.1.4.803:=512)"    +
			"(!userAccountControl:1.2.840.113556.1.4.803:=2)"     +
			"(!userAccountControl:1.2.840.113556.1.4.803:=65536)" +
			"(!userAccountControl:1.2.840.113556.1.4.803:=32)"

if( $anr )
{
	###
	### using an ANR subquery allows us to reduce the result set from the LDAP query
	###
	$ldapFilter += "(anr=$anr)"
}

$ldapFilter += ")"

###
### build the LDAP search
###

$directorySearcher = New-Object System.DirectoryServices.DirectorySearcher
$directorySearcher.PageSize    = 1000
$directorySearcher.SearchRoot  = $domainRoot
$directorySearcher.SearchScope = "subtree"
$directorySearcher.Filter      = $ldapFilter

###
### load the properties we want
###

$directorySearcher.PropertiesToLoad.Add( "displayName"        ) | Out-Null
$directorySearcher.PropertiesToLoad.Add( "mail"               ) | Out-Null
$directorySearcher.PropertiesToLoad.Add( "pwdLastSet"         ) | Out-Null
$directorySearcher.PropertiesToLoad.Add( "sAMAccountName"     ) | Out-Null
$directorySearcher.PropertiesToLoad.Add( "userAccountControl" ) | Out-Null

if( $domainMode -ge 3 )
{
	### this attribute is only valid on Windows2008Domain and above
	$directorySearcher.PropertiesToLoad.Add( "msDS-PSOApplied" ) | Out-Null
}

$users = $directorySearcher.FindAll()

###
### All the data is in $users (or will be paged into $users).
### Build the necessary reports and emails.
###

$crnl = "`r`n"
$script:adminReport = ""

function line
{
	foreach( $arg in $args )
	{
		$script:adminReport += $arg + $crnl
	}
}

$now = Get-Date
$maximumPasswordAge = GetMaximumPasswordAge

line ( "Admin Report - Send Mail to Users with Expiring Passwords - Run Date/Time: " + $now.ToString() )
line " "
line "Parameters:"
line "        Days warning for sending email: $daysForEmail"
line "        Administrator email: $adminEmail"

if( $DontSendEmail )
{
	line "        Email will not be sent to either the administrator email or to user's email"
}
elseif( $adminEmailOnly )
{
	line "        Only the administrator will be sent email"
}

if( ![System.String]::IsNullOrEmpty( $SMTPfrom ) )
{
	line "        SMTP From address: $SMTPfrom"
}

if( ![System.String]::IsNullOrEmpty( $SMTPserver ) )
{
	line "        SMTP server: $SMTPserver"
}

if( ![System.String]::IsNullOrEmpty( $SMTPuser ) )
{
	line "        SMTP user: $SMTPuser"
}

if( ![System.String]::IsNullOrEmpty( $SMTPpassword ) )
{
	line "        SMTP password: $SMTPpassword"
}

if( $SMTPuseSSL )
{
	line "        UseSSL with SMTP is set"
}

line "        Maximum Password Age in Default Domain Policy = $maximumPasswordAge"
line "        Domain Mode $($domainObject.DomainMode.ToString())"
line ( "        User count = " + $users.Count.ToString() )

if( [System.String]::IsNullOrEmpty( $SMTPserver ) -and [System.String]::IsNullOrEmpty( $PSEmailServer ) -and !$DontSendEmail )
{
	Write-Error "No email server was specified via the SMTPserver parameter or the `$PSEmailServer environment variable."
	exit
}

if( ![System.String]::IsNullOrEmpty( $SMTPuser ) -and [System.String]::IsNullOrEmpty( $SMTPpassword ) )
{
	Write-Error "No SMTPpassword was specified."
	exit
}

if( ![System.String]::IsNullOrEmpty( $SMTPpassword ) -and [System.String]::IsNullOrEmpty( $SMTPuser ) )
{
	Write-Error "No SMTPuser was specified."
	exit
}

if( [System.String]::IsNullOrEmpty( $SMTPfrom ) )
{
	$SMTPfrom = "Password Administrator "
}

###
### some of the bitflags attached to the userAccountControl attribute
###

$ADS_UF_NORMAL_ACCOUNT     = 0x00200
$ADS_UF_ACCOUNTDISABLE     = 0x00002
$ADS_UF_DONT_EXPIRE_PASSWD = 0x10000
$ADS_UF_PASSWD_NOTREQD     = 0x00020

$psoCache = @{}

foreach( $user in $users )
{
	###
	### we spend some time being pretty careful dealing with the properties. this
	### allows us to verify we get the attributes we want, and for those that were
	### not present, we can establish reasonable defaults.
	###

	line " "

	$propertyBag = $user.properties
	if( !$propertybag )
	{
		line "error! null propertybag!"
		continue
	}

	$mail = ""
	$disp = ""
	$sam  = ""
	$pls  = 0
	$pso  = 0
	$uac  = 0
	$pwdX = $null

	$dispObj = $propertyBag.Item( 'displayname' )
	if( $dispObj -and ( $dispObj.Count -gt 0 ) )
	{
		$disp = $dispObj.Item( 0 )
		if( !$disp ) { $disp = "" }
	}
	$dispObj = $null
	### line "displayname = $disp"

	$samObj = $propertyBag.Item( 'samaccountname' )
	if( $samObj -and ( $samObj.Count -gt 0 ) )
	{
		$sam = $samObj.Item( 0 )
		if( !$sam ) { $sam = "" }
	}
	else
	{
		$sam = ""
	}
	$samObj = $null
	### line "sam = $sam"

	$uacObj = $propertyBag.Item( 'useraccountcontrol' )
	if( $uacObj -and ( $uacObj.Count -gt 0 ) )
	{
		$uac = $uacObj.Item( 0 )
	}
	else
	{
		line "no uac for $sam, assumed 0x200"
		$uac = $ADS_UF_NORMAL_ACCOUNT
	}
	$uacObj = $null
	### line "uac = $uac"

	$plsObj = $propertyBag.Item( 'pwdlastset' )
	if( $plsObj -and ( $plsObj.Count -gt 0 ) )
	{
		$pls = $plsObj.Item( 0 )
	}
	else
	{
		### this can be a normal occurence if the password has never been set
		$pls = 0
	}
	$plsObj = $null
	### line "pls = $pls"

	line ( $sam.PadRight( 21 ) + $pls.ToString().PadRight( 21 ) + $uac.ToString().PadRight( 8 ) + '0x' + $uac.ToString('x').PadRight( 8 ) )

	if( $pls -eq 0 )
	{
		line ( " " * 8 + "The password has never been set for this user, skipped" )
		continue
	}

	[System.Int64]$localMaxAge = $maximumPasswordAge

	if( $domainMode -ge 3 )
	{
		###
		### PSOs can be created on Server 2003, but they don't work properly until
		### the domain mode is Windows2008Domain or higher.
		###
		### So if we find a PSO and the domain mode is Windows2008Domain or higher,
		### we first determine whether we've seen this PSO before. If we have seen
		### the PSO before, then we've stored the msDS-MaximumPasswordAge value for
		### the PSO into a hash table for quick retrieval. If we have not seen the
		### PSO before, then we use ADSI to load the PSO and retrieve the value for
		### msDS-MaximumPasswordAge and then cache the value for future access. By
		### using a cache, we only have to access Active Directory to obtain values
		### once per PSO, leading to a significant performance improvement compared
		### to using the native cmdlets or S.DS.
		###

		$psoObj = $propertyBag.Item( 'msds-psoapplied' )
		if( $psoObj -and ( $psoObj.Count -gt 0 ) )
		{
			$pso = $psoObj.Item( 0 )
			### line ( "PSO object/name" + $pso )

			if( $psoCache.Item( $pso ) )
			{
				[System.Int64]$localMaxAge = $psoCache.Item( $pso )
				### line ( " " * 8 + "Accessed PSO from cache = " + $pso )
			}
			else
			{
				$psoADSI = [ADSI]( "LDAP://" + $pso )
				$ageOBJ = $psoADSI.'msDS-MaximumPasswordAge'

				[System.Int64]$localMaxAge = $psoADSI.ConvertLargeIntegerToInt64( $ageOBJ.Value )

				### Convert to days
				### 	there are 86,400 seconds per day (24 * 60 * 60)
				### 	there are 10,000,000 nanoseconds per second
				$localMaxAge = ( -$localMaxAge / ( 86400 * 10000000 ) )

				$psoCache.$pso = $localMaxAge
				### line ( " " * 8 + "Stored PSO to cache = " + $pso )

				$ageOBJ  = $null
				$psoADSI = $null

				### line "localMaxAge = $localMaxAge"
			}
		}
		else
		{
			### completely normal to not have a PSO, in that case, use the maxPwdAge
			### from the default domain policy.
			$pso = $null
			### line "pso is null"
		}
		$psoObj = $null
	}

	### In an Exchange environment, the 'mail' attribute contains the primary SMTP
	### address for a user. In a non-Exchange environment, that should also be true,
	### but no system process validates it. We do presume that the mail address is
	### valid, if present.

	$mailObj = $propertyBag.Item( 'mail' )
	if( $mailObj -and ( $mailObj.Count -gt 0 ) )
	{
		$mail = $mailObj.Item( 0 )
	}
	else
	{
		$mail = ''
	}

	if( 0 )
	{
		### The conditions reported on below cannot occur based on our LDAP filter
		### But they helped me develop and test the LDAP filter. :-)

		if( $uac -band $ADS_UF_NORMAL_ACCOUNT )
		{
			"`t`tNormal account"
		}
		else
		{
			"`t`tNot a normal account"
		}
		if( $uac -band $ADS_UF_ACCOUNTDISABLE )
		{
			"`t`tAccount disabled"
		}
		else
		{
			"`t`tAccount enabled"
		}
		if( $uac -band $ADS_UF_DONT_EXPIRE_PASSWD )
		{
			"`t`tPassword doesn't expire"
		}
		else
		{
			"`t`tPassword expires"
		}
		if( $uac -band $ADS_UF_PASSWD_NOTREQD )
		{
			"`t`tPassword not required"
		}
		else
		{
			"`t`tPassword required"
		}
	}

	### If we get here, $pls is non-zero.
	###
	### $pls comes to us in FileTime format (the number of 100 nansecond ticks 
	### since Jan 1, 1601). So it must be converted to DateTime and adjusted 
	### for the normal clock in order for us to do our arithmetic on it. For
	### more information about FileTime, see:
	### msdn.microsoft.com/en-us/library/windows/desktop/ms724290(v=vs.85).aspx

	$date = [DateTime]$pls
	$passwordLastSet = $date.AddYears( 1600 ).ToLocalTime()
	$passwordExpires = $passwordLastSet.AddDays( $localMaxAge )

	line ( " " * 8 + "The password was last set on " + $passwordLastSet.ToString() )

	if( $now -gt $passwordExpires )
	{
		line ( " " * 8 + "The password already expired on $($passwordExpires.ToString()). No email will be sent.")
		continue
	}
	else
	{
		line ( " " * 8 + "The password will expire on "  + $passwordExpires.ToString() )
	}

	$difference = $passwordExpires - $now
 	$days       = $difference.Days.ToString()
	$hours      = $difference.Hours.ToString()
	$minutes    = $difference.Minutes.ToString()

	line ( " " * 8 + "(This is in $days days, $hours hours, $minutes minutes)" )

	if( $difference.Days -le $daysForEmail )
	{
		if( [System.String]::IsNullOrEmpty( $mail ) -and !$adminEmailOnly )
		{
			line ( " " * 8 + "Oops - user doesn't have an email address." )
			continue
		}

		if( $DontSendEmail )
		{
			line ( " " * 8 + "Oops - we aren't supposed to send an email." )
			continue
		}

		line ( " " * 8 + "This user will be sent an email for password change." )

		$hash = @{}
		$hash.To       = $adminEmail
		$hash.Priority = 'High'
		$hash.From     = $SMTPfrom

		if( ![System.String]::IsNullOrEmpty( $disp ) )
		{
			$hash.Subject = "Warning! The network password for $disp ($sam) is about to expire."
		}
		else
		{
			$hash.Subject  = "Warning! The network password for $sam is about to expire."
		}

		if( !$adminEmailOnly -and ![System.String]::IsNullOrEmpty( $mail ) )
		{
			$hash.CC = $mail
		}

		if( ![System.String]::IsNullOrEmpty( $SMTPserver ) )
		{
			$hash.SmtpServer = $SMTPserver
		}
		elseif( ![System.String]::IsNullOrEmpty( $PSEmailServer ) )
		{
			$hash.SmtpServer = $PSEmailServer
		}

		###
		### Send-MailMessage will default to using $PSEmailServer when no other SMTP server is specified.
		### We checked earlier to ensure that at least one of those was specified.
		###

		if( ![System.String]::IsNullOrEmpty( $SMTPuser ) )
		{
			###
			### If SMTPuser is specified then SMTPpassword is also specified.
			### We checked earlier to make certain that if one was specified,
			### then both were specified.
			###

			$hash.Credential = newPSCredential $SMTPuser $SMTPpassword
		}

		if( $SMTPuseSSL )
		{
			$hash.UseSSL = $true
		}

		$bodyHeader = @"
WARNING!

"@
		$bodyHeader += "`r`nFor network user id: "
		if( ![System.String]::IsNullOrEmpty( $disp ) )
		{
			$bodyHeader += $disp + " (" + $sam + ")"
		}
		else
		{
			$bodyHeader += $sam
		}

		$hash.Body = @"
$bodyHeader

Your password is about to expire in $days days, $hours hours, $minutes minutes. 

Please change it now!

Thank you,
Your System Administrator
"@

		###
		### This is V2.0 function. I should replace it with something v1.0 compatible.
		### (splatting is also V2.0 only, which is why I'm lazy and didn't replace it.)
		###

		Send-MailMessage @hash

		$hash = $null
	}
}

###
### Invidual report emails complete.
### Now send summaries to the console and/or to the administrator email address.
###

line " "

if( !$quiet )
{
	###
	### If $quiet is NOT set, then dump the report to the console, as well
	### as sending the email to the administrator.
	###
	$script:adminReport
}

if( !$DontSendEmail )
{
	$hash = @{}
	$hash.To       = $adminEmail
	$hash.Priority = 'High'
	$hash.From     = $SMTPfrom
	$hash.Subject  = "Admin Report - Send Mail to Users with Expiring Passwords - Run Date/Time: " + $now.ToString()
	$hash.Body     = $adminReport

	###
	### Send-MailMessage will default to using $PSEmailServer when no other SMTP server is specified.
	### We checked earlier to ensure that at least one of those was specified.
	###

	if( ![System.String]::IsNullOrEmpty( $SMTPserver ) )
	{
		$hash.SmtpServer = $SMTPserver
	}
	elseif( ![System.String]::IsNullOrEmpty( $PSEmailServer ) )
	{
		$hash.SmtpServer = $PSEmailServer
	}

	if( ![System.String]::IsNullOrEmpty( $SMTPuser ) )
	{
		###
		### If SMTPuser is specified then SMTPpassword is also specified.
		### We checked earlier to make certain that if one was specified,
		### then both were specified.
		###

		$hash.Credential = newPSCredential $SMTPuser $SMTPpassword
	}

	if( $SMTPuseSSL )
	{
		$hash.UseSSL = $true
	}

	Send-MailMessage @hash

	$hash = $null
}

###
### Clean up a bit.
###

$now   = $null
$users = $null

$psoCache        = $null
$directorySearch = $null
$domainADSI      = $null
$domainObject    = $null

$script:adminreport = $null

### Done.

Until next time…

If there are things you would like to see written about, please let me know.


Follow me on twitter!  – @EssentialExch

Exchange 2003 FE Servers vs. Server 2008 Active Directory

I spent eight hours over the last two days figuring out an interesting – but weird – problem. Once I figured out the problem, correcting it was a simple matter.

I’ll just give you the problem description and then the solution. Hopefully it will save you more than a little time in the future.

I have a client (one of several in this particular situation) who has an Exchange 2003 Front-End server located in their DMZ. Yes, that’s right, their DMZ. In the “long-ago time”, this was considered to be a favored practice by Microsoft. That, of course, has changed over the years. Now, in 2011, we do not want any domain member servers located in the DMZ. Why is that? Because to install a domain member in a DMZ basically means that you have to make “swiss cheese” out of your firewall. And, effectively, if a DMZ server is compromised it means that your entire Active Directory can be compromised. The entire list of potentially affected ports is shown in KB 832017, and at the end of the day – it basically says that you have to open everything above port 1024 to make it all work.

With all that being said, if you are using Exchange 2003 with Server 2003 Active Directory (by which I mean an Active Directory domain controller hosted on a Windows Server 2003 server), you could get by with a much more limited set of ports (although it still isn’t small!):

DNS (TCP and UDP 53)
Kerberos (TCP and UDP 88)
RPC Endpoint Mapper (TCP 135)
NetBIOS Name Service (TCP 137)
NetBIOS Datagram Service (TCP 138)
NetBIOS Session Service (TCP 139)
LDAP (TCP and UDP 389)
Directory Services (TCP 445)
LDAP Global Catalog (TCP 3268)
Remote Desktop Protocol (TCP 3389)
Active Directory RPC End-Point (AD-RPC-EP)

You should notice that all of these have defined ports except for the last! By definition, the AD-RPC-EP is a random high port.

However, a somewhat surprising fact is that when you run Active Directory on Windows Server 2003 (in other words, you have a domain controller on Server 2003), the AD-RPC-EP is always either port 1025 or 1026!

Starting with Server 2008, this is no longer true. “Port randomization” ensures that ports are allocated at truly random locations.

So… I have a particular client who, as part of their Exchange 2003 to Exchange 2010 upgrade effort decided that they first wanted to upgrade their Active Directory. That was fine by me – there are no documented restrictions regarding the operating system level of domain controllers to be used by Exchange 2003.

The day we changed the last domain controller from Windows Server 2003 to Windows Server 2008 R2, the Exchange FE server stopped working. Oh, $%&#.

Thankfully, the FE server was only being used for Outlook Web Access (OWA). SMTP was injected into the Exchange environment via a Barracuda cluster directly into the internal back-end server environment.

I’m rather hardheaded. So I wanted to figure this out. And, quite frankly, it took awhile. Eventually, it took an examination using portqry and rpcping (you can find out lots of information about these utilities by using Google or Bing and searching for “portqry site:support.microsoft.com” and “rpcping site:support.microsoft.com”). Comparing the results of those to the “access control lists” from the firewall showed me that the firewall always expected a port to be open at TCP 1025 and/or TCP 1026. Neither of these ports were open on Server 2008 R2!

I went back and investigate some other customers who were running Active Directory on Server 2003. On every single one of their servers, the process lsass.exe had either TCP 1025 or TCP 1026 (or both) open.

A google here, a bing there, and I was led to Active Directory Replication Over Firewalls and KB 224196: Restricting Active Directory replication traffic and client RPC traffic to a specific port.

These led me to understand that on Server 2008 and above AD-RPC-EP could happen at any random port – but that there is a way to specify the port that will be used. YAY!

For the following

HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Services\NTDS\Parameters
Registry value: TCP/IP Port
Value type: REG_DWORD
Value data: (available port)

specifying a “value data” of either 1025 or 1026 ensures that Server 2008 and Server 2008 R2 operate as did Server 2003. After setting this value, it does take a reboot of the affected DC/server for the value to take effect.

Once this was done, my client was happy again! I hope this helps you in your migrations…

Until next time…

As always, if there are items you would like me to talk about, please drop me a line and let me know!


Follow me on twitter! : @EssentialExch