Friday, June 24, 2011

Quick One-Time Scheduled Task

While you can set one-time Schedule Tasks, it is cumbersome whether you do it via the GUI interface or through a PowerShell script. Unless you might use it again at a future date, you will need to clean it up later. A quick way to obtain the same result is to use Start-Sleep. Simply open a PowerShell session under the security context you want to execute the commandlet or script and use the syntax below. The semicolon separates commandlets allowing for this technique. The date math performed in the command returns a time span in total seconds that Start-Sleep uses. The example waits till 10:55 PM of the same day then restarts a TSM backup client service using Restart-Service. This is a great method if you need to kick-off a commandlet on the weekend before you leave the office Friday night. Just adjust the date and time of the first Get-Date commandlet.
Start-Sleep -Seconds ((Get-Date "10:55 PM") - (Get-Date)).TotalSeconds;Restart-Service -DisplayName "TSM Client Scheduler"

Friday, June 17, 2011

PowerShell Tail Improvements

Here are the latest improvements I have made to my tail functions for PowerShell. I typically work in a heterogenous computing environment so I need to be able to handle the various text file encodings and new line delimiters I encounter. The previous blogs posts (here, here & here) showed a steady improvement and functionality with the major feature I needed to complete being the detecting and reading of text files that were not were ASCII encoded with Windows end-of-line (CR+LF). This stops hard coding specific changes in my code to deal with each situation I encounter.

I have added two functions to the code to handle these two items. Looking at the head of the file, I attempt to detect the byte order mark (BOM) to determine if the file is unicode encoded and its endianess. If I am unable to make that determination, revert to ASCII as the encoding. I work with the System.Text.Encoding class to assist in the decode of the unicode based text files. The second function detects the new line delimiter by searching the head for the match of Windows (CR+LF), UNIX (LF) or Classic Macintosh (CR) to assist in the breaking of the lines for initial tail read.

In the code sample below, you will find these two new functions with the log file "tailed" being a system.log from a hypothetical Mac OS X server sharing out its "var" directory via SAMBA so we can access the system.log file in the log subdirectory. This file is typically ASCII encoded with UNIX new lines.

If you are running Microsoft Forefront Client Security and want to monitor the updates, virus detections and removals, you need to access the "MPLog-*.log" file which is Unicode-16 Little-Endian encoded. Swap out the inputFile variable to watch this file for updates. You can find that file here:
$env:ALLUSERSPROFILE\Application Data\Microsoft\Microsoft Forefront\Client Security\Client\Antimalware\Support
These are good examples to demonstrate the capability of the added functions add flexibility to my prior attempts.
Function Get-FileEncoding($fileStream) {
 $fileEncoding = $null
 if($fileStream.CanSeek) {
  [byte[]] $bytesToRead = New-Object byte[] 4
  $fileStream.Read($bytesToRead, 0, 4) | Out-Null
  if($bytesToRead[0] -eq 0x2B -and  $bytesToRead[1] -eq 0x2F -and  $bytesToRead[2] -eq 0x76) { # UTF-7
   $encoding = "utf7"
  } elseif($bytesToRead[0] -eq 0xFF -and $bytesToRead[1] -eq 0xFE) { # UTF-16 Little-Endian
   $encoding = "unicode-le"
  } elseif($bytesToRead[0] -eq 0xFE -and $bytesToRead[1] -eq 0xFF) { # UTF-16 Big-Endian
   $encoding = "unicode-be"
  } elseif($bytesToRead[0] -eq 0 -and $bytesToRead[1] -eq 0 -and $bytesToRead[2] -eq 0xFE -and $bytesToRead[3] -eq 0xFF) { # UTF-32 Big Endian
   $encoding = "utf32-be"
  } elseif($bytesToRead[0] -eq 0xFF -and $bytesToRead[1] -eq 0xFE -and $bytesToRead[2] -eq 0 -and $bytesToRead[3] -eq 0) { # UTF-32 Little Endian
   $encoding = "utf32-le"
  } elseif($bytesToRead[0] -eq 0xDD -and $bytesToRead[1] -eq 0x73 -and $bytesToRead[2] -eq 0x66 -and $bytesToRead[3] -eq 0x73) { # UTF-EBCDIC
   $encoding = "unicode"
  } elseif($bytesToRead[0] -eq 0xEF -and $bytesToRead[1] -eq 0xBB -and $bytesToRead[2] -eq 0xBF) { # UTF-8 with BOM
   $encoding = "utf8"
  } else { # ASCII Catch-All
   $encoding = "ascii"
  }
  switch($encoding) {
   "unicode-be" { $fileEncoding = New-Object System.Text.UnicodeEncoding($true, $true) }
   "unicode-le" { $fileEncoding = New-Object System.Text.UnicodeEncoding($false, $true) }
   "utf32-be" { $fileEncoding = New-Object System.Text.UTF32Encoding($true, $true) }
   "utf32-le" { $fileEncoding = New-Object System.Text.UTF32Encoding($false, $true) }
   "unicode" { $fileEncoding = New-Object System.Text.UnicodeEncoding($true, $true) }
   "utf7" { $fileEncoding = New-Object System.Text.UTF7Encoding } 
   "utf8" { $fileEncoding = New-Object System.Text.UTF8Encoding } 
   "utf32" { $fileEncoding = New-Object System.Text.UTF32Encoding } 
   "ascii" { $fileEncoding = New-Object System.Text.AsciiEncoding }
  }
 }
 return $fileEncoding 
}
#--------------------------------------------------------------------------------------------------#
Function Get-NewLine($fileStream, $fileEncoding) {
 $newLine = $null
 $byteChunk = 512
 if($fileStream.CanSeek) {
  $fileSize = $fileStream.Length
  if($fileSize -lt $byteChunk) { $byteChunk -eq $fileSize }
  [byte[]] $bytesToRead = New-Object byte[] $byteChunk
  $fileStream.Read($bytesToRead, 0, $byteChunk) | Out-Null
  $testLines = $fileEncoding.GetString($bytesToRead)
  if($testLines -match "\r\n") { # Windows
   $newLine = "\r\n"
  } elseif($testLines -match "\n") { # Unix
   $newLine = "\n"
  } elseif($testLines -match "\r") { # Classic Mac
   $newLine = "\r"
  } else { # When all else fails, Go Windows
   $newLine = "\r\n"
  }
 }
 return $newLine
}
#--------------------------------------------------------------------------------------------------#
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)
  $fileEncoding = Get-FileEncoding $fileStream
  $newLine = Get-NewLine $fileStream $fileEncoding
  $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($fileEncoding.GetString($bytesRead),$newLine)))
   $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
 }
 Write-Host $linesOfText.count
 return $linesOfText
}
#--------------------------------------------------------------------------------------------------#
Function Read-FileUpdates($fileName,$startSize) {
 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)
  $fileEncoding = Get-FileEncoding $fileStream
  $fileStream.Close()
  while([System.IO.File]::Exists($fileName)) {
   $fileStream = New-Object System.IO.FileStream($fileName,[System.IO.FileMode]::Open,[System.IO.FileAccess]::Read,[System.IO.FileShare]::ReadWrite)
   if($fileStream.CanSeek) {
    $currentFileSize = $fileStream.Length
    if($currentFileSize -gt $startSize) {
     $byteChunk = $currentFileSize - $startSize
     [byte[]] $bytesRead = New-Object byte[] $byteChunk
     $fileStream.Seek((-$byteChunk), [System.IO.SeekOrigin]::End) | Out-Null
     $fileStream.Read($bytesRead, 0, $byteChunk) | Out-Null
     Write-Host ($fileEncoding.GetString($bytesRead)) -noNewLine
     $startSize = $currentFileSize
     }
    }
   $fileStream.Close()
   Start-Sleep -milliseconds 250
  }
 }
}
#--------------------------------------------------------------------------------------------------#
Set-Variable -name inputFile -option Constant -value "\\macosx-server.mydomain.local\var\log\system.log"
#--------------------------------------------------------------------------------------------------#
if([System.IO.File]::Exists($inputFile)) {
 Write-Host (Read-EndOfFileByByteChunk $inputFile 10 1280 | Out-String) -noNewLine
 $fileStream = New-Object System.IO.FileStream($inputFile,[System.IO.FileMode]::Open,[System.IO.FileAccess]::Read,[System.IO.FileShare]::ReadWrite)
 $fileSize = $fileStream.Length
 $fileStream.Close()
 Read-FileUpdates $inputFile $fileSize
} else {
 Write-Host "Could not find $inputFile..." -foregroundColor Red
}

