Monday, March 14, 2011

Unix Tail-like Functionality in PowerShell Revisited

My first attempt to replicate tail for PowerShell, which I wrote about in "Unix Tail-like Functionality in PowerShell", was horribly inefficient once you got past a couple dozen lines. This makes since given the method I was using -- a byte by byte reverse read of a text file, converting each byte at a time to ASCII. I knew the solution was to "wolf down" large byte chunks and process them as a whole. Using System.Text.ASCIIEncoding.GetString, I am doing that just after reading into memory multiple bytes using System.IO.FileStream.Read. With this change in methodology, I am getting to within 3% of the speed of tail in UNIX in my tests. The largest test I've performed was returning 1,000,000 lines from a 850MB log file. A Mac OS X 10.6.6 workstation performed the task in 16 seconds using tail and a Windows Server 2003 server returned in 17 seconds using this method. Good enough for me. Most of my needs are in the thousands of lines which I am able to return in hundreds of milliseconds which is perfect my monitoring scripts in Nagios. Compared to my previous attempt, this is a Lockheed SR-71 vs. a Wright Brothers Flyer. A small 5,000 tail using the old code took 5 1/2 minutes to return while this code returned the same lines in 200 milliseconds. Huge difference!

In the code sample below, I am using 10 kilobytes for that chunking. I found that number suited most of my needs. However, you can greatly increase that number for large number of lines to be returned (I used 4MB for my million line test). You can also do a little automatic tuning by altering the number of bytes using the number of lines you are seeking. One thing to be aware when passing files to this code, if you pass a file to System.IO.File/FileStream without a full path, it will not assume the file is located in the path of the executed script so Test-Path is not a valid test. Using System.IO.Directory.GetCurrentDirectory, you can find this by running the following in PowerShell:
[System.IO.Directory]::GetCurrentDirectory()
More than likely, it will point to the home directory of the profile the shell is executed under.

Also be aware that this tail-like function does not handle unicode log files. The method I am using to decode the bytes is ASCII dependent. I am not using System.Text.UnicodeEncoding yet in the code. Currently ASCII meets all my needs for reading log files but I am still interested in adding compatibility to this function. I am also assuming that all log files denote the end of a line using carriage return & line feed (CHR 13 + CHR 10) which is how majority of text files are written in Windows. UNIX & old style Macintosh text files will not work properly with this code. You will need to modify line 23 to change the delimiter for the split for those text file formats.

UPDATE: I have now finished an update that provides the "tail -f" functionality for continuously reading the updates to a text file. Read about it in my blog post, Replicating UNIX "tail -f" in PowerShell.

UPDATE: I have updated the code to handle unicode text files and non-Windows new lines. You can review the code here.
Function Read-EndOfFileByByteChunk($fileName,$totalNumberOfLines,$byteChunk) {
 if($totalNumberOfLines -lt 1) { $totalNumberOfLines = 1 }
 if($byteChunk -le 0) { $byteChunk = 10240 }
 $linesOfText = New-Object System.Collections.ArrayList
 if([System.IO.File]::Exists($fileName)) {
  $fileStream = New-Object System.IO.FileStream($fileName,[System.IO.FileMode]::Open,[System.IO.FileAccess]::Read,[System.IO.FileShare]::ReadWrite)
  $asciiEncoding = New-Object System.Text.ASCIIEncoding
  $fileSize = $fileStream.Length
  $byteOffset = $byteChunk
  [byte[]] $bytesRead = New-Object byte[] $byteChunk
  $totalBytesProcessed = 0
  $lastReadAttempt = $false
  do {
   if($byteOffset -ge $fileSize) {
    $byteChunk = $fileSize - $totalBytesProcessed
    [byte[]] $bytesRead = New-Object byte[] $byteChunk
    $byteOffset = $fileSize
    $lastReadAttempt = $true
   }
   $fileStream.Seek((-$byteOffset), [System.IO.SeekOrigin]::End) | Out-Null
   $fileStream.Read($bytesRead, 0, $byteChunk) | Out-Null
   $chunkOfText = New-Object System.Collections.ArrayList
   $chunkOfText.AddRange(([System.Text.RegularExpressions.Regex]::Split($asciiEncoding.GetString($bytesRead),"\r\n")))
   $firstLineLength = ($chunkOfText[0].Length)
   $byteOffset = ($byteOffset + $byteChunk) - ($firstLineLength)
   if($lastReadAttempt -eq $false -and $chunkOfText.count -lt $totalNumberOfLines) {
    $chunkOfText.RemoveAt(0)
   }
   $totalBytesProcessed += ($byteChunk - $firstLineLength)
   $linesOfText.InsertRange(0, $chunkOfText)
  } while($totalNumberOfLines -ge $linesOfText.count -and $lastReadAttempt -eq $false -and $totalBytesProcessed -lt $fileSize)
  $fileStream.Close()
  if($linesOfText.count -gt 1) {
   $linesOfText.RemoveAt($linesOfText.count-1)
  }
  $deltaLines = ($linesOfText.count - $totalNumberOfLines)
  if($deltaLines -gt 0) {
   $linesOfText.RemoveRange(0, $deltaLines)
  }
 } else {
  $linesOfText.Add("[ERROR] $fileName not found") | Out-Null
 }
 return $linesOfText
}
#--------------------------------------------------------------------------------------------------#
$fileName = "C:\Logs\really-huge.log" # Your really big log file
$numberOfLines = 100 # Number of lines from the end of the really big log file to return
$byteChunk = 10240 # Size of bytes read per seek during the search for lines to return
####################################################################################################
## This is a possible self-tuning method you can use but will blow up memory on an enormous 
## number of lines to return
## $byteChunk = $numberOfLines * 256 
####################################################################################################
$lastLines = @()

