Get-FrameworkVersion 1.5

Recently I posted Get-FrameworkVersion. The cmdlet’s job was to examine a particular computer for all versions of the Windows .NET Framework installed on that computer. This was a version 1.0 effort, but it did the job, especially when you were interested only in the local computer. So, if you didn’t mind copying the script to every computer you were interested in, all was great!

However, the requests came in quickly to add support for scanning remote computers. This wasn’t surprising.

This sounds like a simple request, but how to provide it (given no control over a company’s infrastructure) is not as obvious. So, in this version, I’ve added remote support, and made a couple of SWAGs about the best ways to do it. Let me know how it works for you!

I’ve also added extensive help and examples to the cmdlet, as well as documented the internal operation of the cmdlet (for the PowerShell scripters out there).

Get the script on the TechNet Gallery: Display a list of all .NET Framework Versions installed on a computer.


Follow me on twitter! @EssentialExch

Get-FrameworkVersion

Get-FrameworkVersion.ps1 displays a list of all .NET Framework versions installed on a computer. While other scripts perform similar functionality, those I found were not well-behaved when they found an unknown version. Thus, the genesis for this version. Operation is simple:

PS C:\scripts> .\Get-FrameworkVersion.ps1
v2.0.50727       2.0.50727.4927   SP2
v3.0             3.0.30729.4926   SP2
v3.5             3.5.30729.4926   SP1
v4
		Client           4.7.02556
		Full             4.7.02556
v4.0
		Client           4.0.0.0

Current .NET Framework version: 4.7.1 on Windows 10 (4.7.02556 = release 461308)
PS C:\scripts>

I have posted the script on the TechNet Gallery: Display a list of all .NET Framework Versions installed on a computer.

The script is small enough that you can easily use it inside an Invoke-Command to execute on another computer. I will add native remote functionality in the next version.


Follow me on twitter! @EssentialExch

iOS 11.0.1 Released – EAS on iOS Fixed

As I reported last week (in iOS 11 about to release – Things to be aware of), the initial release of iOS 11 was broken in regards to HTTP/2 negotiation.

Today (26 September 2017) Apple has released iOS 11.0.1 which fixes this problem. This is noted in the Apple KB HT208136.

This means you can re-enable HTTP/2 on your Windows Server 2016 servers!

Of course, iOS 11.0.1 probably has other corrections, but I’m not aware of them. RedmondPie has download links and other information about the update.


Follow me on twitter! @EssentialExch

September 2017 Quarterly Exchange Updates

Microsoft has released Exchange Server 2016 CU7 (download) and Exchange Server 2013 CU18 (download) for on-premises servers today.

Perhaps most noticeable is that Exchange Server 2016 CU7 now enforces a Forest Functional Level (and therefore a Domain Functional Level) of Server 2008R2. This change had been announced some time ago, but now it is enforced.

The list of documented fixes in Exchange Server 2016 CU7 is small, but one of the fixes is an annoyance (a warning that can’t be eliminated) associated with UseDatabaseQuotaDefaults when using Set-Mailbox. I’m glad to see it fixed!

Exchange Server 2016 CU7 and Exchange Server 2013 CU18 share another fix which can cause a corrupted email attachment if the attachment is exactly a certain size.

Exchange Server 2013 CU18 also includes various Daylight Savings Time updates for countries all over the world.

Please remember a few things:

You should always test in a lab first.

Your installation of a CU may fail or take significantly longer if you don’t disable anti-virus and anti-malware software before the installation.

If you have a large number of servers, you should probably drain and place each server in maintenance mode before applying the CU (and then return them to operational mode after!).

I generally find that things go more smoothly if you reboot your server “very first thing”.

Not every CU may contain changes to the Active Directory Schema, or to RBAC roles, or many other things. But life can often be made simpler by doing a PrepareSchema and a PrepareAllDomains before executing the upgrade. On my first server to be upgraded, my normal process is this:

setup /IAcceptExchangeServerLicenseTerms /PrepareSchema
setup /IAcceptExchangeServerLicenseTerms /PrepareAllDomains
setup /IAcceptExchangeServerLicenseTerms /m:upgrade

Use an elevated cmd.exe session, not a PowerShell session. (PowerShell searches the path differently than cmd.exe – PowerShell will find the setup.exe in $exbin instead of the setup.exe in the current folder.)

After the upgrade, you should again reboot. Then re-enable your anti-virus and anti-malware. Finally, place the server back in operational mode.

Happy upgrading!