Tuesday, June 14, 2011

Validate IPv4 Addresses in PowerShell

Here is a simple function to validate if a IPv4 address meets the RFC. It does not confirm if the host associated to the IP Address is accessible. To do that with PowerShell, you need to utilize the System.Net.NetworkInformation.Ping class. The validation is performed by a fairly complex Regular Expression using '-match'.
Function Test-IPv4Address($ipAddress) {
 if($testAddress -match "\b(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\b") {
  $addressValid = $true
 } else {
  $addressValid = $false
 }
 return $addressValid
}
#--------------------------------------------------------------------------------------------------#
Set-Variable -name testAddresses -option Constant -value @("192.168.1.1","546.23.10.12","8.8.8.8","127.0.999.26")
#--------------------------------------------------------------------------------------------------#
foreach($testAddress in $testAddresses) {
 if((Test-IPv4Address $testAddress)) {
  Write-Host "$testAddress is a validly formatted IPv4 Address" -foregroundColor Green
 } else {
  Write-Host "$testAddress is not a validly formatted IPv4 Address" -foregroundColor Red
 }
}

Monday, June 13, 2011

Nagios Check for Scheduled TSM Backups

Good backups are the best friends you have as a System Administrator. If you don't know if your backups are successful, your business is at risk. Being unable to recover data after a catastrophic failure on a business critical system is typically an RGE and can put a company out of business. IBM's Tivoli Storage Manager system provides a robust backup and recovery and has decent reporting. I prefer to consolidate system health monitoring in one system, Nagios, so I do not need a separate monitoring console for every product in production. Monitoring backups successfully over hundreds or thousands of systems from a generated report is laborious. Nagios provides checks and balances for resolving system issues. In one view, you know your risk level. If you don't resolve a problem, it remains CRITICAL. Having a check for TSM backups simplifies the process and greatly reduces the labor required to monitor backups.

Scheduled TSM backups write to a log defined in the schedule's .opt file; typically dsmsched.log. On large file servers with long dsmsched.log retention periods, this file can grow easily over 1 gigabyte. Reading the entire dsmsched.log file to determine success of the last backup will likely breach the timeout of the Nagios check. To compensate for this, we need to tail the log file and retrieve the summary data from the last backup. In the check below, I do just that. If you pass the name of the schedule log file (you can run multiple schedules on a client; each with a different log file name), the check will look for it install directory in the "Program Files" stored in the environmental variable. If no log file name is provided to the check, it will search the registry to look for the default log filename. If you are running custom install locations, this will need to me modified.

As you follow through the flow of the code, you will see what triggers are passed on to Nagios. They are fairly straightforward:
  • If a backup has not completed in 24 hours, a critical alarm is generated
  • If a certain number of failed file backups are reported, a warning alarm is generated
  • If a backup is still running, a warning alarm is generated
  • If a successful backup is detected, a normal return is generated
These are all customizable via the initial variables in the code. One special feature of this code (and is optional) is the ability to restart the TSM Client Scheduler if no communication between the client and server have occurred in the last 36 hours. Not a common problem but one that I have encountered enough times to make this a feature; saving time spent manually restarting the process. Restarting the TSM Client Scheduler service will re-initiate the communication.

And remember, just because you have log files saying you have "good backups" that doesn't mean it's true. You need to test restores on a regular basis as a part of your disaster recovery practice. Ultimately, backups are only as good as their ability to be restored.

UPDATE: I have made some improvements to this code here.
param([string]$tsmFile)
#--------------------------------------------------------------------------------------------------#
Function Get-TSMInfo($server, $tsmInfo) {
 $key = "SOFTWARE"
 $hKey = [Microsoft.Win32.RegistryHive]::LocalMachine
 $baseKey = [Microsoft.Win32.RegistryKey]::OpenRemoteBaseKey($hKey, $server)
 foreach($rootKeyValue in ($baseKey.OpenSubKey($key)).GetSubKeyNames()) {
  if($rootKeyValue -eq "IBM" -and ($baseKey.OpenSubKey("$key\IBM\ADSM\CurrentVersion")).SubKeyCount -gt 2) {
   $tsmVersion = ($baseKey.OpenSubKey("$key\IBM\ADSM\CurrentVersion\BackupClient")).GetValue("PtfLevel")
   $tsmPath = ($baseKey.OpenSubKey("$key\IBM\ADSM\CurrentVersion\BackupClient")).GetValue("Path")
   $key = "SYSTEM\CurrentControlSet\Services"
   if($tsmVersion -ne "" -and $tsmPath -ne "") {
    foreach($keyValue in ($baseKey.OpenSubKey($key)).GetSubKeyNames()) {
     foreach($subKeyValue in ($baseKey.OpenSubKey("$key\$keyValue")).GetSubKeyNames()) {
      $clientNodeName = ""
      $errorLog = ""
      $optionsFile = ""
      $scheduleLog = ""
      if(($baseKey.OpenSubKey("$key\$keyValue").GetValue("Start")) -eq "2") {
       if($subKeyValue -eq "Parameters") {
        foreach($value in ($baseKey.OpenSubKey("$key\$keyValue\Parameters")).GetValueNames()) {
         if($value -eq "clientNodeName") {
          $clientNodeName = ($baseKey.OpenSubKey("$key\$keyValue\Parameters")).GetValue($value)
         } elseif($value -eq "errorLog") {
          $errorLog = ($baseKey.OpenSubKey("$key\$keyValue\Parameters")).GetValue($value)
         } elseif($value -eq "optionsFile") {
          $optionsFile = ($baseKey.OpenSubKey("$key\$keyValue\Parameters")).GetValue($value)
         } elseif($value -eq "scheduleLog") {
          $scheduleLog = ($baseKey.OpenSubKey("$key\$keyValue\Parameters")).GetValue($value)
         }
        }
       }
      }
      if($clientNodeName -ne "" -and $errorLog -ne "" -and $optionsFile -ne "" -and $scheduleLog -ne "") {
       $optionsFileUncPath = ("\\$server\" + ($optionsFile.SubString(0,1) + "$" + $optionsFile.SubString(2)))
       $tsmServer = "FAILED"
       $tsmClientPort = "FAILED"
       if(Test-Path -path $optionsFileUncPath) {
        foreach($line in (Get-Content -path $optionsFileUncPath)){
         if($line -match "TCPSERVERADDRESS") {
          $tsmServer = ($line -replace "TCPSERVERADDRESS","").Trim()
         }
         if($line -match "TCPCLIENTPORT") {
          $tsmClientPort = ($line -replace "TCPCLIENTPORT","").Trim()
         }
        }
       }
       $serviceStatus = $null
       foreach($service in Get-Service) {
        if($service.DisplayName -eq $keyValue) {
         $serviceStatus = $service.Status
         break
        }
       }
       if($serviceStatus -eq "Running" -or $serviceStatus -eq "Stopped") {
        $clientNodeInformation = New-Object -typeName PSObject
        Add-Member -inputObject $clientNodeInformation -type NoteProperty -name "server" -value $server
        Add-Member -inputObject $clientNodeInformation -type NoteProperty -name "tsmVersion" -value $tsmVersion
        Add-Member -inputObject $clientNodeInformation -type NoteProperty -name "installPath" -value $tsmPath
        Add-Member -inputObject $clientNodeInformation -type NoteProperty -name "tsmServer" -value $tsmServer
        Add-Member -inputObject $clientNodeInformation -type NoteProperty -name "tsmClientPort" -value $tsmClientPort
        Add-Member -inputObject $clientNodeInformation -type NoteProperty -name "scheduleName" -value $keyValue
        Add-Member -inputObject $clientNodeInformation -type NoteProperty -name "clientNodeName" -value $clientNodeName
        Add-Member -inputObject $clientNodeInformation -type NoteProperty -name "optionsFile" -value $optionsFile
        Add-Member -inputObject $clientNodeInformation -type NoteProperty -name "scheduleLog" -value $scheduleLog
        Add-Member -inputObject $clientNodeInformation -type NoteProperty -name "errorLog" -value $errorLog
        Add-Member -inputObject $clientNodeInformation -type NoteProperty -name "status" -value $serviceStatus
        $tsmInfo += $clientNodeInformation
       }
      }
     }
    }
   }
  }
 }
 return $tsmInfo
}
#--------------------------------------------------------------------------------------------------#
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
}
#--------------------------------------------------------------------------------------------------#
Set-Variable -name returnNormal -option Constant -value 0
Set-Variable -name returnWarning -option Constant -value 1
Set-Variable -name returnError -option Constant -value 2
Set-Variable -name returnUnknown -option Constant -value 3
Set-Variable -name computerFqdn -option Constant -value (([System.Net.NetworkInformation.IPGlobalProperties]::GetIPGlobalProperties()).HostName + "." + ([System.Net.NetworkInformation.IPGlobalProperties]::GetIPGlobalProperties()).DomainName)
Set-Variable -name backupWindow -option Constant -value 24 # in Hours
Set-Variable -name deafService -option Constant -value 36 # in Hours
Set-Variable -name enableRestarts -option Constant -value $true # Allow check to restart TSM if the service
Set-Variable -name lookBack -option Constant -value 250 # Number of lines to tail
Set-Variable -name maximumFailures -option Constant -value 5 # Your tolerance for failed files
Set-Variable -name successfulBackup -value $false
Set-Variable -name todaysBackupFound -value $false
Set-Variable -name backupStillRunning -value $false
Set-Variable -name completionTime -value $null
Set-Variable -name totalFailed -value 0
Set-Variable -name logEntries -value @()
Set-Variable -name exitMessage -value "Massive Script Failure"
Set-Variable -name exitValue -value $returnError
#--------------------------------------------------------------------------------------------------#
if($tsmFile -eq "$" -or (!$tsmFile)) {
 $tsmInfo = @(Get-TSMInfo $computerFqdn @())
 foreach($tsmInstance in $tsmInfo) {
  if($tsmInstance.scheduleLog -match $tsmFile) {
   if($tsmInstance.scheduleLog -match "\\dsmsched.log") {
    $tsmLogFile = $tsmInstance.scheduleLog
    Write-Host $tsmLogFile
    break
   }
  }
 }
} else {
 $tsmLogFile = ($env:programfiles + "\Tivoli\TSM\baclient\$tsmFile")
}