$lastLines = Read-EndOfFileByByteChunk $fileName $numberOfLines $byteChunk
foreach($lineOfText in $lastLines) {
 Write-Output $lineOfText
}

Thursday, March 10, 2011

Write Excel Spreadsheets Fast in PowerShell

If you try to populate an Excel spreadsheet cell by cell using PowerShell expect it to take a long time. As I showed in a previous post, "Speed Up Reading Excel Files in PowerShell", when dealing with Excel work with comma separated value text files. The population of the cells appears to be the huge bottleneck in Excel permformace but if you let Excel load a comma separated values file, you can quickly format and manipulate the prepopulated cells.

One example from my own work involves a spreadsheet with ~3,500 rows. If I populate the rows directly through Excel it can take upwards of five minutes to complete the process. If I dump the data to a comma separated values text file then import it into Excel, it takes about 2 seconds -- a huge time savings. When you are running ad hoc reports, it pays off. If you want an excuse to head down to Starbucks from your office, it doesn't.

To demonstrate this technique, I will take a previous post, "BlackBerry User Report", and change the report from a comma separated values text file to Excel spreadsheet. First to make the final result look nice, the NoteProperty names are cleaned up so they make attractive headers in the spreadsheet. Swap out the following lines to achieve this.
   Add-Member -inputObject $blackberryUser -type NoteProperty -name "Domain" -value $domain
   Add-Member -inputObject $blackberryUser -type NoteProperty -name "User ID" -value $sAMAccountName
   Add-Member -inputObject $blackberryUser -type NoteProperty -name "First Name" -value $firstName
   Add-Member -inputObject $blackberryUser -type NoteProperty -name "Last Name" -value $firstName
   Add-Member -inputObject $blackberryUser -type NoteProperty -name "Display Name" -value $displayName
   Add-Member -inputObject $blackberryUser -type NoteProperty -name "E-Mail Address" -value $proxyAddress
   Add-Member -inputObject $blackberryUser -type NoteProperty -name "PIN" -value $pin
   Add-Member -inputObject $blackberryUser -type NoteProperty -name "Cell Phone Number" -value $phoneNumber
   Add-Member -inputObject $blackberryUser -type NoteProperty -name "Desk Phone Number" -value $telephoneNumber
   Add-Member -inputObject $blackberryUser -type NoteProperty -name "Street Address" -value $streetAddress
   Add-Member -inputObject $blackberryUser -type NoteProperty -name "City" -value $city
   Add-Member -inputObject $blackberryUser -type NoteProperty -name "State" -value $state
   Add-Member -inputObject $blackberryUser -type NoteProperty -name "Zip Code" -value $zipCode
   Add-Member -inputObject $blackberryUser -type NoteProperty -name "Country" -value $country
   Add-Member -inputObject $blackberryUser -type NoteProperty -name "BlackBerry Model" -value $modelName
   Add-Member -inputObject $blackberryUser -type NoteProperty -name "Carrier" -value $homeNetwork
   Add-Member -inputObject $blackberryUser -type NoteProperty -name "IMEI" -value $imei
   Add-Member -inputObject $blackberryUser -type NoteProperty -name "Password Enabled" -value $passwordEnabled
   Add-Member -inputObject $blackberryUser -type NoteProperty -name "Exchange Server" -value $exchangeServer
   Add-Member -inputObject $blackberryUser -type NoteProperty -name "BlackBerry Server" -value $blackberryServer
Now that you have nice and pretty column headers in your NoteProperty, its time to write out that Excel spreadsheet. This code assumes that you have at least Excel 2007 installed on your scripting workstation/server. First the code writes out to the temp directory using a GUID (just to be fancy) for the comma separated values file's name. We create an Excel object and load that temporary file. At that point, we can save out a fairly boring Excel spreadsheet. That won't impress anyone. If you want to look "pro" when you distribute reports, having a good layout is key. You want to save out a nicely formatted spreadsheet. So you autofit all the columns to longest cell in each, populate the worksheet's name, update some of the metadata in the spreadsheet and then apply a table style. Table styles are a quick way to add color to your spreadsheet with minimal effort by creating a list object. I have found that if you want to apply your own custom style (a future blog post) there is no speed penalty as long as the data is prepopulated. And finally we perform a Save As, remind Excel that we are saved, quit Excel and do some garbage cleanup. If you just quit Excel programatically, you will find that Excel will still be in memory.
$blackberryUsers = $blackberryUsers | Sort-Object "BlackBerry Server", "Last Name", "First Name"