Please follow me on twitter! @EssentialExch

Azure Classic Portal – More Pieces Removed

In a move surprising no-one, pieces are being retired from the Azure Classic Portal. In a blog post yesterday, Alex Simons of Microsoft revealed that the Azure Active Directory management experience would be removed from the Classic Portal on November 30 of this year (2017).

This will also impact Azure Information Protection (AIP) and Access Control Service. AIP was formerly known as Rights Management Service (RMS).

For more information, refer to: Marching into the future of the Azure AD admin experience: retiring the Azure classic portal.

The writing has been on the wall about the Classic Portal for quite some time. If you haven’t moved yet, you certainly should be working on the process!


Follow me on twitter! @EssentialExch

iOS 11 about to release – Things to be aware of

Apple has announced that iOS 11 will be released tomorrow (September 19, 2017). If you are a Windows admin, there are probably some things about which you should be aware. This is not intended to be an exhaustive list, but does describe some rather important items…

  1. Exchange ActiveSync is broken when you run Exchange Server 2016 on Windows Server 2016. Apple is aware of the issue and is pursuing a fix. On a technical level, this happens because iOS 11 is improperly negotiating a HTTP/2 TLS connection and the connection fails. Microsoft has a fully supported workaround, which disables HTTP/2 TLS connections.

    The workaround is described in this article: How to deploy custom cipher suite ordering in Windows Server 2016.

    This only occurs with iOS 11 and Windows Server 2016, because in earlier versions of each, HTTP/1.1 was the default, not HTTP/2.

    (If you think the article name is weird, well so do I.)

  2. The default picture format for iPhones 7/8/X is changing. As a Microsoft employee wrote earlier today:

    The new photo and video formats result in files about 1/2 size of the old JPEG and video formats, while having better quality. The problem is that new files will likely not open properly outside of your phone until everything that you use to work with photos updates to work with new HEIF formats.

    To check if your iOS 11 phone uses the new format, go to Settings > Camera > Formats. “High Efficiency” is new and “Most Compatible” is the old / current.

    I do not suggest to just turn this off; hey – getting files half the size is super cool. Just realize that if you use the photos outside of your phone that there might be temporary issues with viewing.

    Windows and OneDrive do not yet support the new formats.

    h/t ninob

  3. Yammer and Dynamics CRM apps have not yet been updated for iOS 11.

    There are a wide variety of Intune changes/impacts with iOS 11:

    Support Tip: Intune Support for iOS 11

    Perhaps the two things most notable to your users: [3a] An updated Company Portal and Managed Browser are required for iOS 11, and [3b] Drag-and-drop (a new feature of iOS 11) is disabled when a device is enrolled with Intune.

    h/t briand


That’s all for now. Please follow me on twitter! @EssentialExch

First post..

This is the first post to this blog.

I  have replaced my 10+ year blog, named http://TheEssentialExchange.com/blogs/michael with this blog.

I have a backup of the old blog and it’s 1,600 posts from those years. Once I figure out how to get it into a WordPress database, I will!


Follow me on twitter! EssentialExch

Clustersize, Blocksize, and Allocation Unit Size

Depending on where you see the terms used in Windows, you may see different names for the same thing. The clustersize of a volume, the blocksize of a volume, and the allocation unit size of the volume are all referring to the same value. Some tools report the blocksize as the physical blocksize, instead of the volume blocksize, which can lead to confusion between those terms. In this article, we will use the term clustersize.

Regardless of whether your disk volumes are for holding Exchange databases and logfiles, SQL databases and logfiles, Hyper-V VMs, or other large files; there is something you should do in order to properly optimize your access to those files. Adjust your clustersize. While we focus on Exchange here, most of the same ideas and concepts apply to many other applications.

The clustersize is used for several different things. What we care about:

  • The Master File Table (MFT) allocates storage on a disk volume based on the clustersize (that is, when a program requests to extend a file, the file is extended in multiples of the clustersize).
  • Exchange reads and writes to a disk volume based on multiples of the clustersize. Exchange also attempts to keep clusters contiguous.
  • Exchange allocates storage for logfiles and databases based on multiples of the clustersize. For example, a logfile is 1 MB in size (1 048 576 bytes). Exchange will allocate the 1 MB for a logfile all at once. If your clustersize is 4 096 (4 KB, the default) then the file consumes 256 clusters. If your clustersize is 65 536 (64 KB), then the file consumes 16 clusters. It is far more likely that 16 clusters can be allocated contiguously than 256 clusters.
  • Disk fragmentation is based on the contiguous or discontiguous placement of clusters.