if(Test-Path -path $tsmLogFile) {
 $logEntries = Read-EndOfFileByByteChunk $tsmLogFile $lookBack 1280
 
 foreach($logEntry in $logEntries) {
  if($logEntry.Length -ge 19) {
   $dateTest = $logEntry.SubString(0,19) -as [DateTime]
   if($dateTest) {
    if(((Get-Date) - (Get-Date $logEntry.SubString(0,19))).TotalHours -le $backupWindow) {
     if($logEntry -match "Scheduled event '(.*?)' completed successfully.") {
      $successfulBackup = $true
      $completionTime = Get-Date $logEntry.SubString(0,19)
     }
     if($logEntry -match "Total number of objects failed:") {
      [int]$totalFailed = ($logEntry -Replace "(.*)Total number of objects failed:", "").Trim()
     }
     $todaysBackupFound = $true
    }
    $lastLine = $logEntry
   }
  }
 }
 
 if($successfulBackup -eq $false -and $todaysBackupFound -eq $true) {
  $lastLogTime = ((Get-Date) - (Get-Date $lastLine.SubString(0,19))).TotalMinutes
  if($lastLogTime -le 15) {
   $backupStillRunning = $true
  }
 }
 
 if($todaysBackupFound -eq $false) {
  if(((Get-Date) - (Get-Date $lastLine.SubString(0,19))).TotalHours -ge $deafService -and $enableRestarts -eq $true) {
   $tsmInfo = @(Get-TSMInfo $computerFqdn @())
   $schedulerFound = $false
   foreach($tsmInstance in $tsmInfo) {
    if($tsmInstance.scheduleLog -match $tsmFile) {
     if($tsmInstance.status -eq "Running") {
      Restart-Service -name $tsmInstance.scheduleName
      $exitMessage = ("TSM Scheduler `"" + $tsmInstance.scheduleName + "`" has not contacted the TSM server in $deafService hours. Restarting service.")
     } else {
      Start-Service -name $tsmInstance.scheduleName
      $exitMessage = ("TSM Scheduler `"" + $tsmInstance.scheduleName + "`" was stopped and hasn't contacted the TSM server in $deafService hours. Starting service.")
     }
     $schedulerFound = $true
     $exitValue = $returnError
     break
    }
   }
   if($schedulerFound -eq $false) {
    $timeSinceLastContact = ((Get-Date) - (Get-Date $lastLine.SubString(0,19))).TotalHours
    $exitMessage = ("Unable to find data in the last $backupWindow hours in $tsmLogFile and the client hasn't contacted the TSM Server in $timeSinceLastContact hours. Last Backup log date: " + (Get-Date $lastLine.SubString(0,19)))
    $exitValue = $returnError
   }
  } else {
   $exitMessage = ("Unable to find data in the last $backupWindow hours in $tsmLogFile. Last Backup log date: " + (Get-Date $lastLine.SubString(0,19)))
   $exitValue = $returnError
  }
 } elseif($totalFailed -ge $maximumFailures) {
  $exitMessage = "Backup completed with $totalFailed failed objects."
  $exitValue = $returnWarning
 } elseif($successfulBackup -eq $true) {
  $exitMessage = "Backup completed successfully: $completionTime"
  $exitValue = $returnNormal
 } elseif($backupStillRunning -eq $true) {
  $exitMessage = ("Backup still running! Please allow to complete. Current status: " + $lastLine -Replace "\\","/")
  $exitValue = $returnWarning
 } else {
  $exitMessage = ("Unable to find a successful backup. Last status: " + $lastLine -Replace "\\","/")
  $exitValue = $returnError
 }
} else {
 $exitMessage = "Unable to locate $tsmLogFile"
 $exitValue = $returnError
}

Write-Host $exitMessage
$Host.SetShouldExit($exitValue)

Thursday, May 19, 2011

Exporting the Membership of a Large, Nested, Active Directory Group

One of the most common requests I receive is to enumerate the membership of an Active Directory group and provide some basic information back about the members. Simple enough task if the group is flat and contains a limited number of members. Once you start dealing with nested groups and groups containing more than 1,500 members (1,000 members for an Active Directory 2000 Forest), you need to start planning how you output the information.

With groups containing more than 1,500 members, performing the following will not provide the entire population.
$groupObject = New-Object System.DirectoryServices.DirectoryEntry("GC://CN=All Employees,OU=Distribution Lists,DC=ad,DC=mydomain,DC=local")
foreach($member in $groupObject.member) { Write-Output $member }
You will only enumerate the first 1,500 distinguished names of the multivalued attribute 'member' and output them to the console. Big enough to convince you that you retrieved all the members on first glance but on further investigation you will realize you are missing some people. Hopefully, you caught that and not the person that requested the report with the resolved distinguished names. To overcome this limitation, you need to search within the group object itself and return a range of values within the 'member' attribute; looping until you have enumerated each and every distinguished name.

Active Directory groups can contain user objects, computer objects, contact objects and other groups. To obtain the full membership of a Security Group to understand who has rights to an asset or a Distribution List to understand who will receive an e-mail, you must be aware of nested groups. If a group is a member is a member of the initial group, you will need to enumerate that group and any other groups that it may contain and so on. One of the issues that you can encounter with nested groups is the Administrator that nested a group that is member of a group that itself is patriline to that group. This leads to looping in reporting and must be accounted when recursively enumerating the membership. I tackle this problem by storing the nested group distinguished names that were enumerated in an array and at each recursion evaluating if that distinguished name is an element.

The code sample below deals with these two issues related to group enumeration. I have tested this against code against a group that contained 32,000+ member objects with multiple layers of nesting genreating a 2.9 megabyte report. The only big deficit in the code is there is no provision to deal with objects that are of the foreignSecurityPrincipal class. If you have cross-forest membership in groups, you can add that to the if-then statement and add your own formatting function for those objects. A nice feature of this script is that if you need to enumerate a large number of groups that have similar sAMAccountNames you can wild card them in the command line argument, such as "-group *AdSales*" or "-group DL_*". The code in the script is fairly portable. You can recombine these functions to enumerate the groups associated to NTFS security to provide an access report. You just need to update the functions that deal with the formatting of the objects to provide the desired information.
param([string]$domain,[string]$group,[switch]$verbose)
#-------------------------------------------------------------------------------------------------#
Function Find-GroupDistinguishedNamesBysAMAccountName($domain,$sAMAccountName) {
 $groupDistinguishedNames = @()
 $directorySearcher = New-Object System.DirectoryServices.DirectorySearcher
 $directorySearcher.SearchRoot = (New-Object System.DirectoryServices.DirectoryEntry(("LDAP://" + (Get-LocalDomainController $domain) + "/" + (Get-DomainDn $domain))))
 $directorySearcher.Filter = "(&(objectClass=group)(objectCategory=group)(sAMAccountName=$sAMAccountName))"
 $directorySearcher.PropertiesToLoad.Clear()
 $directorySearcher.PropertiesToLoad.Add("distinguishedName") | Out-Null
 $searchResults = $directorySearcher.FindAll()

 if($searchResults -ne $null) {
  foreach($searchResult in $searchResults) {
   if($searchResult.Properties.Contains("distinguishedName")) {
    $groupDistinguishedNames += $searchResult.Properties.Item("distinguishedName")[0]
   }
  }
 } else {
  $groupDistinguishedNames = $null
 }
 $directorySearcher.Dispose()
 return $groupDistinguishedNames
}
#-------------------------------------------------------------------------------------------------#
Function Get-GroupType($groupType) {
 if($groupType -eq -2147483646) {
  $groupType = "Global Security Group"
 } elseif($groupType -eq -2147483644) {
  $groupType = "Domain Local Security Group"
 } elseif($groupType -eq -2147483643) {
  $groupType = "BuiltIn Group"
 } elseif($groupType -eq -2147483640) {
  $groupType = "Universal Security Group"
 } elseif($groupType -eq 2) {
  $groupType = "Global Distribution List"
 } elseif($groupType -eq 4) {
  $groupType = "Local Distribution List"
 } elseif($groupType -eq 8) {
  $groupType = "Universal Distribution List"
 } else {
  $groupType = "Unknown Group Type"
 }
 return $groupType
}
#-------------------------------------------------------------------------------------------------#
Function Get-GroupMembers($distinguishedName,$level,$groupsReported) {
 $reportText = @()
 $groupObject = New-Object System.DirectoryServices.DirectoryEntry("GC://" + ($distinguishedName -replace "/","\/"))
 if($groupObject.groupType[0] -ne -2147483640 -or $groupObject.groupType[0] -ne 8) {
  $groupObject = New-Object System.DirectoryServices.DirectoryEntry("LDAP://" + (Get-LocalDomainController(Get-ObjectAdDomain $distinguishedName)) + "/" + ($distinguishedName -replace "/","\/"))
 }
 $directorySearcher = New-Object System.DirectoryServices.DirectorySearcher($groupObject)
 $lastQuery = $false
 $quitLoop = $false
 if($groupObject.member.Count -ge 1000) {
  $rangeStep = 1000
 } elseif($groupObject.member.Count -eq 0) {
  $lastQuery = $true
  $quitLoop = $true
 } else {
  $rangeStep = $groupObject.member.Count
 }
 $rangeLow = 0
 $rangeHigh = $rangeLow + ($rangeStep - 1)
 $level = $level + 2
 while(!$quitLoop) {
  if(!$lastQuery) {
   $attributeWithRange = "member;range=$rangeLow-$rangeHigh"
  } else {
   $attributeWithRange = "member;range=$rangeLow-*"  }
  $directorySearcher.PropertiesToLoad.Clear()
  $directorySearcher.PropertiesToLoad.Add($attributeWithRange) | Out-Null
  $searchResult = $directorySearcher.FindOne()
  $directorySearcher.Dispose()
  if($searchResult.Properties.Contains($attributeWithRange)) {
   foreach($member in $searchResult.Properties.Item($attributeWithRange)) {
    $memberObject = Get-ActiveDirectoryObject $member
    if($memberObject.objectClass -eq "group") {
     $reportText += Format-Group $memberObject $level $groupsReported
    } elseif ($memberObject.objectClass -eq "contact") {
     $reportText += Format-Contact $memberObject $level
    } elseif ($memberObject.objectClass -eq "computer") {
     $reportText += Format-Computer $memberObject $level
    } elseif ($memberObject.objectClass -eq "user") {
     $reportText += Format-User $memberObject $level
    } else {
     Write-Warning "NOT SUPPORTED: $member"
    }
   }
   if($lastQuery) { $quitLoop = $true }
  } else {
   $lastQuery = $true
  }
  if(!$lastQuery) {
   $rangeLow = $rangeHigh + 1
   $rangeHigh = $rangeLow + ($rangeStep - 1)
  }
 }
 return $reportText
}
#-------------------------------------------------------------------------------------------------#
Function Format-User($userObject,$level) {
 $reportText = @()
 if($userObject.displayName) {
  $identity = ((" " * $level) + $userObject.displayName.ToString() + " [" + (Get-ObjectNetBiosDomain $userObject.distinguishedName) + "\" + $userObject.sAMAccountName.ToString() + "]")
 } else {
  $identity = ((" " * $level) + $userObject.name.ToString() + " [" + (Get-ObjectNetBiosDomain $userObject.distinguishedName) + "\" + $userObject.sAMAccountName.ToString() + "]")
 }
 if($userObject.mail) {
  $identity = ("$identity <" + $userObject.mail.ToString() + ">")
 }
 if($userObject.userAccountControl[0] -band 0x0002) {
  $identity = "$identity (User Disabled)"
 } else {
  $identity = "$identity (User Enabled)"
 }
 $reportText += $identity
 $description = ((" " * $level) + "  Description: " + $userObject.description.ToString())
 $reportText += $description
 if($verbose) { Write-Host ($reportText | Out-String) }
 return $reportText
}

Function Format-Contact($contactObject,$level) {
 $reportText = @()
 $identity = ((" " * $level) + $contactObject.displayName.ToString() + " [" + (Get-ObjectNetBiosDomain $contactObject.distinguishedName) + "] <" + $contactObject.mail.ToString() + "> (Contact)")
 $description = ((" " * $level) + "  Description: " + $contactObject.description.ToString())
 $reportText += $identity
 $reportText += $description
 if($verbose) { Write-Host ($reportText | Out-String) }
 return $reportText
}

Function Format-Computer($computerObject,$level) {
 $reportText = @()
    $identity = ((" " * $level) + $computerObject.name + " [" + (Get-ObjectNetBiosDomain $computerObject.distinguishedName) + "\" + $computerObject.sAMAccountName.ToString() + "] (Computer)")
 $operatingSystem = ((" " * $level) + "  OS: " + $computerObject.operatingSystem.ToString())
 $reportText += $identity
    if($computerObject.dNSHostName) {
     $fqdn = ((" " * $level) + "  FQDN: " + ($computerObject.dNSHostName.ToString()).ToLower())
  $reportText += $fqdn
  $reportText += $operatingSystem
    } else {
  $reportText += $operatingSystem
    }
 if($verbose) { Write-Host ($reportText | Out-String) }
 return $reportText
}

Function Format-Group($groupObject,$level,$groupsReported) {
 $reportText = @()
 $identity = ((" " * $level) + $groupObject.name.ToString() + " [" + (Get-ObjectNetBIOSDomain $groupObject.distinguishedName) + "\" + $groupObject.sAMAccountName.ToString() + "]")
 if($groupObject.mail) {
  $identity = ("$identity <" + $groupObject.mail.ToString() + ">")
 }
 $identity = ("$identity (" + (Get-GroupType $groupObject.groupType) + ")")
 $reportText += $identity
 if($verbose) { Write-Host ($reportText | Out-String) }
 if($groupsReported -notcontains $groupObject.distinguishedName) {
  $groupsReported += $groupObject.distinguishedName
  $reportText += Get-GroupMembers $groupObject.distinguishedName.ToString() $level $groupsReported
 } else {
  $reportText += ((" " * $level) + "  [previously reported nested group]")
 }
 $reportText += ""
 return $reportText
}
#-------------------------------------------------------------------------------------------------#
Function Get-AllForestDomains {
 $domains = @()
 $forestInfo = [System.DirectoryServices.ActiveDirectory.Forest]::GetCurrentForest()
 foreach($domain in $forestInfo.Domains) {
  $domains += $domain.name
 }
 return $domains
}

Function Get-DomainDn($domain) {
 return ((New-Object System.DirectoryServices.DirectoryEntry("LDAP://$domain/RootDSE")).defaultNamingContext).ToString()
}

Function Get-ObjectAdDomain($distinguishedName) {
 return ((($distinguishedName -replace "(.*?)DC=(.*)",'$2') -replace "DC=","") -replace ",",".")
}

Function Get-ObjectNetBiosDomain($distinguishedName) {
 return ((Get-ObjectAdDomain $distinguishedName).Split(".")[0]).ToUpper()
}

Function Get-LocalDomainController($domain) {
 return ((New-Object System.DirectoryServices.DirectoryEntry("LDAP://$domain/RootDSE")).dnsHostName).ToString()
}

Function Get-ActiveDirectoryObject($distinguishedName) {
 return New-Object System.DirectoryServices.DirectoryEntry("LDAP://" + (Get-LocalDomainController (Get-ObjectAdDomain $distinguishedName)) + "/" + ($distinguishedName -replace "/","\/"))
}
#-------------------------------------------------------------------------------------------------#
Set-Variable -name reportsDirectory -option Constant -value "$pwd\Reports"
Set-Variable -name forestDomains -option Constant -value @(Get-AllForestDomains)
#-------------------------------------------------------------------------------------------------#
if(!([bool]$domain) -or !([bool]$group)) {
 Write-Host ("  Example: .\Export-GroupMembership.ps1 -domain " + $forestDomains[0] + " -group Administrators -verbose") -foregroundcolor Yellow
 Write-Host ""
}

if(!([bool]$domain)) {
 Write-Host " You are missing the `"-domain`" Switch" -Foregroundcolor Red
 Write-Host ""
 Write-Host " The domain of the group."
 Write-Host "  Valid Domains:"
 foreach($forestDomain in $forestDomains) {
  Write-Host ("   $forestDomain [" + ($forestDomain.Split("."))[0] + "]")
 }
 Write-Host ""
 Write-Host " Please enter the Domain below"
 while(!([bool]$domain)) {
  $domain = Read-Host -prompt "`tDomain"
 }
 Write-Host ""
}