$excelFile = ("\\fileserver.ad.mydomain.local\it_reports\blackberry\" + (Get-Date -format yyyyMMdd) + "-BlackBerry User Report.xlsx")

$temporaryCsvFile = ($env:temp + "\" + ([System.Guid]::NewGuid()).ToString() + ".csv")
$blackberryUsers | Export-Csv -path $temporaryCsvFile -noTypeInformation

if(Test-Path -path $excelFile) { Remove-Item -path $excelFile }

$excelObject = New-Object -comObject Excel.Application
$excelObject.Visible = $false  

$workbookObject = $excelObject.Workbooks.Open($temporaryCsvFile) 
$workbookObject.Title = ("BlackBerry User List for " + (Get-Date -Format D))
$workbookObject.Author = "Robert M. Toups, Jr."

$worksheetObject = $workbookObject.Worksheets.Item(1) 
$worksheetObject.UsedRange.Columns.Autofit() | Out-Null
$worksheetObject.Name = "BlackBerry Users"

$listObject = $worksheetObject.ListObjects.Add([Microsoft.Office.Interop.Excel.XlListObjectSourceType]::xlSrcRange, $worksheetObject.UsedRange, $null,[Microsoft.Office.Interop.Excel.XlYesNoGuess]::xlYes,$null)
$listObject.Name = "User Table"
$listObject.TableStyle = "TableStyleMedium4" # Style Cheat Sheet in French/English: http://msdn.microsoft.com/fr-fr/library/documentformat.openxml.spreadsheet.tablestyle.aspx

$workbookObject.SaveAs($excelFile,51) # http://msdn.microsoft.com/en-us/library/bb241279.aspx
$workbookObject.Saved = $true
$workbookObject.Close()
[System.Runtime.Interopservices.Marshal]::ReleaseComObject($workbookObject) | Out-Null

$excelObject.Quit()
[System.Runtime.Interopservices.Marshal]::ReleaseComObject($excelObject) | Out-Null
[System.GC]::Collect()
[System.GC]::WaitForPendingFinalizers() 

if(Test-Path -path $temporaryCsvFile) { Remove-Item -path $temporaryCsvFile }

Tuesday, March 8, 2011

Speed Up Reading Excel Files in PowerShell

One of the most frustrating aspects of PowerShell for me is the extremely sluggish performance the ComObject is for Excel. I deal with spreadsheets on a daily basis and have processes to read them and compare information. I have spent hours upon hours trying to figure out how to optimize the process of reading cells. One day, I said to myself, "Why can't I read Excel documents at near the speed I can read a text file?" (in more colorful language than this). The proverbial lightbulb went off above my head. That one question lead to the code below. If I can't read Excel files at the speed I want and I can with text files, why not convert the Excel spreadsheet to a text based format? A comma separated value text file!

Using PowerShell to load an Excel spreadsheet is quick. The slowdown is reading the cells/rows. So the solution is to load the Excel spreadsheet and immediately save it in the temp directory as a .csv file. PowerShell has a wonderful commandlet called Import-Csv that will allow you to read in a comma separated value text file as PSObject and associate the first row as a NoteProperty for each column in the spreadsheet. As long as your Excel spreadsheet is straight forward, this works perfectly and blazing fast. I have some processes that are now 20 times faster using this method.
Function Remove-File($fileName) {
 if(Test-Path -path $fileName) { Remove-Item -path $fileName }
}
#--------------------------------------------------------------------------------------------------#
$excelFile = "\\server.ad.mydomain.local\excelreports\myreport.xlsx"
if(Test-Path -path $excelFile) {
 $csvFile = ($env:temp + "\" + ((Get-Item -path $excelFile).name).Replace(((Get-Item -path $excelFile).extension),".csv"))
 
 Remove-File $csvFile
 
 $excelObject = New-Object -ComObject Excel.Application   
 $excelObject.Visible = $false  
 $workbookObject = $excelObject.Workbooks.Open($excelFile) 
 $workbookObject.SaveAs($csvFile,6) # http://msdn.microsoft.com/en-us/library/bb241279.aspx
 $workbookObject.Saved = $true
 $workbookObject.Close()
 [System.Runtime.Interopservices.Marshal]::ReleaseComObject($workbookObject) | Out-Null
 $excelObject.Quit()
 [System.Runtime.Interopservices.Marshal]::ReleaseComObject($excelObject) | Out-Null
 [System.GC]::Collect()
 [System.GC]::WaitForPendingFinalizers() 
 
 $spreadsheetDataObject = Import-Csv -path $csvFile # Use the $spreadsheetDataObject for your analysis
 
 Remove-File $csvFile
}

Monday, March 7, 2011

Unix Tail-like Functionality in PowerShell

A common tool I use in shell scripts on Unix/Linux/Mac OS X servers is tail. While there are command-line tail conversions for Windows, I need something I can integrate into a script for reading the end of large log files, search for information and act on that result. I don't want to distribute third party software along with the script to accomplish the task. Get-Content and Select-Object are not suitable for large files.

After researching the capabilities of File IO in .Net, I found that System.IO.FileStream class had just what I needed. Using this class, I read the target text file byte by byte from the end of the file until I reach a selected number of lines of text delimited by a carriage return. The amount of time it takes to obtain the data is related to the number of characters per line. It works very well in 500 lines or less in my typical log files (I tested up to 1 gigabyte) and much faster than using:
Get-Content "C:\Logs\really-huge.log" | Select-Object -last 100
The code meets 95% of my needs but I am sure I can optimize it so it comes close to matching the speed of tail from the Unix distributions I commonly use. It's my first stab at tackling the problem. One interesting part of the code is that I use System.Collections.ArrayList instead of a standard PowerShell array. The reason is since I am reading the file in reverse, I need to return the data back in the proper order. The ArrayList object allows me to insert into the first element so I don't have to re-write the array in the right order after collecting the data. Also I noticed that using System.Convert to covert the bytes to a character instead of using PowerShell's native [char] was faster. Returning large number of lines, it was significant -- about .5 seconds per 100 lines.

I will keep working on this to close that 5% and update this post with a link to an updated blog post in the future with the improvements.

UPDATE: I have rewritten this function in a new blog post and it is lightning fast. This code is deprecated and should only be used for amusement purposes.
Function Read-EndOfFile($fileName,$totalNumberOfLines) {
 $fileStream = New-Object System.IO.FileStream($fileName,[System.IO.FileMode]::Open,[System.IO.FileAccess]::Read,[System.IO.FileShare]::ReadWrite)
 $linesOfText = New-Object System.Collections.ArrayList
 $byteOffset = 1
 $lineOfText = ""
 do {
   $fileStream.Seek((-$byteOffset), [System.IO.SeekOrigin]::End) | Out-Null
  $byte = $fileStream.ReadByte()
  if($byte -eq 13) {
  } elseif($byte -eq 10) {
   $linesOfText.Insert(0, $lineOfText)
   $lineOfText = ""
  } else {
   $lineOfText = [System.Convert]::ToChar($byte) + $lineOfText
  }
  $byteOffset++
 } while ($linesOfText.count -le $totalNumberOfLines)
 $fileStream.Close()
 return $linesOfText
}
#--------------------------------------------------------------------------------------------------#
$fileName = "C:\Logs\really-huge.log" # Your really big log file
$numberOfLines = 100 # Number of lines from the end of the really big log file to return

if([System.IO.File]::Exists($fileName) -and $numberOfLines -gt 0) {
 $lastLines = Read-EndOfFile $fileName $numberOfLines

 foreach($lineOfText in $lastLines) {
  Write-Output $lineOfText
 }
}

SharePoint 2010 and Social Collaboration

Below is a webinar that I attended hosted by Gig Werks focused on the Social Collaboration features in SharePoint 2010. I found it to be a good introduction to the built-in capabilities of the product.

Thursday, March 3, 2011

Quick Organizational Chart from Active Directory

Building on my blog post, Export Manager's Nested Direct Reports, I quickly threw together a revision of the code that will create visual (ASCII at least) organizational chart based on the directReports attribute of an employee in Active Directory. The nesting method I am using could easy be converted to simulate the "tree" DOS command using Get-ChildObject on file systems or about any programming challenge that deals with nested objects. The one main fault of the code, is that it is not pretty when it gets to the last nested object of a parent. It leaves the "|" in front of the last nested subordinate object. A little more work and I could clean this presentation issue, but not today.
Function Get-DirectReports($distinguishedName,$level) {
 $managerObject = Get-ActiveDirectoryObject $distinguishedName
 if($managerObject.directReports.count -gt 0) {
  foreach($directReport in $managerObject.directReports) {
   $directReportObject = Get-ActiveDirectoryObject $directReport
   Write-Output (("|  " * $level) + "|")
   Write-Output (("|  " * $level) + "+-" + ($directReportObject.givenName).ToString() + " " + ($directReportObject.sn).ToString())
   Get-DirectReports $directReport ($level + 1)
  }
 }
}

Function Get-LocalDomainController($objectDomain) {
 return ([System.DirectoryServices.ActiveDirectory.ActiveDirectorySite]::GetComputerSite()).Servers | Where-Object { $_.Domain.Name -eq $objectDomain } | ForEach-Object { $_.Name } | Select-Object -first 1
}
   
Function Get-ObjectADDomain($distinguishedName) {
 return ((($distinguishedName -replace "(.*?)DC=(.*)",'$2') -replace "DC=","") -replace ",",".")
}
   
Function Get-ActiveDirectoryObject($distinguishedName) {
 return [ADSI]("LDAP://" + (Get-LocalDomainController (Get-ObjectADDomain $distinguishedName)) + "/" + ($distinguishedName -replace "/","\/"))
}
#--------------------------------------------------------------------------------------------------#
Set-Variable -name forestRootDn -option Constant -value ([ADSI]("LDAP://" + (([System.DirectoryServices.ActiveDirectory.Forest]::GetCurrentForest()).name) + "/rootDSE")).defaultNamingContext
#--------------------------------------------------------------------------------------------------#
$objectConnection = New-Object -comObject "ADODB.Connection"
$objectCommand = New-Object -comObject "ADODB.Command"
$objectConnection.Open("Provider=ADsDSOObject;")
$objectCommand.ActiveConnection = $objectConnection

$manager = "the.ceo@mycompany.local"
 
$ldapBase = "GC://$forestRootDn"
$ldapAttr = "distinguishedName"
$ldapScope = "subtree"
$ldapFilter = "(&(objectClass=user)(proxyAddresses=smtp:$manager))"
$ldapQuery = "<$ldapBase>;$ldapFilter;$ldapAttr;$ldapScope"
$objectCommand.CommandText = $ldapQuery
$objectRecordSet = $objectCommand.Execute()
 
while(!$objectRecordSet.EOF) {
 $firstLevelObject = Get-ActiveDirectoryObject $objectRecordSet.Fields.Item('distinguishedName').Value
 $topLevelManager = (($firstLevelObject.givenName).ToString() + " " + ($firstLevelObject.sn).ToString())
 Write-Output $topLevelManager
 Get-DirectReports $firstLevelObject.distinguishedName 0
 $objectRecordSet.MoveNext()
}

Friday, February 25, 2011

IP Address to Binary

When I was teaching myself subnetting some time ago, I wrote a perl script to assist in learning the conversion. There are many ways to do this but I wanted a method to help me learn to do the conversion in my head. This is why the subroutine is written out as collection of if than statements to represent the bits in the octet.
#!/usr/bin/perl
# use: ./ip2binary.pl 192.168.1.1
use strict;

my $ip = $ARGV[0];

if($ip =~ /\b(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})\b/) {
 my @binary;
 my @octets = split(/\./,$ip);

 foreach my $octet (@octets) {
  push (@binary, return_binary($octet));
 }

 print join(".",@binary) . "\n";
} else {
 print "$ip is not a valid IP\n";
}

exit;

sub return_binary {
 my $decimal = $_[0];
 my @binary;
 if($decimal >= 128) {
  $binary[0] = 1;
  $decimal -= 128;
 } else {
  $binary[0] = 0;
 }

 if($decimal >= 64) {
  $binary[1] = 1;
  $decimal -= 64;
 } else {
  $binary[1] = 0;
 }

 if($decimal >= 32) {
  $binary[2] = 1;
  $decimal -= 32;
 } else {
  $binary[2] = 0;
 }

 if($decimal >= 16) {
  $binary[3] = 1;
  $decimal -= 16;
 } else {
  $binary[3] = 0;
 }

 if($decimal >= 8) {
  $binary[4] = 1;
  $decimal -= 8;
 } else {
  $binary[4] = 0;
 }

 if($decimal >= 4) {
  $binary[5] = 1;
  $decimal -= 4;
 } else {
  $binary[5] = 0;
 }

 if($decimal >= 2) {
  $binary[6] = 1;
  $decimal -= 2;
 } else {
  $binary[6] = 0;
 }

 if($decimal >= 1) {
  $binary[7] = 1;
  $decimal -= 1;
 } else {
  $binary[7] = 0;
 }

 return join("",@binary);

}

Thursday, February 24, 2011

Nagios Check for Forefront Client Security Signature Definitions

I have used Nagios for nearly 8 years and found it to be a great monitoring tool. Its flexible and allows you to monitor a heterogenous server environment with custom system checks tailored to your needs. For Windows Servers, the NSClient++ secure monitoring client allows you to go farther than simple ping, port and SNMP queries and execute scripts for monitoring services. Below is a VBScript I wrote to monitor the Forefront Client Security (FCS) Virus Signature freshness on a server by checking the age of the downloaded definition file. While you can monitor this directly in the FCS console, it becomes burdensome to monitor every product by its individual monitoring console. Integration into one monitoring platform saves a considerable amount of time and allows for straight forward escalation practices when trouble arises. One tool for evaluating your entire Enterprise health.
Const contHkeyLocalMachine = &H80000002
Const contReturnNormal = 0
Const contReturnWarning = 1
Const contReturnError = 2
Const contReturnUnknown = 3
 
strServer = "."
 
strToday = FormatDateTime(Date, 0)
strVirusDefinitionFilePath = NULL
Set objReg = GetObject("winmgmts:{impersonationLevel=impersonate}!\\" & strServer & "\root\default:StdRegProv")
 
strKeyPath = "SOFTWARE\Microsoft\Microsoft Forefront\Client Security\1.0\AM\Signature Updates"
strSignatureLocation = "SignatureLocation"
objReg.GetStringValue contHkeyLocalMachine,strKeyPath,strSignatureLocation,strVirusDefinitionFilePath
 
strVirusDefinitionFile = strVirusDefinitionFilePath & "\mpavdlta.vdm"
 
If strVirusDefinitionFile = "\mpavdlta.vdm" Then
 strReturnValue = contReturnError
 strResult = "Cannot determine location directory for mpavdlta.vdm from Registry. FCS might not have received its first update. Visit http://support.microsoft.com/kb/935934 to quickly resolve."
 WScript.Echo strResult
 WScript.Quit (strReturnValue)
End If
 
Set objFso = CreateObject("Scripting.FileSystemObject")
Set objFile = objFso.GetFile(strVirusDefinitionFile)
 
strVirusSignatureDate = FormatDateTime(objFile.DateCreated, 0)
 
intSignatureDefinitionDiff = DateDiff("d",strVirusSignatureDate,StrToday)
 
If intSignatureDefinitionDiff > 3 Then
 strReturnValue = contReturnError
ElseIf intSignatureDefinitionDiff > 1 Then
 strReturnValue = contReturnWarning
ElseIf intSignatureDefinitionDiff < 2 Then
 strReturnValue = contReturnNormal
Else
 strReturnValue = contReturnUnknown
End If
 
If intSignatureDefinitionDiff > 1 Then
 strResult = "Virus Signatures Last Updated: " & strVirusSignatureDate & " (" & intSignatureDefinitionDiff & " days ago)"
ElseIf intSignatureDefinitionDiff = 0 Then
 strResult = "Virus Signatures Last Updated: " & strVirusSignatureDate & " (Today)"
Else
 strResult = "Virus Signatures Last Updated: " & strVirusSignatureDate & " (" & intSignatureDefinitionDiff & " day ago)"
End If
 
WScript.Echo strResult
WScript.Quit (strReturnValue)

Wednesday, February 23, 2011

Export Manager's Nested Direct Reports

In the code sample below, I create an export in two flavors for the nested direct reports of managers. By taking a text file listing the e-mail addresses of user objects in a forest, this code proceeds to drill recursively through the mutli-valued string directReports attribute of the object until it reaches a nested user object with that attribute set to null. After obtaining all the distinguished names, the code proceeds to perform LDAP lookups to obtain relevant attribute data. At the conclusion, it saves two files, a comma separated values text file with all the relevant attributes and an HTML file with a subset of those attributes.

The concept for this script was done before PowerShell V2 was released and provided better typing including 64 bit integers. Like in my older Perl and VBScript code dealing with Active Directory attributes that stored 64 bit integers like accountExpires, it required manipulating the return data in order to perform comparisons. The code below contains a Function written by Adam Weigert which has saved me the time translating the same conversion I've done before in Perl and VBScript. A link to his blog entry on the topic is commented in the code for your review.
param([string]$file)

Function Get-DirectReports($objectDn, $distinguishedNames) {
 $distinguishedNames += $objectDn
 $managerObject = Get-ActiveDirectoryObject $objectDn
 if($managerObject.directReports.count -gt 0) {
  foreach($directReport in $managerObject.directReports) {
   $distinguishedNames = Get-DirectReports $directReport $distinguishedNames
  }
 }
 return $distinguishedNames
}
Function Read-UserStatus($userFlags) {
 if ($userFlags -band 0x0002) {
  $enabled = $false
    } else {
  $enabled = $true
 } 
 return $enabled
}
Function Convert-ADSLargeInteger([object]$adsLargeInteger) { # http://weblogs.asp.net/adweigert/archive/2007/03/23/powershell-convert-active-directory-iadslargeinteger-to-system-int64.aspx
 $highPart = $adsLargeInteger.GetType().InvokeMember("HighPart", [System.Reflection.BindingFlags]::GetProperty, $null, $adsLargeInteger, $null)
 $lowPart  = $adsLargeInteger.GetType().InvokeMember("LowPart",  [System.Reflection.BindingFlags]::GetProperty, $null, $adsLargeInteger, $null)

 $bytes = [System.BitConverter]::GetBytes($highPart)
 $temp   = [System.Byte[]]@(0,0,0,0,0,0,0,0)
 [System.Array]::Copy($bytes, 0, $temp, 4, 4)
 $highPart = [System.BitConverter]::ToInt64($temp, 0)

 $bytes = [System.BitConverter]::GetBytes($lowPart)
 $lowPart = [System.BitConverter]::ToUInt32($bytes, 0)
 
 return $lowPart + $highPart
}
Function Get-LocalDomainController($objectDomain) {
 return ([System.DirectoryServices.ActiveDirectory.ActiveDirectorySite]::GetComputerSite()).Servers | Where-Object { $_.Domain.Name -eq $objectDomain } | ForEach-Object { $_.Name } | Select-Object -first 1
}
  
Function Get-ObjectADDomain($distinguishedName) {
 return ((($distinguishedName -replace "(.*?)DC=(.*)",'$2') -replace "DC=","") -replace ",",".")
}
  
Function Get-ActiveDirectoryObject($distinguishedName) {
 return [ADSI]("LDAP://" + (Get-LocalDomainController (Get-ObjectADDomain $distinguishedName)) + "/" + ($distinguishedName -replace "/","\/"))
}
#--------------------------------------------------------------------------------------------------#
Set-Variable -name forestRootDn -option Constant -value ([ADSI]("LDAP://" + (([System.DirectoryServices.ActiveDirectory.Forest]::GetCurrentForest()).name) + "/rootDSE")).defaultNamingContext
#--------------------------------------------------------------------------------------------------#
if(![bool]$file) {
 Write-Host "You are missing the `"-file`" command line argument" -foregroundColor Red
 Write-Host ""
 Write-Host "This is the file that contains the list of employee e-mail addresses"
 Write-Host ""
 while((![bool]$file)) {
  $file = Read-Host -prompt "`tFile"
 }
 Write-Host ""
}
if(!(Test-Path -path $file)) {
 Write-Host "Unable to locate: $file" -foregroundColor Red
 Write-Host ""
 Write-Host "Please review the correct path"
 Write-Host ""
 while(!(Test-Path -path $file)) {
  $file = Read-Host -prompt "`tFile"
 }
 Write-Host ""
}
   
$answer = $null
while($answer -ne "Y" -and $answer -ne "N") {
 $answer = Read-Host -prompt "Are you sure you want to use $file (Y/N)?"
}
   
if($answer -eq "N") {
 Write-Host ""
 Write-Host "Quiting..." -foregroundColor Yellow
 exit
}
 
$outputFile = (((Get-Item -path $file).name).Replace(((Get-Item -path $file).extension),".csv"))
 
$managers = Get-Content -path $file

$objectConnection = New-Object -comObject "ADODB.Connection"
$objectCommand = New-Object -comObject "ADODB.Command"
$objectConnection.Open("Provider=ADsDSOObject;")
$objectCommand.ActiveConnection = $objectConnection

$distinguishedNames = @()
$users = @()

foreach($manager in $managers) {

 $ldapBase = "GC://$forestRootDn"
 $ldapAttr = "distinguishedName"
 $ldapScope = "subtree"
 $ldapFilter = "(&(objectClass=user)(ProxyAddresses=smtp:$manager))"
 $ldapQuery= "<$ldapBase>;$ldapFilter;$ldapAttr;$ldapScope"
 $objectCommand.CommandText = $ldapQuery
 $objectRecordSet = $objectCommand.Execute()

 while(!$objectRecordSet.EOF) {
  $distinguishedNames = Get-DirectReports $objectRecordSet.Fields.Item('distinguishedName').Value $distinguishedNames
  $objectRecordSet.MoveNext()
 }
}

foreach($distinguishedName in $distinguishedNames) {
 $userObject = Get-ActiveDirectoryObject $distinguishedName

 if($userObject.objectClass -eq "User") {
  $accountExpires = (Convert-ADSLargeInteger $userObject.accountExpires[0])

  if($accountExpires -ne 0 -and $accountExpires -ne 9223372036854775807) {
   $account_expiration = (Get-Date ($userObject.psbase.invokeget("AccountExpirationDate")) -Format d)
  } else {
   $account_expiration = "N/A"
  }
 
  if($userObject.manager) {
   $manager_object = Get-ActiveDirectoryObject $userObject.manager
   if($manager_object.displayName) {
    $manager = ($manager_object.displayName).ToString()
   } else {
    $manager = ($manager_object.name).ToString()
   }
  } else {
   $manager = "N/A"
  }
  
  $user = New-Object -typeName PSObject
  Add-Member -inputObject $user -type NoteProperty -name "domain" -value ((Get-ObjectADDomain $userObject.distinguishedName).Split(".")[0]).ToUpper()
  Add-Member -inputObject $user -type NoteProperty -name "sAMAccountName" -value (($userObject.sAMAccountName).ToString()).ToLower()
  Add-Member -inputObject $user -type NoteProperty -name "givenName" -value ($userObject.givenName).ToString()
  Add-Member -inputObject $user -type NoteProperty -name "sn" -value ($userObject.sn).ToString()
  Add-Member -inputObject $user -type NoteProperty -name "displayName" -value ($userObject.displayName).ToString()
  Add-Member -inputObject $user -type NoteProperty -name "mail" -value ($userObject.mail).ToString()
  Add-Member -inputObject $user -type NoteProperty -name "title" -value ($userObject.title).ToString()
  Add-Member -inputObject $user -type NoteProperty -name "department" -value ($userObject.department).ToString()
  Add-Member -inputObject $user -type NoteProperty -name "manager" -value $manager
  Add-Member -inputObject $user -type NoteProperty -name "streetAddress" -value (($userObject.streetAddress).ToString() -Replace "`r`n",", ")
  Add-Member -inputObject $user -type NoteProperty -name "l" -value ($userObject.l).ToString()
  Add-Member -inputObject $user -type NoteProperty -name "st" -value ($userObject.st).ToString()
  Add-Member -inputObject $user -type NoteProperty -name "postalCode" -value ($userObject.postalCode).ToString()
  Add-Member -inputObject $user -type NoteProperty -name "c" -value ($userObject.c).ToString()
  Add-Member -inputObject $user -type NoteProperty -name "telephoneNumber" -value ($userObject.telephoneNumber).ToString()
  Add-Member -inputObject $user -type NoteProperty -name "accountExpiration" -value $account_expiration
  Add-Member -inputObject $user -type NoteProperty -name "enabled" -value (Read-UserStatus $userObject.userAccountControl[0])
  Add-Member -inputObject $user -type NoteProperty -name "organizationalUnit" -value ($userObject.psBase.parent.distinguishedName).ToString()
  $users += $user
 }
}

$users | Export-Csv -path $outputFile -noTypeInformation
$users | ConvertTo-Html -property givenName,sn,mail,telephoneNumber,title,department -title "Important Contacts" > "contacts.html"

Tuesday, February 22, 2011

Scheduling Tasks in PowerShell for Windows Server 2008 -- Part 2: Weekly Tasks

In my previous blog post, "Scheduling Tasks in PowerShell for Windows Server 2008 -- Part 1: Monthly Task", I laid the groundwork for the various ways you can schedule tasks in Windows Server 2008. This installment of the series shows how to create a weekly task with multiple triggers. In the code sample below, I schedule a report to run on Monday (2), Wednesday (8) and Thursday (16) at 4:30 pm local time to the server. To extend this example further, you could use a hash table to establish a different time of day for each of the day of the week task triggers.
# Parameters to modify
$taskName = "Export-TriWeeklyDataDeobfuscationReport.ps1"
$taskWorkingDirectory = "C:\PowerShell"
$taskAuthor = "ad\myaccount"
$taskDescription = "The Tri-Weekly Data Deobfuscation Report"
$taskSecurityPrincipal = "ad\myaccount"
$taskShedulerTaskFolder = "\MyTasks"
$startTime = (Get-Date "02/28/2011 16:30:00" -Format s)
$daysOfWeek = @("2","8","16") # http://msdn.microsoft.com/en-us/library/aa381905(v=VS.85).aspx

# Would like to use -asSecureString but RegisterTaskDefinition does not accept it
# Look over your shoulder before typing
$password = Read-Host -prompt "$taskSecurityPrincipal Password"

# The meaty parts

$taskService = New-Object -ComObject Schedule.Service
$taskService.Connect()

$rootFolder = $taskService.GetFolder($taskShedulerTaskFolder)

$taskDefinition = $taskService.NewTask(0)

$registrationInformation = $taskDefinition.RegistrationInfo
$registrationInformation.Description = $taskDescription
$registrationInformation.Author = $taskAuthor

$taskPrincipal = $taskDefinition.Principal
$taskPrincipal.LogonType = 1
$taskPrincipal.UserID = $taskSecurityPrincipal
$taskPrincipal.RunLevel = 0

$taskSettings = $taskDefinition.Settings
$taskSettings.StartWhenAvailable = $true
$taskSettings.RunOnlyIfNetworkAvailable = $true
$taskSettings.Priority = 7
$taskSettings.ExecutionTimeLimit = "PT2H"

$taskTriggers = $taskDefinition.Triggers

foreach($dayOfWeek in $daysOfWeek) {
 $executionTrigger = $taskTriggers.Create(3) 
 $executionTrigger.DaysOfWeek = $dayOfWeek
 $executionTrigger.StartBoundary = $startTime
}

$taskAction = $taskDefinition.Actions.Create(0)
$taskAction.Path = "%SystemRoot%\system32\WindowsPowerShell\v1.0\powershell.exe"
$taskAction.Arguments = "-command `"$taskWorkingDirectory\$taskName`""
$taskAction.WorkingDirectory = $taskWorkingDirectory

# 6 == Task Create or Update
# 1 == Password must be supplied at registration
$rootFolder.RegisterTaskDefinition($taskName, $taskDefinition, 6, $taskSecurityPrincipal, $password, 1)

# Since we captured this in plain text I am going to nuke the value
# Not 100% security. Close the PowerShell command window.
Clear-Variable -name password