The Exchange Server preferred architecture (PA) has long stated that logfiles and databases should reside on disk volumes where the clustersize is 64 KB. This is also true for the SQL Server PA. In earlier releases of Windows Server, it was also necessary to ensure that the initial location on a physical disk occur at a multiple of 64 KB. Beginning with Windows Server 2008 (??, I think), the OS automatically begins the initial allocation at the 1 MB boundary (which is an even multiple of 64 KB, as shown above).

In order to create a 64 KB volume, you can use whatever tool you prefer: format.com, diskpart.exe, WMI/CIM, or PowerShell’s Format-Volume. Or, if you don’t need to script this, you can use the GUI.

However, determining the clustersize isn’t the easiest thing in the world. There is no obvious place to find this value in the GUI. There is no obvious place to find this in PowerShell Get-* cmdlets (Get-Volume and Get-Partition do not return this information). If you want to go old-school, you use fsutil.exe in this manner:

PS C:\> fsutil.exe fsinfo ntfsinfo D:
NTFS Volume Serial Number :       0x2edec3c5dec38395
NTFS Version   :                  3.1
LFS Version    :                  2.0
Number Sectors :                  0x0000000c473befff
Total Clusters :                  0x00000000188e77df
Free Clusters  :                  0x00000000188e69ce
Total Reserved :                  0x0000000000000000
Bytes Per Sector  :               512
Bytes Per Physical Sector :       512
Bytes Per Cluster :               65536
Bytes Per FileRecord Segment    : 1024
Clusters Per FileRecord Segment : 0
Mft Valid Data Length :           0x0000000000010000
Mft Start Lcn  :                  0x000000000000c000
Mft2 Start Lcn :                  0x0000000000000001
Mft Zone Start :                  0x000000000000c000
Mft Zone End   :                  0x000000000000cca0
Resource Manager Identifier :     5012D937-F3EB-11E4-80BF-549F35094798
PS C:\>

And we see our answer in “Bytes Per Cluster”. If this is NOT the value we see, then we need to re-format the drive.

However, there is certainly a way to obtain this using PowerShell (or VBScript, for that matter). We switch to Windows Management Instrumentation (WMI). You perform the query this way:

PS C:\> Get-WmiObject -Class Win32_Volume  | ft -auto Label, Blocksize, Name

Label           Blocksize Name
-----           --------- ----
System Reserved      4096 \\?\Volume{998bc8e0-edd0-11e4-80b5-806e6f6e6963}
Data                65536 D:
                     4096 C:
PS C:\>

Again, if our data volume does not have a value of 64 KB, then we will need to reformat it.

Why the big deal about a larger clustersize? Well, it’s about efficiency (reducing fragmentation and optimizing I/O) and – perhaps surprisingly – that with low values of clustersize, it is possible to have a disk with lots of empty space – but files can no longer grow on the disk volume. This is because of an “artifact” of the MFT called the FAL (the File Attribute List). The FAL has a fixed maximum size of 256 KB. Among other things, the FAL keeps track of how many clusters are assigned to a given file. With the limit of 256 KB, the FAL can “only” store about one-and-a-half million fragmented clusters. On a volume with 4 KB clusters, the FAL will run out of space around at well less than 1 TB (depending on the overall fragmentation level of the volume). I have seen this occur with a customer on a database volume with a database size of less than 400 GB.

There used to be a KB article discussing this issue, but I can no longer find it online. Perhaps your favorite search engine can help you locate it. 🙂 KB 967351.

Exchange 2016 supports ReFS, which has a fixed clustersize of 64 KB (nothing smaller, nothing larger). According to recent postings about Exchange 2016, the Exchange 2016 Preferred Architecture will use ReFS instead of NTFS.

Please follow me on Twitter: @essentialexch

Postscript:
You’ll notice in the PowerShell example above that C:\ does not have a Label associated with it. In Windows Server 2012 R2, this is easily fixed in PowerShell:

PS C:\> Set-Volume -DriveLetter C -NewFileSystemLabel System
PS C:\> Get-WmiObject -Class Win32_Volume  | ft -auto Label, Blocksize, Name

Label           Blocksize Name
-----           --------- ----
System Reserved      4096 \\?\Volume{998bc8e0-edd0-11e4-80b5-806e6f6e6963}
Data                65536 D:
System               4096 C:
PS C:\> 

 

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