if(!([bool]$group)) {
 Write-Host " You are missing the `"-group`" Switch" -Foregroundcolor Red
 Write-Host ""
 Write-Host " Please enter the group name below"
 while(!([bool]$group)) {
  $group = Read-Host -prompt "`tGroup"
 }
 Write-Host ""
}

$validDomain = $false
foreach($forestDomain in $forestDomains) {
 if($forestDomain -eq $domain) {
  $validDomain = $true
  break
 }
 if((($forestDomain.Split("."))[0]) -eq $domain) {
  $validDomain = $true
  break
 }
}

if($validDomain -eq $false) {
 Write-Host ""
 Write-Host "$domain is not a valid domain in your current forest." -foregroundcolor Red
 Write-Host ""
 exit
}

if(!(Test-Path -path $reportsDirectory)) {
 New-Item -path $reportsDirectory -type Directory | Out-Null
}

$groupDistinguishedNames = Find-GroupDistinguishedNamesBysAMAccountName $domain $group

if($groupDistinguishedNames -eq $null) {
 Write-Host "Unable to locate $domain\$group. Exiting..." -foregroundColor Red
 exit
}

foreach($groupDistinguishedName in $groupDistinguishedNames) {
 $groupObject = Get-ActiveDirectoryObject $groupDistinguishedName
 $groupName = $groupObject.name.ToString()
 $groupsAMAccountName = $groupObject.sAMAccountName.ToString()
 $groupDomain = Get-ObjectAdDomain $groupDistinguishedName
 $groupNetBiosDomain = Get-ObjectNetBiosDomain $groupDistinguishedName
 $groupType = Get-GroupType $groupObject.groupType
 $outputFilename = ($groupDomain + "-" + $groupsAMAccountName + ".txt")
 $reportText = @()
 $reportText += ("#" + ("-" * 78) + "#")
 $reportText += "  Members of $groupName [$groupNetBiosDomain\$groupsAMAccountName]"
 $reportText += ("  Description: " + $groupObject.description.ToString())
 if($groupObject.mail -and $groupType -match "Distribution") {
  $reportText += "  Distribution List E-Mail Address: " + $groupObject.mail.ToString()
 } elseif($groupObject.mail) {
  $reportText += "  Security Group E-Mail Address: " + $groupObject.mail.ToString()
 }
 $reportText += "  Group Type: $GroupType"
 $reportText += ("  Report generated on " + (Get-Date -format D) + " at " + (Get-Date -format T))
 $reportText += ("#" + ("-" * 78) + "#")
 if($verbose) { Write-Host ($reportText | Out-String) }
 $reportText += Get-GroupMembers $groupDistinguishedName 0 @()
 $reportText += ("#" + ("-" * 78) + "#")
 if($verbose) { Write-Host ("#" + ("-" * 78) + "#") }
 Set-Content -path "$reportsDirectory\$outputFilename" -value ($reportText | Out-String)
}

Wednesday, May 18, 2011

Correct Order to Start and Stop BlackBerry Enterprise Server Services Using PowerShell

A common activity I perform after patching Exchange Servers and Domain Controllers is to restart BlackBerry services on client BlackBerry servers. Failure to do so can lead to issues for BlackBerry users such as failed delivery, lookup failures and inability to send. RIM recommends a specific order for these services to be stopped and started. Since patching occurs typically occurs in the dead of night to avoid impacting end users, you want to get things done quickly but accurately so you can get some sleep. In the code samples below, I take advantage of Get-Service, Stop-Service and Start-Service to execute the correct order of the process. I store the required service order in an array and take advantage of the natural looping order of elements in "foreach" to manipulate the services. The remaining services are discovered by obtaining all services with the name BlackBerry and filtering out the services that required a specific order for stopping and starting. If there are any issues with stopping or stopping the services, the code immediately exits so you can take manual intervention, research and resolve the problem.

Stopping BlackBerry Services:
#--------------------------------------------------------------------------------------------------#
Set-Variable -name selectedServices -option Constant -value @("BlackBerry Controller","BlackBerry Dispatcher","BlackBerry Router")
#--------------------------------------------------------------------------------------------------#
foreach($selectedService in $selectedServices) {
 if((Get-Service -Name $selectedService).Status -ne "Stopped") {
  Write-Output "Stopping $selectedService..."
  Stop-Service -Name $selectedService
  if((Get-Service -Name $selectedService).Status -ne "Stopped") {
   Write-Output "$selectedService failed to stop. Exiting..."
   exit
  } else {
   Write-Output "$selectedService successfully stopped."
  }
 } else {
  Write-Output "$selectedService already stopped!"
 }
}
foreach($blackberryService in (Get-Service -Name Blackberry*)) {
 if($selectedServices -notcontains $blackberryService.Name) {
  if($blackberryService.Status -ne "Stopped") {
   Write-Output ("Stopping " + $blackberryService.Name + "...")
   Stop-Service -Name $blackberryService.Name
   if((Get-Service -Name $blackberryService.Name).Status -ne "Stopped") {
    Write-Output ($blackberryService.Name + " failed to stop. Exiting...")
    exit
   } else {
    Write-Output ($blackberryService.Name + " successfully stopped.")
   }
  } else {
   Write-Output ($blackberryService.Name + " already stopped!")
  }
 }
}
Starting BlackBerry Services:
#--------------------------------------------------------------------------------------------------#
Set-Variable -name selectedServices -option Constant -value @("BlackBerry Router","BlackBerry Dispatcher","BlackBerry Controller")
#--------------------------------------------------------------------------------------------------#
foreach($selectedService in $selectedServices) {
 if((Get-Service -Name $selectedService).Status -ne "Running") {
  Write-Output "Starting $selectedService..."
  Start-Service -Name $selectedService
  if((Get-Service -Name $selectedService).Status -ne "Running") {
   Write-Output "$selectedService failed to start. Exiting..."
   exit
  } else {
   Write-Output "$selectedService successfully started."
  }
 } else {
  Write-Output "$selectedService already running!"
 }
}
foreach($blackberryService in (Get-Service -Name Blackberry*)) {
 if($selectedServices -notcontains $blackberryService.Name) {
  if($blackberryService.Status -ne "Running") {
   Write-Output ("Starting " + $blackberryService.Name + "...")
   Start-Service -Name $blackberryService.Name
   if((Get-Service -Name $blackberryService.Name).Status -ne "Running") {
    Write-Output ($blackberryService.Name + " failed to start. Exiting...")
    exit
   } else {
    Write-Output ($blackberryService.Name + " successfully started.")
   }
  } else {
   Write-Output ($blackberryService.Name + " already running!")
  }
 }
}

Tuesday, May 10, 2011

Build a Report of Unused BlackBerry Devices

A crucial element of BlackBerry management is reducing the cost of providing BlackBerry service. An unused BlackBerry is a wasted license, an unnecessary cell phone bill with a data plan and possibly a security issue. When thousands of handhelds are under management, dozens can be idle. In the current economic climate, every penny counts in your IT budget. Finding these idle devices can add up to thousands of dollars in savings annually.

In the code sample below, I build upon a previous blog post, "BlackBerry User Report". The command text is updated to select information from the UserStats table where the connection data for the BlackBerry devices is stored. If no value is held in the row for connectivity, a DBNull value will be returned. This is not the same as PowerShell's built-in "$null" and you will need to test against the DBNull class to determine this. The end result of this code example is a comma separated values text file with the employees neglecting their BlackBerry. Reach out to them and see if they no longer need their handheld or if they have moved on to an ActiveSync device as their primary mobile messaging device.

In a change from previous blog posts, I am using System.DirectoryServices.DirectorySearcher class to perform the user object lookup instead of ActiveX Data Object (ADO). I have a tendency to use ADO as the methodology is the same in PowerShell, Perl and VBScript making the translation of ADSI queries between languages simple.
Function Get-ForestRootDn {
 return ((New-Object System.DirectoryServices.DirectoryEntry("LDAP://RootDSE")).rootDomainNamingContext).ToString()
}

Function Get-ObjectAdDomain($distinguishedName) {
 return ((($distinguishedName -replace "(.*?)DC=(.*)",'$2') -replace "DC=","") -replace ",",".")
}

Function Find-ActiveDirectoryObjectByEMailAddress($smtpAddress) {
 $directorySearcher = New-Object System.DirectoryServices.DirectorySearcher
 $directorySearcher.SearchRoot = (New-Object System.DirectoryServices.DirectoryEntry("GC://$forestRootDn"))
 $directorySearcher.Filter = "(&(objectClass=user)(objectCategory=person)(proxyAddresses=smtp:$smtpAddress))"
 $searchResult = $directorySearcher.FindOne()
 if($searchResult -ne $null) {
  $userObject = New-Object System.DirectoryServices.DirectoryEntry($searchResult.Path)
 } else {
  $userObject = $null
 }
 $directorySearcher.Dispose()
 return $userObject
}
#--------------------------------------------------------------------------------------------------#
Set-Variable -name forestRootDn -option Constant -scope Script -value (Get-ForestRootDn)
Set-Variable -name dbNull -option Constant -value ([System.DBNull]::Value) # http://msdn.microsoft.com/en-us/library/system.dbnull.aspx
Set-Variable -name databases -option Constant -value @("mssql2008-east.ad.mydomain.local","mssql2008-west.ad.mydomain.local","mssql2008-europe.ad.mydomain.local","mssql2008-japan.ad.mydomain.local")
Set-Variable -name idleDays -option Constant -value 14
#--------------------------------------------------------------------------------------------------#
$blackberryUsers = @()

foreach($sqlServer in $databases) {
 $sqlConnection = New-Object System.Data.SQLClient.SQLConnection
 $sqlConnection.ConnectionString = "server=$sqlServer;database=BESMgmt;trusted_connection=true;"
 $sqlConnection.Open()
 
 $sqlCommand = New-Object System.Data.SQLClient.SQLCommand
 $sqlCommand.Connection = $sqlConnection
 $sqlCommand.CommandText = "SELECT u.DisplayName as DisplayName, u.MailboxSMTPAddr as MailboxSMTPAddr, u.PIN as PIN, d.phonenumber as phonenumber, s.MachineName as MachineName, t.LastFwdTime as LastFwdTime, t.LastSentTime as LastSentTime, t.MsgsPending as MsgsPending FROM UserConfig u, SyncDeviceMgmtSummary d, ServerConfig s, UserStats t WHERE u.id = d.userconfigid and u.ServerConfigId = s.id and u.id = t.userconfigid"
 
 $sqlDataReader = $sqlCommand.ExecuteReader()
 
 if($sqlDataReader.HasRows -eq $true) {
  while($sqlDataReader.Read()) {
   $displayName = $sqlDataReader.Item("DisplayName")
   $proxyAddress = $sqlDataReader.Item("MailboxSMTPAddr")
   $pin = $sqlDataReader.Item("PIN")
   $phoneNumber = $sqlDataReader.Item("phonenumber")
   $blackberryServer = $sqlDataReader.Item("MachineName")
   $lastFwdTime = ($sqlDataReader.Item("LastFwdTime"))
   $lastSentTime = $sqlDataReader.Item("LastSentTime") 
   $msgsPending = $sqlDataReader.Item("MsgsPending")
 
   if($lastSentTime -eq $dbNull) { $lastSentTime = "No Contact" } else { $lastSentTime = (Get-Date $lastSentTime) }
   if($lastFwdTime -eq $dbNull) { $lastFwdTime = "No Contact" } else { $lastFwdTime = (Get-Date $lastFwdTime) }
   
   if(($lastSentTime -eq "No Contact" -or $lastFwdTime -eq "No Contact") -or ((Get-Date $lastSentTime) -le (Get-Date).AddDays(-$idleDays) -or (Get-Date $lastFwdTime) -le (Get-Date).AddDays(-$idleDays))) {
    $userObject = Find-ActiveDirectoryObjectByEMailAddress $proxyAddress
    if($userObject -eq $null) {
     Write-Host "USER NOT FOUND: $proxyAddress" -foregroundColor Red
    } else {
     $blackberryUser = New-Object -typeName PSObject
     Add-Member -inputObject $blackberryUser -type NoteProperty -name "Domain" -value ((Get-ObjectAdDomain $userObject.distinguishedName.ToString()).Split(".")[0]).ToUpper()
     Add-Member -inputObject $blackberryUser -type NoteProperty -name "User ID" -value $userObject.sAMAccountName.ToString()
     Add-Member -inputObject $blackberryUser -type NoteProperty -name "First Name" -value $userObject.givenName.ToString()
     Add-Member -inputObject $blackberryUser -type NoteProperty -name "Last Name" -value $userObject.sn.ToString()
     Add-Member -inputObject $blackberryUser -type NoteProperty -name "Display Name" -value $userObject.displayName.ToString()
     Add-Member -inputObject $blackberryUser -type NoteProperty -name "E-Mail Address" -value $userObject.mail.ToString()
     Add-Member -inputObject $blackberryUser -type NoteProperty -name "Last Message Forwarded" -value $lastFwdTime
     Add-Member -inputObject $blackberryUser -type NoteProperty -name "Last Message Sent" -value $lastSentTime
     Add-Member -inputObject $blackberryUser -type NoteProperty -name "Messages Pending" -value $msgsPending
     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 $userObject.telephoneNumber.ToString()
     Add-Member -inputObject $blackberryUser -type NoteProperty -name "Street Address" -value ($userObject.streetAddress.ToString() -replace "`r`n",", ")
     Add-Member -inputObject $blackberryUser -type NoteProperty -name "City" -value $userObject.l.ToString()
     Add-Member -inputObject $blackberryUser -type NoteProperty -name "State" -value $userObject.st.ToString()
     Add-Member -inputObject $blackberryUser -type NoteProperty -name "Zip Code" -value $userObject.postalCode.ToString()
     Add-Member -inputObject $blackberryUser -type NoteProperty -name "Country" -value $userObject.c.ToString()
     Add-Member -inputObject $blackberryUser -type NoteProperty -name "BlackBerry Server" -value $blackberryServer
     $blackberryUsers += $blackberryUser
    }
   }
  }
 }
 $sqlConnection.Close()
}

$blackberryUsers | Export-Csv -path "Stale Blackberry User Report.csv" -noTypeInformation

Monday, April 18, 2011

Replicating UNIX "tail -f" in PowerShell

Building upon my previous blog post, Unix Tail-like Functionality in PowerShell Revisited, I have completed the next step in providing tail functionality in my PowerShell scripts; the ability to emulate the "-f" argument.

From the TAIL(1) man page:
The -f option causes tail to not stop when end of file is reached, but rather to wait for additional data to be appended to the input.
My approach to replicating this functionality in PowerShell is to once again take advantage of System.IO.FileStream Class and read from the end of file as I did in my previous tail emulations. Originally, I thought this was going to be a more difficult task to accomplish but since I learned so much from my prior attempts, it turned out to be fairly simple and a lot less code to implement. My solution focuses on the fact that the file size grows as more data is appended to the text file. If I know how large the file was in a prior reading than it is currently, I know how many bytes have been added to the file and from there, I know the number of bytes I need to read from the end of the file and return to the console. All I need is a looping routine to constantly check the file for changes. In my code sample below, I emulate "tail -f" to the console by first reading the last 10 lines of a hypothetical BlackBerry Enterprise Server Management Agent log file (on an active server this file grows constantly) on a remote server as Unix "tail -f" would then start to monitor the log file for changes by comparing the file size waiting 100 milliseconds between each comparison. This process will continue until you send a break or the file is deleted.

With this bit of starter code, you should be able to implement some unique tools to monitor and react to data in log files in real-time.

UPDATE: I have made some improvements to this 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
}
#--------------------------------------------------------------------------------------------------#
Function Read-FileUpdates($fileName,$startSize) {
 $asciiEncoding = New-Object System.Text.ASCIIEncoding
 while([System.IO.File]::Exists($fileName)) {
  $fileStream = New-Object System.IO.FileStream($fileName,[System.IO.FileMode]::Open,[System.IO.FileAccess]::Read,[System.IO.FileShare]::ReadWrite)
  $currentFileSize = $fileStream.Length
  if($currentFileSize -gt $startSize) {
   $byteChunk = $currentFileSize - $startSize
   [byte[]] $bytesRead = New-Object byte[] $byteChunk
   $fileStream.Seek((-$byteChunk), [System.IO.SeekOrigin]::End) | Out-Null
   $fileStream.Read($bytesRead, 0, $byteChunk) | Out-Null
   Write-Host ($asciiEncoding.GetString($bytesRead)) -noNewLine
   $startSize = $currentFileSize
  }
  $fileStream.Close()
  Start-Sleep -milliseconds 100
 }
}
#--------------------------------------------------------------------------------------------------#
Set-Variable -name inputFile -option Constant -value "\\japan-bes.ad.mydomain.local\E$\Program Files\Research In Motion\BlackBerry Enterprise Server\Logs\20110418\JAPAN-BES_MAGT_02_20110418_0001.txt"
#--------------------------------------------------------------------------------------------------#
if([System.IO.File]::Exists($inputFile)) {
 Write-Host (@(Read-EndOfFileByByteChunk $inputFile 10 1280) | Out-String) -noNewLine
 $fileStream = New-Object System.IO.FileStream($inputFile,[System.IO.FileMode]::Open,[System.IO.FileAccess]::Read,[System.IO.FileShare]::ReadWrite)
 $fileSize = $fileStream.Length
 $fileStream.Close()
 Read-FileUpdates $inputFile $fileSize
} else {
 Write-Host "Could not find $inputFile..." -foregroundColor Red
}

Monday, April 11, 2011

Audit Group Policy Objects

I needed to review a group of Group Policy Objects and determine if an Active Directory group is listed in the permissions for the GPO. If it was not, I needed to be aware of it. In the code sample, I take advantage of the Group Policy module to loop through all the GPOs in a domain that have "Workstation" in the display name, identify the ones that do not include the "ASIA\DesktopTeam" in the permission list and output them to a comma separated values text file.
#--------------------------------------------------------------------------------------------------#
Set-Variable -name accountDomain -option Constant -value "ASIA"
Set-Variable -name accountsAMAccountName -option Constant -value "DesktopTeam"
Set-Variable -name workingDomain -option Constant -value "asia.ad.mycompany.local"
Set-Variable -name matchFilter -option Constant -value "Workstation"
Set-Variable -name outputFile -option Constant -value  ($workingDomain + "-" + $matchFilter + ".csv")
#--------------------------------------------------------------------------------------------------#
if((Get-Host).Version.Major -ne 2) {
 Write-Host "You must have PowerShell V2 installed to use this script. Exiting!" -foregroundColor Red
 exit
}
if(!(Get-Module -ListAvailable | Where-Object { $_.Name -eq "GroupPolicy" }).Name) {
 Write-host "Unable to locate the GroupPolicy module. Exiting!" -foregroundColor Red
 exit
}
Import-Module -Name GroupPolicy

$badGpos = @()
$gpoList = Get-GPO -All -Domain $workingDomain | Where-Object { $_.DisplayName -match $matchFilter }
foreach($gpo in $gpoList) {
 $gpoPermissions = Get-GPPermissions -guid $gpo.Id -Domain $workingDomain -All
 $found = $false
 foreach($gpoPermission in $gpoPermissions) {
  if($gpoPermission.Trustee.Domain -eq $domain -and $gpoPermission.Trustee.Name -eq $sAMAccountName) {
   $found = $true
  }
 }
 if($found -eq $false) {
  $badGpos += $gpo
 }
}

$badGpos | Export-Csv -path $outputFile -noTypeInformation

Remove-Module -Name GroupPolicy

Wednesday, March 30, 2011

Creating Dynamic User Objects in Active Directory

Account management in Active Directory comes at a cost of time and effort for an Administrator. This is especially burdensome for objects that are only needed for a limited timeframe such as testing objects. Active Directory provides a low cost method for dealing with temporary objects through the dynamic object class which provides automatic garbage collection based on a time-to-live value in seconds. This benefit does come with several limitations:
  • The maximum TTL of a dynamic object is 31,556,926 seconds (1 year).
  • A deleted dynamic object due to its TTL expiring does not leave a tombstone behind.
  • All DCs holding replicas of dynamic objects must run on Windows Server 2003 or greater.
  • Dynamic entries with TTL values are supported in all partitions except the Configuration partition and Schema partition.
  • Active Directory Domain Services do not publish the optional dynamicSubtrees attribute, as described in the RFC 2589, in the root DSE object.
  • Dynamic entries are handled similar to non-dynamic entries when processing search, compare, add, delete, modify, and modifyDN operations.
  • There is no way to change a static entry into a dynamic entry and vice-versa.
  • A non-dynamic entry cannot be added subordinate to a dynamic entry.
In the code sample below, I take the advantage of the dynamic object class to create 50 test accounts that will expire and delete themselves from the forest on May 1, 2011 at 2:30 pm. The accounts created for a hypothetical finance application testing are placed in a specific OU, provided a unique sAMAccountName (tested to ensure this) and a pseudo-random password created for them. All this information is placed in a comma separated values text file so you can pass it over to your Quality Assurance team.

And remember, this script creates objects in Active Directory! Use at your own risk! I might have the best of intentions but my skill may betray you. Test, test and further test before implementing this code in a production environment.
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 "/","\/"))
}

Function Get-DomainDistinguishedName($domain) {
 return ("dc=" + $domain.Replace(".",",dc="))
}

Function Test-sAMAccountName($sAMAccountName,$domain) {
 $objectConnection = New-Object -comObject "ADODB.Connection"
 $objectConnection.Open("Provider=ADsDSOObject;")
 $objectCommand = New-Object -comObject "ADODB.Command"
 $objectCommand.ActiveConnection = $objectConnection

 $ldapBase = ("LDAP://" + (Get-LocalDomainController $domain) + "/" + (Get-DomainDistinguishedName $domain))
 $ldapAttr = "sAMAccountName"
 $ldapScope = "subtree"
 $ldapFilter = "(&(objectClass=user)(sAMAccountName=$sAMAccountName))"
 $ldapQuery= "<$ldapBase>;$ldapFilter;$ldapAttr;$ldapScope"
 $objectCommand.CommandText = $ldapQuery
 $objectRecordSet = $objectCommand.Execute()
 if($objectRecordSet.RecordCount -gt 0) {
  $found = $true
 } else {
  $found = $false
 }
 return $found 
}
#--------------------------------------------------------------------------------------------------#
Set-Variable -name destinationOu -option Constant -value "ou=Finance Testing,dc=americas,dc=ad,dc=mycompany,dc=local"
Set-Variable -name totalNumberOfAccounts -option Constant -value 50
Set-Variable -name givenName -option Constant -value "Test"
Set-Variable -name sn -option Constant -value "Account"
Set-Variable -name passwordSize -option Constant -value 12
#--------------------------------------------------------------------------------------------------#
$randomNumber = New-Object System.Random
$accounts = @()
$endDate = "05/01/2011 14:30:00"
$entryTtlSeconds = [int]((Get-Date $endDate) - (Get-Date)).TotalSeconds
#######################################################################
# Or you can do a time span instead for the account time to live i.e.,
# $entryTtlSeconds = [int](New-TimeSpan -days 90).TotalSeconds 
# $entryTtlSeconds = [int](New-TimeSpan -minutes 60).TotalSeconds 
#######################################################################
if($entryTtlSeconds -gt 31556926) {
 Write-Host "Time-to-live greater than 1 year. Exiting!" -foregroundColor Red
 exit
}

$destinationOuObject = Get-ActiveDirectoryObject $destinationOu

if($destinationOuObject.distinguishedName -ne $destinationOu) {
 Write-Host "Unable to connect to $destinationOu. Exiting!" -foregroundColor Red
 exit
}

for($i = 1;$i -le $totalNumberOfAccounts;$i++) {
 $accountName = ($givenName + $sn + "$i")
 Write-Host "Creating $accountName..."
 
 if(Test-sAMAccountName $accountName (Get-ObjectADDomain $destinationOu)) {
  Write-Host "$accountName already exists, skipping..." -foregroundColor Yellow
  continue
 }
 
 $userObject = $destinationOuObject.Create("user","CN=$accountName")
 $userObject.PutEx(2,"objectClass",@("dynamicObject","user"))
 $userObject.Put("entryTTL",$entryTtlSeconds)
 $userObject.Put("sAMAccountName", $accountName) # Mandatory attribute
 $userObject.Put("givenName",$givenName)
 $userObject.Put("sn",($sn + "$i"))
 $userObject.Put("displayName",($givenName + " " + $sn + "$i"))
 $userObject.Put("description","Account Used for Application Testing")
 $userObject.Put("wWWHomePage","http://sharepointsite/projects/newfinancesystem")
 $userObject.Put("userPrincipalName",($accountName + "@" + (Get-ObjectADDomain $destinationOu)))
 $userObject.SetInfo()
 
 $password = ""
 for($x = 1;$x -le $passwordSize;$x++) {
  $password += [char]($randomNumber.Next(33,126))
 }
 
 $userObject.SetPassword($password)
 $userObject.Put("userAccountControl", 512)
 $userObject.SetInfo()

 $account = New-Object -typeName PSObject
 Add-Member -inputObject $account -type NoteProperty -name "domain" -value (Get-ObjectADDomain $destinationOu).Split(".")[0]
 Add-Member -inputObject $account -type NoteProperty -name "sAMAccountName" -value ($userObject.sAMAccountName).ToString()
 Add-Member -inputObject $account -type NoteProperty -name "givenName" -value ($userObject.givenName).ToString()
 Add-Member -inputObject $account -type NoteProperty -name "sn" -value ($userObject.sn).ToString()
 Add-Member -inputObject $account -type NoteProperty -name "userPrincipalName" -value ($userObject.userPrincipalName).ToString() 
 Add-Member -inputObject $account -type NoteProperty -name "password" -value $password
 $accounts += $account
}

$accounts | Export-Csv -path "$givenName $sn List.csv" -noTypeInformation