Showing posts with label PowerShell. Show all posts
Showing posts with label PowerShell. Show all posts

Wednesday, November 17, 2021

Managing Microsoft 365 Licenses by using Microsoft Graph

Microsoft has announced that after June 2022, the MSOL and AzureAD cmdlets for managing user licenses in Microsoft 365 will cease working. These cmdlets rely on management functionality that is being retired. To manage licenses programmatically, you need to start using Microsoft Graph.

Here is the announcement:

Microsoft Graph is a web-API that you can use to manage Microsoft 365 users, groups, and services. If you're a programmer, then perhaps the idea of building a web request to perform administrative tasks sounds like a good idea. However, for an admin guy like me that typically uses PowerShell cmdlets for management tasks, building web requests is a bit painful. Fortunately, the Microsoft Graph PowerShell SDK has been released that provides PowerShell cmdlets to access Microsoft Graph features.

To get more information about the Microsoft Graph PowerShell SDK:

 Connecting with Microsoft Graph

Just like you use Connect-AzureAD or Connect-MsolService, for Microsoft Graph, you use Connect-MgGraph. When you connect, you need to specify a scope that defines your permissions. So, unlike previous versions, the connection does not automatically gain the full permissions based on your roles like Global Admin. I haven't experimented with exactly which scopes are required to manage user licenses. However, I can confirm that the following example does work.

Connect-MgGraph -Scopes "User.ReadWrite.All","Directory.ReadWrite.All"

To get more information about Microsoft Graph scopes:

License Structure and Naming

If you've been managing licenses through the web interface in Microsoft 365 or the MSOL cmdlets, you're used to seeing license names such as Office 365 E3. When you manage licenses by using Microsoft Graph, you need to know the SkuId property of the licenses available in your tenant. You can obtain the SkuId for a license by using Get-MgSubscribedSku as shown in the following figure. The SkuPartNumber property is a more user friendly name that you can recognize.

Within each licenses type, there are also service plans. These correlate with apps provided by a license such as Exchange Online (Plan 2). To enable or disable the service plans, you need to use the ServicePlanId for a  service plan. If you place your subscribed SKUs in a variable, you can view the service plans included in the SKU as shown in the following figure.

To get a list of generally available SKUs and their service plans:

Viewing Assigned Licenses

You can view the licenses assigned to a user by using the Get-MgUserLicenseDetail cmdlet as shown in the following example:

Get-MgUserLicenseDetail -UserId user@domain.com

The results of this command return the users license assigned to the user. An array of licenses is returned if multiple licenses have been assigned. Within each license returned, you can view the ServicePlans property to see if any service plans have been disabled for a user.

You can also query assigned license information by using Get-MgUser. The licensing information isn't returned by default and you need to specify that the AssignedLicenses property will be retrieved as shown in the figure below. Notice that these results list the service plans that are disabled for a license.

Querying Users with Assigned Licenses

If you want to query all of the users with a specific license, you can do this by using Get-MgUser with a filter for a specific SkuId. The example below shows the syntax.

Get-MgUser -Filter "assignedLicenses/any(x:x/skuId eq 78e66a63-337a-4a9a-8959-41c6654dfb56)" -Property AssignedLicenses,UserPrincipalName,Id

When you are filtering based on AssignedLicenses there are some limitations on the results returned. By default, only 100 results are returned. If you use the PageSize parameter, you can specify up to 999 results are returned as shown below.

Get-MgUser -Filter "assignedLicenses/any(x:x/skuId eq 78e66a63-337a-4a9a-8959-41c6654dfb56)" -PageSize 999

If you have a larger tenant, and you try to use the All parameter to return results larger than these limits, you will be the following error Get-MgUser : The specified page token value has expired and can no longer be included in your request.To avoid this error, you need to query a list of all users and then filter by using Where-Object as shown below.

$allusers = Get-MgUser -All -Property AssignedLicenses,UserPrincipalName,Id,DisplayName
$A1plusUsers = $allusers | Where-Object {$_.AssignedLicenses.SkuId -contains "78e66a63-337a-4a9a-8959-41c6654dfb56"}

Modifying Assigned Licenses

To add or remove licenses for a user, you use the Set-MgUserLicense cmdlet. When you run the cmdlet, you need to provide the following parameters:

  • UserId. The user being modified. You can specify the user by the object Id or UserPrincipalName.
  • AddLicenses. A hash table that specifies the SkuId of a license being added and the ServicePlanId of any service plans that are being disabled.
  • RemoveLicenses. A string that identifies the SkuId of a license being removed.

The following code shows how to build a hash table for the AddLicenses parameter. This specifies a license SkuId and a service plan that's disabled in the license.

$A1FacultySku = @{
    SkuID = "94763226-9b3c-4e75-a931-5c89701abe66"
    DisabledPlans = "9aaf7827-d63c-4b61-89c3-182f06f82e5c"
}

The following code lists a SkuID that will be disabled.

$A1PlusFacultySku = "78e66a63-337a-4a9a-8959-41c6654dfb56"

The command that modifies the user license is below. Note that the UserId parameter will accept a UPN also.

Set-MgUserLicense -UserId $User.Id -AddLicenses $A1FacultySku -RemoveLicenses $A1PlusFacultySku

The RemoveLicenses and AddLicenses parameters are mandatory. If you don't provide an empty array, you'll get an error such as Set-MgUserLicense : One or more parameters of the function import 'assignLicense' are missing from the request payload. The missing parameters are: removeLicenses. If you don't want to remove any licenses, you need to provide an empty array for RemoveLicenses as shown below. If you are only removing licenses, you need to provide an empty array for the AddLicenses parameter.

Set-MgUserLicense -UserId $User.id -AddLicenses $A1FacultySku -RemoveLicenses @()

If you want to modify the disabled plans for a licenses, you build a new hash table with the license and all of the plans you want disabled. Then you apply the new hash table with the AddLicenses parameter. The new license assignment overwrites the existing license assignment.

If you want to add multiple licenses, you can provide a comma separated list of hash tables. I have not explicitly tested, but I think providing an array with the hash tables would also work.

If you want to remove multiple licenses, create an array with the SkuIDs that you want to remove.




Tuesday, April 17, 2018

Using Saved Credentials with PowerShell Scripts

Most of the time in a Windows environment, a Windows PowerShell script runs in the security context of the user account that is running it. If you have a scheduled task that runs a PowerShell script then you can specify a user account (service account) that is used to run the task. The service account needs to be assigned the necessary permissions to perform any actions in the script.

In some cases, you need to script to access remote resources and sign in. For example, if you have a scheduled task that pulls reporting information from Office 365. PowerShell has a method for storing encrypted credentials that can only be accessed the user account that stored them. The code below prompts you for a credential and then stored is encrypted in an XML file.

$credential = Get-Credential
$credential | Export-CliXml D:\credential.xml

To retrieve the credential and using it within a script, you read it from the XML file:

$credential = Import-CliXml D:\credential.xml

If you created the XML file while signed in as your account and then attempt to read it from a script running as a service account, it will fail because it can't be decrypted. Information in the correct user profile is required to decrypt the credential in the XML file. This also means that the encrypted file cannot be moved to another computer because the information required to decrypt the credential is not available on other computers.

To prepare a credential for use with a scheduled task, you can sign in as the service account to create it. This works if the service account has permission to sign in interactively or via remote desktop. However, I'm often in scenarios where I remote in to client sites as a specific user account and don't have the option to sign in directly as the service account.

If you can't sign in directly as the service account, you can spawn a new PowerShell prompt that runs as the service account and then create the XML file from the new PowerShell prompt. Use the following command to spawn a new PowerShell prompt and enter runas credentials:

Start-Process powershell.exe -Credential ""

Sign in as the service account when prompted with credentials. Then you can create an XML file with credentials that can be read by the service account when the script runs.

Note that in some cases, you need close your initial PowerShell prompt before you can enter commands in the new PowerShell prompt. This seems to vary by operating system and I've not mapped out the exact dependencies.


Wednesday, September 20, 2017

ACMESharp and Visual Studio Code Error

I lost a fair bit of time troubleshooting an error that turned out to be an odd compatibility issue between the ACMESharp module and Visual Studio Code. Hopefully this saves someone else the time.

In Visual Studio Code, when running Submit-ACMECertificate, I got this error:
Submit-ACMECertificate : Error resolving type specified in JSON 'ACMESharp.PKI.CsrDetails, ACMESharp'. Path '$type', line 2, position 48.
At line:1 char:1
+ Submit-ACMECertificate nosub
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~
    + CategoryInfo          : NotSpecified: (:) [Submit-ACMECertificate], JsonSerializationException
    + FullyQualifiedErrorId : Newtonsoft.Json.JsonSerializationException,ACMESharp.POSH.SubmitCertificate



I read a bunch of stuff about Newtonsoft.Json being installed in the Global Assembly Cache, but it wasn't on my computer.

I tested the same script on my desktop instead of the laptop. Nope, same error.

It turned out that the command worked just fine at a normal PowerShell prompt. So, my best guess is that Visual Studio Code is doing something different with that Json conversion that PowerShell by itself doesn't.

Worked all good:


I've run into oddities in the past when using PowerShell ISE for some things. However, that was minor stuff like text colors not updating. First time I've seen something this large dependent on the dev tool.

Tuesday, April 18, 2017

Script to Synchronize Primary Email Address with UPN

When planning an Office 365 implementation, it is best practice to start by assuming that UPN for signing in to Office 365 should match the user email address. If you don't configure it this way, then users have two separate items (their UPN for signing in and their email address) that look very similar. In many cases users are confused by the similarity.

If you are synchronizing  your on-premises Active Directory with Office 365 (in most cases you do) then you need to set the UPN for the on-premises user accounts with the correct UPN. The UPN from on-premises user accounts is synchronized to Office 365 to create the ID for signing in.

Most organizations are not using the UPN on user accounts for authentication on-premises. The option has been there since Windows 2000, but most organizations still use the domainname\username format for authentication. However, you need to verify if any user accounts are using the UPN for authentication before making this change. At minimum, you should communicate with your application and system administrators to see if they are aware of anything that might use UPNs. If your organization has issued certificates to users, they might be using UPN as the unique identifier for the certificate.

The script below does the following:
  • Obtains a list of all users where the proxyAddresses attribute has a value. This is done so that the result include only user accounts with an Exchange attributes configured.
  • Identifies the primary email address based on the all caps "SMTP:" text.
  • Strips out the "SMTP:" text from the primary SMTP address.
  • If the new UPN and the existing UPN do not match the user account is updated and the change is logged.
The location and name of the log file are configured in $logfile. You need to manually configure this variable and verify that the necessary folders exist.

  
 #Log folder must already exist  
 $logfile = "C:\Scripts\SyncUPN.txt"  
   
 #Adds timestamp to log file  
 Get-Date | Out-File -FilePath $logfile -Append  
   
 #Obtains only users with valid proxyAddresses attribute   
 $users = Get-ADUser -Properties proxyAddresses -Filter {proxyAddresses -like "*"}  
   
 #Prepare variables for processing status  
 $total = $users.count  
 $current = 0  
   
 Foreach ($u in $users) {  
   #Find primary SMTP address for user  
   $primarySMTP = $u.proxyAddresses | Where-Object {$_ -clike "SMTP:*"}  
       
   #Remove "SMTP:" to create the new UPN value  
   $newUPN = $primarySMTP.Substring(5)  
       
   #Set the new UPN value only if required  
   If ($u.UserPrincipalName -ne $newUPN) {  
     $u.DistinguishedName + " Old UPN: " + $u.UserPrincipalName | Out-File -FilePath $logfile -Append  
     $u.DistinguishedName + " New UPN: " + $newUPN | Out-File -FilePath $logfile -Append  
     Set-ADUser $u -UserPrincipalName $newUPN      
   } #end if  
   
   #Processing status  
   $current += 1  
   Write-Progress -Activity "Processing users to update UPN to primary email address" -Status "Progress: $current" -PercentComplete ($current/$total*100)   
        
 } #end foreach  

Script to Remove Old Domains from User Email Addresses

When managing email addresses and domains in Exchange Server, old email addresses are never removed automatically. This is good because it ensures that email addresses on a mailbox are never accidentally lost. However, you may want to clean up old domains or address formats that are no longer in use.

Some common scenarios where you might want to remove an old domain:
  • An SMB deployment of Exchange Server where a .local domain was added as the first domain for email addresses.
  • Old GroupWise addresses are left in place from an older migration.
  • Obsolete domain left over from a company merger many years ago
I often find that obsolete domains are identified when I run IDFix as part of preparing to migrate to Office 365. To simplify the removal of obsolete domains, I have created the following script.

A few things to note:
  • You need to set $RemovePattern to identify the domain to be removed. Any email addresses matching this pattern will be removed from proxyAddresses attribute in Active Directory objects.
  • The script uses Get-ADObject rather than Get-ADUser to make sure that the domain is removed from distribution groups too.
  • This version of the script is capable of removing multiple instances of a matching email address. So, if a user has several email addresses in the old domain, all of them are removed.
  • At the end of the script, I use Write-Progress to display a status bar. It's not necessary, but if there is a large number of users it's nice to see activity on the screen instead of just waiting and hoping it's doing something.

 #This pattern is used to match the email addresses being removed.  
 #Test that this pattern finds the correct users and email addresses  
 #before running this script.  
 #Example: $pattern = "smtp:*@olddomain.com"  
 $RemovePattern = "smtp:*@olddomain.local"  
   
 Import-Module ActiveDirectory #only required for 2008 R2  
   
 #Get the users that have an email address that matches the pattern  
 Clear-Host  
 Write-Host "Querying objects...This may take a moment"  
 Write-Host ""  
 $objects = Get-ADObject -Filter {ProxyAddresses -like $RemovePattern} -Properties ProxyAddresses  
   
 #Identify address being removed from first user for warning  
 [String]$proxyexample = $objects[0].proxyaddresses -like $RemovePattern  
   
 #Display warning and get confirmation  
 Write-Host "You are going to remove email addresses that match the following pattern:"  
 Write-Host -ForegroundColor Red "$RemovePattern"  
 Write-Host ""  
 Write-Host "This is an example from the first object:"  
 Write-Host -ForegroundColor Red "$proxyexample"  
 Write-Host ""  
 Write-Host "This will modify $($objects.count) objects"  
 Write-Host ""  
   
 $confirm = Read-Host "Enter Y to continue"  
 If ($confirm -ne "Y") {Break}  
   
 #Prepare variables for processing status  
 $total = $objects.count  
 $current = 0  
   
 #Processing users to remove addresses  
 Foreach ($o in $objects) {  
     
   #Build list of addresses to remove for object  
   #required because there might be multiple that match  
   $proxy= New-Object System.Collections.ArrayList  
   
   Foreach ($a in ($o.ProxyAddresses)) {  
     If ($a -like $RemovePattern) {  
       $proxy.add($a) | Out-Null  
     } #end if  
   } #end foreach  
     
   #Remove each bad address  
   Foreach ($p in $proxy) {  
     Set-ADObject $o -Remove @{'proxyAddresses'=$p}  
   }  
     
   #Pause  
   
   #Processing status  
   $current += 1  
   Write-Progress -Activity "Removing email addresses that match pattern" -Status "Progress: $current" -PercentComplete ($current/$total*100)   
 } #end foreach  

Thursday, April 13, 2017

Change All UPNs in a Domain

I needed to update all UPNs in a domain today. It was pretty quick to figure out, but here is one line to take care of it for you.

Get-ADUser -Filter * | ForEach-Object { Set-ADUser $_ -UserPrincipalName ($_.UserPrincipalName).Replace("OldDomain","NewDomain")}
Remember to make the pattern in the OldDomain unique enough that you don't accidentally change things you don't intend to. For example, if you are changing from a .local domain in the UPN to a .com, make sure that you replace ".local" and not "local" on the off chance one of the user IDs includes "local" in the name.

If there are any user accounts without a UPN, then an error is generated for those accounts. My domain had 4 accounts without a UPN:
  • krbtgt - default account used for kerberos
  • IWAM_ServerName - Old IIS account from Windows 2003
  • IUSR_ServerName - Old IIS account from Windows 2003
  • support_XXXXXXX - Used by Help and Support service

Suppress Results when Adding Items to an ArrayList

I ran into a mildly annoying feature when adding items to an array list when using PowerShell today. An array list is an expandable array of items with better performance than a normal array when working with large data sets.

Each time I added an item to the array list, it echoed back the index number of the list item. When I added the first item in the list, the number 0 was displayed on the screen. Adding a second item would echo back the number 1. For example:
$proxylist.add($a)
0

I would prefer my script to be silent when running except when there is data that I want to display. However, there is no option obviously available for that purpose. Instead, you need to redirect the output to $null. There are a few ways to do this and any one will work:
$proxylist.add($a) > $null
$proxylist.add($a) | Out-Null
[void]$proxylist.add($a)

Saturday, January 28, 2017

PowerShell Script for Math Homework

My daughter needs to practice her multiplication tables. So, I came up with a little script that can help.

You can use the script on any Windows computer. Copy the code below into a text file and then name that file something like multiply.ps1. The file needs to end in .ps1 for Windows to recognize it as PowerShell.

You may also need to allow PowerShell scripts on your computer. Open a PowerShell prompt and run Set-ExecutionPolicy RemoteSigned.

If you have the file saved on your desktop, right-click it and select Run with Windows PowerShell


 Clear-Host  
 $questions = Read-Host "How many questions?"   
     
  For($i=1;$i -le $questions;$i++) {   
   $first = Get-Random -Minimum 0 -Maximum 10   
   $second = Get-Random -Minimum 0 -Maximum 10   
   $answer = $first * $second   
     
   Do {  
     Clear-Host  
     Write-Host "$first x $second = ??"   
     $response = Read-Host "Enter your answer"   
      
     If ($response -eq $answer) {   
          Write-Host "That is correct!"  
     }   
     Else {   
          Write-Host "Try Again"  
       Pause  
       Clear-Host  
     }  
   }  
   Until ($response -eq $answer)  
 }  
   
 Clear-Host   
   
 Write-Host "Well done! $questions questions completed!"
 Pause  

Tuesday, January 10, 2017

PowerShell Learning Resources

I'm doing some onsite PowerShell training this week and realized that I mention lots of resources but haven't provided a list of them anywhere for easy access. So, this posting is my best summary of Windows PowerShell related learning content from Microsoft. There are also a bunch of my links to my blog articles that I use as examples in class.

General Resources

Microsoft makes a lot of content available online for free. Here is a high level list:

Windows PowerShell Resources

Here are some resources specifically related to PowerShell:

PowerShell Examples

The following are examples of using PowerShell from my blog. They may or may not be useful for your purposes. I use them in class as examples that we review.



Wednesday, January 4, 2017

Finding Stale SIDs on GPOs

One of my clients has a tool from Microsoft that scans the AD infrastructure and generates a report of items that can fixed/improved. One of the items on a recent report was stale SIDs on GPOs that could affect GPO processing. However, the tools didn't give us the stales SIDs. Just said we had them.

First, let's talk about what a stale SID is...

All Windows security is based on a Security Identifier (SID) that is unique for each user or group. In the Access Control List (ACL) for an resource, it is the SID that is assigned permissions, not the name of a user or group. The Windows tools just translate that SID back to a user or group name for use to manage them easier.

A stale SID occurs when a user or group has been assigned permissions to access a resource and the user or group is later deleted. There is no link back from the user or group to where the permissions have been assigned. So, Windows cannot go back and remove the SID from the ACL. The SID that's left behind without a matching user or group object is a stale SID. When you are using graphical tools to view permissions and it shows a SID instead of a user or group name, that's typically a stale SID.

NOTE: Just because a graphical tool is showing a SID does not 100% guaranteed that the SID is stale. It could be a user or group from a trusted domain that the tool is having trouble resolving. If you have trusted forests or domains, you should verify that SID is in your domain.

If there were only a few GPOs, it would be fairly fast to use the Group Policy Management Console to find the stale SIDs. However, this client had about 500 GPOs and manually verifying the permissions would have been quite painful.

To find the stale SIDs on GPOs, I wrote up a small script that scans the GPOs and finds any security permissions that are unknown:


 Import-Module GroupPolicy  
   
 $gpo = Get-GPO -All  
   
 Foreach ($g in $gpo) {  
   $permissions = $g.getsecurityinfo()  
   Foreach ($p in $permissions) {  
     If ($p.Trustee.SidType -eq "unknown") {  
       Write-Host "Policy with unknown SID: $($g.DisplayName)"  
       Write-Host "Trustee SID: $($p.Trustee.Sid)"  
     } #end if  
   } #end foreach permissions  
 } #end foreach gpo  
   
    

Here is what the script does:
  • Loads the GroupPolicy module (required for Windows Server 2008 R2, Windows Server 2012 will do that automatically.
  • Pulls all GPOs into the variable $gpo.
  • Starts a foreach loop to process each gpo in $gpo.
  • Pulls the permissions for the current GPO into the $permissions variable by suing the getsecurityinfo() function for gpo objects.
  • Starts a foreach loop to process each permission in $permissions.
  • Tests whether the SidType for the trustee in the permissions is unknown. An unknown SidType identifies a SID that couldn't be resolved to a user or group.
  • The name of the gpo and the SID of the trustee are written to screen.
 This script writes output to screen, but you could easily modify it to dump the output to fine instead.

Thursday, November 24, 2016

How you do things matters (PowerShell Performance)

I've run into a couple of things over the last month or so that were interesting from a PowerShell perspective. With relatively small amounts of data, performance really isn't a concern. If you're only working with a few hundred or few thousand items PowerShell is pretty quick no matter what. However, when you work with larger datasets, how you do things can make a big difference.

Reducing the number of operations

The first thing I want to highlight is that that the number of operations affects performance. I was recently building some scripts to build a test environment by copying the AD structure from production. I based all of my work on AD Mirror (https://gallery.technet.microsoft.com/scriptcenter/AD-Mirror-PowerShell-Module-331b1b12).

I ran into a performance issue populating group membership. The code from AD Mirror had the list of group members in a variable and added them by using a foreach loop like this:
foreach ($m in $newMembers) {
     Add-ADGroupMember -Identity $targetgroup -Members $m
}
This worked, but for groups with a large membership it was very slow. A group with 10,000 members took about 10 minutes to complete.

After doing some reading, I realized that the -Members parameter will accept an array. So, I changed it up to look like this:
Add-ADGroupMember -Identity $targetgroup -Members $newMembers
It still wasn't fast, but it was a couple of minutes instead of 10. It's still slow, but when you multiply that out across the 18,000 groups I needed to get setup, it added up to a lot of saved time.

As a side note, this is still slow because there is a lot to update in AD when the change is made. When a group member is added, both the group and user accounts need to be updated. Adding the members as a single lump means only a single change to the group, but each user account is still updated. For a group with 10,000 members, this means the loop does 20,000 updates while adding as a single set does 10,001 updates.

However, I did run into a problem with the largest groups. There must be some sort of limit on the data that can be passed or a timeout, because I had two groups with 27,000 and 20,000 users that didn't work by using the full array. Initally I went back to doing those with a loop. However, then I thought that a better way would be splitting the array into smaller sets and do them as several groups.

In my case, the sizing parameters are pretty well defined. So, I'm able to break the array up based on defined size limits. I'm sure there is a way to more gracefully break this up using variables instead of hard coded limits, but this met my needs for the moment.

This is my code that breaks up the array into chunks of 10000 accounts:
$newMembersCount=$newMembers.count
$part1 = $newMembers[0..9999]
Add-ADGroupMember -Identity $TargetGroupDn -Members $part1
   
Switch ($newMembersCount) {
   { $_ -gt 10000 } {
       Write-Host "Second 10K users"
       $part2 = $newMembers[10000..19999]
       Add-ADGroupMember -Identity $TargetGroupDn -Members $part2
       }
   { $_ -gt 20000 } {
       Write-Host "Third 10K users"
       $part3 = $newMembers[20000..29999]
       Add-ADGroupMember -Identity $TargetGroupDn -Members $part3
       }       
   { $_ -gt 30000 } {
       Write-Host "Fourth 10K users"
       $part4 = $newMembers[30000..39999]
       Add-ADGroupMember -Identity $TargetGroupDn -Members $part4
       }
} #End switch

Lists instead of arrays

When you want an array of objects in PowerShell, you typically start it by declaring an array like this:
$arrayvar = @()
Then you add stuff to it like this:
$arrayvar += $newitem
When you create and manage an array this way it ends up being very inefficient for large arrays. The array you create is a fixed size. Then when you use += to add an item, PowerShell creates a new array that contains the existing values and the new value. In a very large array that ends up being a lot of data movement with no benefit.

The fix for this is to create an array as a list that is not a fixed size:
$listvar = New-Object System.Collections.ArrayList
Then add items to the list like this:
$list.add($newitem)
The add function for a list is many times faster than using += for a fixed size array. When you are working with large lists it becomes exponentially faster. It's hard to quantify because you'll see varying performance differences based on the objects you're working with. However, this link has one example:
Here is some data from that link:
  • 10,000 items, array performance: 16 seconds
  • 10,000 items, list performance: 1 second
  • 100,000 items, array performance: 3230 seconds (53 minutes)
  • 100,000 items, list performance: 7 seconds
Update Apr 2017
The original version of this post suggested to use generic list instead of an array list. Someone with much more technical knowledge on this than me has suggested that array list is a better way to go. So, I've change the syntax above to use array list instead of generic list.

Wednesday, August 31, 2016

Script to Create Migration Batches

Migration batches are a nice feature introduced in Exchange 2013 for managing mailbox moves. In general they work pretty well, but it can be a bit awkward to work with batches in large organizations. The graphical interface only displays 500 mailboxes at a time and this can be limiting.

To get the batches exactly as you want them, you often end up exporting a list of mailboxes to a CSV file and then cutting that down into batches. You can create a batch by importing a CSV file.

To simplify this process, I've created a script that takes all the mailboxes from a list of source databases and generates the CSV files in batch sizes that you specify. For example, you can use both Admin1DB and Admin2DB as your source databases. If you specify a batch size of 200 mailboxes, then you'll get CSV files with 200 mailboxes except for the final CSV which has the left overs.

The script can also automatically create the migration batches if you turn that option on. In the script, you can specify one or more destination mailbox databases for the moves.

The script does not specify an archive database. If your users have archive mailboxes, then you'll need to edit the migration batches after creation and specify the correct databases for the archive mailboxes.

A final note: The script creates the migration batches, but does not start them. Starting the migration batches and the timing of it is up to you.

The script is below. I hope you find it useful.

 #This script selects users from existing source mailbox databases  
 #and creates migration batches to new Mailbox databases  
 #Typical use is migration projects, but could also be used for  
 #server retirement or cleaning up corrupted mailbox databases  
 #Multiple migration batches are created but you need to start them.  
   
 #Archive mailboxes are not included when the migration batches are  
 #created. To move archive mailboxes, edit the migration matches  
 #after they are created.  
   
 #Created by Byron Wright (@ByronWrightGeek)  
   
 #Set variables for creating batches  
   
 #BatchSize is the number of mailboxes in each batch  
 $BatchSize=50  
   
 #BatchName is text added on to the CSV name and batch name  
 #This is useful to uniquely identify groups such as  
 #Admin or Students  
 $BatchName="Batch"  
   
 #Path for CSV files  
 #This patch must already exist  
 $CsvPath="C:\Scripts\"  
   
 #Mailboxes in the the source mailbox databases that are put into migration batches  
 #One or more databases can be used  
 $SourceMbxDB="Mailbox Database 0999986598","VIP Users"  
   
 #When $CreateMigrationBatch is $true a migration batch is created from  
 #each CSV file. If it is $false then only the CSV files are created.  
 #If $false then you don't need to configure the destination database  
 $CreateMigrationBatch=$true  
   
 #Batches move mailboxes to the destination mailbox databases  
 #One or more databases can be specified  
 #When multiple databases are specified, the mailboxes are  
 #spread among the databases based on number of mailboxes and  
 #not size of mailboxes.  
 $DestinationDB="DB1","Mailbox Database 1840440945"  
   
 #Get list of mailboxes to move  
 Foreach ($s in $SourceMbxDB) {  
      $Mbx+=Get-Mailbox -Database $s  
 }  
   
 $MbxNumber=0  
   
 #Add a batch number property to each mailbox  
 #Add the EmailAddress property to each mailbox as required for the New-MigrationBatch CSV  
 Foreach ($m in $Mbx) {  
     $batch=[Math]::Floor(($MbxNumber)/$BatchSize)+1  
     $m | Add-Member -NotePropertyName Batch -NotePropertyValue $batch  
     $m | Add-Member -MemberType AliasProperty -Name EmailAddress -Value PrimarySMTPAddress  
     $mbxNumber++  
 }  
   
 $TotalBatches=[Math]::Floor(($Mbx.count)/$BatchSize)+1  
   
 #CreateCSV files and migration batches  
 For($b=1;$b -le $TotalBatches;$b++) {  
      $BatchFileName="$BatchName$b.csv"  
      $BatchPath=$CsvPath+$BatchFileName  
      $mbx | Where-Object Batch -eq $b | Select-Object Name,EmailAddress,Batch | Export-CSV $BatchPath -NoTypeInformation   
      If ($CreateMigrationBatch -eq $true) {  
           New-MigrationBatch -Name $BatchFileName -CSVData ([System.IO.File]::ReadAllBytes(“$BatchPath”)) –Local –TargetDatabase $DestinationDB -AllowUnknownColumnsInCsv $true  
      }  
 }  
   

Friday, August 12, 2016

Update Mount-ADDatabase for PowerShell v2

I'm working on some Active Directory (AD) disaster recovery projects right now and one of the recovery methods we're implementing is AD snapshots. With AD snapshots, you have a copy of your AD data to identify and recover from accidental changes.

The client I'm working with has Windows 2008 R2 with PowerShell 2.0 for their domain controllers. PowerShell is my preferred method for automating anything at this point but AD snapshots don't have any PowerShell cmdlets.

Fortunately Ashley McGlone, a Microsoft PFE, has created some PowerShell functions that help you manage and use AD snapshots. One of the coolest things in there is a function (Repair-ADAttribute) that lets you pull attributes from the snapshot and apply them to the same object in the production AD. You can read more about these functions and download them from these two locations:
The minor issue I ran into is with the Mount-ADDatabase function. This function has a -Filter parameter which displays a list AD snapshots and lets you choose which one to mount. In Ashley's function this is done by using Out-GridView with the -OutputMode parameter which requires PowerShell v3. Using Out-GridView is an easy way to allow the user to select the snapshot. I wish it worked for my servers using PowerShell v2. Here is the line from the function:

 $Choice = $snaps | Select-String -SimpleMatch '/' |  
       Select-Object -ExpandProperty Line |  
       Out-GridView -Title 'Select the snapshot to mount' -OutputMode Single  

For my project, getting all of the DCs upgraded to using PowerShell v3 would take a while. I also didn't want to leave the project in a place where a whole bunch of manual steps were required to mount an AD snapshot older than the previous day. So, let's convert this to a method that works in PowerShell v2.

Now I needed a way to convert a list of snapshots in to a menu. My starting point was a TechNet discussion posting from Grant Ward (Bigteddy). You can view his solution for a discussion here:
Using that example I created the following code:

 $choices = $snaps | Select-String -SimpleMatch '/' | Select-Object -ExpandProperty Line  
 $menu = @{}  
 $i=0  
 foreach ($s in $choices) {  
      $i++  
      Write-Host "$i --> $s"  
      $menu.Add($i,$s)  
 }  
   
 [int]$ans = Read-Host 'Enter selection'  
 $Choice = $menu.Item($ans)  

This code takes the list of snapshots in the variable $snaps and does two things:
  • Writes a menu to the screen
  • Add each menu item to the array $menu
After the menu is displayed on the screen and the user selects an option (the $ans variable), the option is used to place the snapshot name into the $Choice variable for further processing. Now we have a version that works in PowerShell v2.




Monday, August 8, 2016

Finding the User or Group Name from a SID

I'm working on project where we needed to set AD security permissions in a test environment based on the permission based on production. When I generated a report of AD permissions that had been applied, several of the entries came back with SID numbers instead of user or group names. Typically this means that the user or group has been deleted, but I wanted to confirm.

I wanted to take the SID and identify the user or group account that was associated with it. After a quick search I found a few examples that looked similar to this:

 $objSID = New-Object System.Security.Principal.SecurityIdentifier("S-1-5-21-1454471165-1004335555-1606985555-5555")  
 $objUser = $objSID.Translate([System.Security.Principal.NTAccount])  
 $objUser.Value  


Above example taken from: https://technet.microsoft.com/en-us/library/ff730940.aspx

It seemed to me that there had to be an easier way using the ActiveDirectory module for PowerShell which isn't used by these examples. Good news, there is!

You can't use Get-ADUser or Get-ADGroup to identify the SID name because it could be either one. However, you can use Get-ADObject:

 Get-ADObject -Filter {objectSID -eq "S-1-5-21-1454471165-1004335555-1606985555-5555"}  

If the command does not return any results then there is no AD object with that SID.

Friday, July 29, 2016

Finding PCs Infected with WORM_ZLADER.B

A virus has recently been making the rounds that propagates by renaming folders and storing an executable file in a Recycle Bin folder. Just today we saw this on some shared folders.

Here is a link with more info about the virus:

So, we can identify that someone is infected by finding the $Recycle.Bin folders in the shares. But we need to track down where this came from. To do that we want to see the owner of the $Recycle.Bin folder because that is the person that created it.

You can't get the owner of the $Recycle.Bin folder by using Windows Explorer because Explorer treats this as a special folder type and limits what you can see. However, you can find the owner by using Get-ACL in PowerShell.

On a large file server with many shares, it's useful to scan the whole server to verify the extent of the infection. The script below starts in the current directory, finds all of the Recycle.Bin folders, displays the full folder path and the owner of each folder.

 $Folders=Get-ChildItem -Force -Recurse -ErrorAction SilentlyContinue | Where {$_.psiscontainer -eq $true -and $_.Name -like '$Recycle.B*' }  
 Foreach ($f in $Folders) {  
   $owner=($f | get-acl).owner  
   $Name=$f.Fullname   
   Write-Host "Folder: $Name"  
   Write-Host "Owner: $owner"  
   Write-Host ""  
 }  

Some others have written scripts to rename folders back to their original names which is useful if many folders were infected. See these links for more information:

Tuesday, May 31, 2016

Error Using PowerShell Direct

If you've not heard of it, PowerShell Direct is a nifty new way that you can connect with virtual machines on a Windows 10 or Windows Server 2016 Hyper-V host. PowerShell Direct lets you enter PowerShell remoting sessions or invoke commands on a VM from the Hyper-V without requiring network connectivity.

To enter a PowerShell remoting session:
Enter-PSSession -VMName NameOfVM
I've played with this a bit before and it's pretty cool. I've never had any issues with it. However, today when working with some VMs provided for a course I'm working on I got longer version of this error when trying to connect:
Enter-PSSession : An error has occurred which Windows PowerShell cannot handle. A remote session might have ended.
+ FullyQualifiedErrorId :  CreateRemoteRunspaceForVMFailed,Microsoft.PowerShell.Commands.EnterPSSessionCommand
This same error message is provided if your authentication credentials fail. So, I reset the local administrator password on the VM, but no fix.

This was a generation 1 VM given to me. So, my first throught is that maybe its the generation of VM. Nope. I tested with other generation 1 and generation 2 VMs and all worked fine.

What it finally ended up being was the version of VM. The VM was built on a Windows Server 2012 R2 host. So, it was version 5 rather than version 7.1 for the VMs that I had created.

To view the version:
Get-VM | FT Name,Version
To update the version:
Update-VMVersion NameOfVM
After updating the VM to from version 5 to version 7.1 PowerShell Direct worked just like it is supposed to. This is worth noting because none of the articles about PowerShell Direct mention the version as a required.

Friday, November 27, 2015

The Total Data Received From the Remote Client Exceeded Allowed Maximum

Working with a large number of mailboxes is usually about the same a working with a small number of mailboxes, except that you need to include -ResultSize Unlimited in your Get-* cmdlets. However, I recently ran into the following error when getting a large list of mailboxes (approx 38K) with Get-Mailbox and piping them to Set-Mailbox:
Sending data to a remote command failed with the following error message: The total data received from the remote client exceeded allowed maximum. Allowed maximum is 524288000.
In the end this seemed to be a limitation of piping from Get-Mailbox to Set-Mailbox. For example, this would generate the error:
Get-Mailbox -ResultSize Unlimited | Set-Mailbox -SingleItemRecoveryEnabled $true
One option to work around this is to chunk up the work and do it by database instead:
Get-Mailbox -Database XXX -ResultSize Unlimited | Set-Mailbox -SingleItemRecoveryEnabled $true
However, my preferred workaround was this:
$mbx=Get-Mailbox -ResultSize Unlimited
foreach($m in $mbx) {Set-Mailbox $m -SingleItemRecoveryEnabled $true}
Using a foreach loop instead of piping seems to avoid the error when dealing with large data sets. The powershell.exe process for this task consumed about 2.5GB of memory.

Wednesday, October 14, 2015

Getting Status Totals in PowerShell

I was doing an Exchange migration on the weekend and had a large number of move requests. Being the slightly OCD computer person as most of us are, I wanted to see how things were progressing occasionally. However, I didn't want to be dumping stuff into spreadsheets and be counting items.

After few false starts, I ran across the Group-Object cmdlet. For me, this cmdlet is in the same category as Measure-Object. I've never had a need in the past. Now I'm happy to have it.

My solution:
Get-MoveRequest | Group-Object -Property Status

The results looked like this:

Sunday, October 4, 2015

Synchronizing Remote IP Ranges Across Recieve Connectors

Exchange 2010 and later do a nice job of providing high availability with database availability groups (DAGs) and load balancing. However, one configuration detail doesn't automatically synchronize between multiple Exchange servers, and that is receive connectors.

If you create receive connectors for relaying output from printers or scanners then the connector you create is unique on each server. That's fine if you are pointing the devices at individual Exchange servers but to have high availability, you need multiple load balanced servers with the same configuration. To do this, you need to create the same receive connectors on each server.

During intial setup creating 2 or 4 receive connectors with the same settings for authentication and such isn't too big a deal. The item that's a pain is the remote IP ranges that are allowed to use the receive connector. Many organizations have a large list of individual IP addresses that are allowed to use the receive connector.

If there is a list of 50 individual IP addresses, it's a long process to enter once, let alone multiple times. Not to mention, there is the risk of administrators adding or removing IP addresses to the list on one server, but not others, or typos.

Here is a quick and easy way to synchronize the remote IP ranges from a receive connector on one server to another.
$Source=Get-RecieveConnector Server\ConnectorName
$Destination=Get-ReceiveConnector Server\ConnectorName
Set-ReceiveConnector $Destination -RemoteIPRanges $Source.RemoteIPRanges
In the example above:
  • $Source is the connector you are copying the remote IP ranges from
  • $Destination is the connector your are copying the remote IP ranges to
It is not necessary to use a variable for the destination receive connector. I did that to clarify what is happening in the Set-ReceiveConnector command. You could put the identity of the destination receive connector directly in the Set-ReceiveConnector command.

If you need to do this on a regular basis, you can put the receive connector into an array and use a foreach loop to set them all in a script like this:
$Source=Get-RecieveConnector Server\ConnectorName
$Destination=Get-ReceiveConnector | Where { $_.name -like "Relay*"}
ForEach ($d in $Destination) {
    Set-ReceiveConnector $d -RemoteIPRanges $Source.RemoteIPRanges
}

Tuesday, July 7, 2015

Script for Exchange 2013 Message Tracking

Exchange Server 2010 had a graphical utility for analyzing message tracking logs. Unfortunately, this tool was removed from Exchange Server 2013. Instead in Exchange Server 2013, you have only the Get-MessageTrackingLog cmdlet.

The Get-MessageTrackingLog cmdlet is a pain in the butt for a few reasons:
  • You need to memorize the syntax. Most of it is pretty straight forward, but you need to remember the correct parameters for searching by sender, recipient, or subject.
  • It only searches the local server by default. Without specifying servers, it only searches the local Exchange server that you're running the tool on. In a lot of cases, you need to see information from all your servers to track it down.
While working on a message delivery problem this week, I wrote up a short script help with simple message tracking based on time, sender, recipient, or message subject. The script is as follows:
 Write-Host "Current Date/Time: $(Get-Date)"  
 $StartTime = Read-Host "Start time for search"  
 $EndTime = Read-Host "End time for search"  
 $SearchType = Read-Host "Search for (S)ender, (R)ecipient, (M)essage subject, or display (A)ll"  
 Switch ($SearchType) {  
   'S' {  
            $Sender = Read-Host "Sender"  
            Get-ExchangeServer | where {$_.isHubTransportServer -eq $true -or $_.isMailboxServer -eq $true} | Get-MessageTrackingLog -Start $StartTime -End $EndTime -Sender $Sender |Select-Object Timestamp,ServerHostname,ClientHostname,Source,EventId,Sender,Recipients,MessageSubject | Out-GridView }  
   'R' {  
            $Recipient = Read-Host "Recipient"  
            Get-ExchangeServer | where {$_.isHubTransportServer -eq $true -or $_.isMailboxServer -eq $true} | Get-MessageTrackingLog -Start $StartTime -End $EndTime -Recipient $Recipient |Select-Object Timestamp,ServerHostname,ClientHostname,Source,EventId,Sender,Recipients,MessageSubject | Out-GridView }  
   'M' {  
            $MessageSubject = Read-Host "Message subject (performs partial matches)"  
            Get-ExchangeServer | where {$_.isHubTransportServer -eq $true -or $_.isMailboxServer -eq $true} | Get-MessageTrackingLog -Start $StartTime -End $EndTime -MessageSubject $MessageSubject |Select-Object Timestamp,ServerHostname,ClientHostname,Source,EventId,Sender,Recipients,MessageSubject | Out-GridView }  
   'A' {  
            Get-ExchangeServer | where {$_.isHubTransportServer -eq $true -or $_.isMailboxServer -eq $true} | Get-MessageTrackingLog -Start $StartTime -End $EndTime |Select-Object Timestamp,ServerHostname,ClientHostname,Source,EventId,Sender,Recipients,MessageSubject | Out-GridView }  
   default {Write-Host "Invalid Option - Run Script Again"}  
 }  
Here is how the script works:
  1. The current date/time are displayed. This shows you the date/time syntax to use for entering time in the next steps.
  2. You are prompted for the time to start searching the logs.
  3. You are prompted for the time to stop searching the logs.
  4. You are prompted for the type of search you want to do: sender, recipient, message subject, or display all.
  5. The switch command uses the $SearchType variable to run a specific code block. The command varies depending on the option, but in general, it prompts for the required information and then runs the query based on it.
  6. Results are displayed by using Out-Gridview. This allows you to sort based on columns.

Notes:

  • This code is used to identify and generate a list of all Exchange servers with message tracking logs which is then piped to the Get-MessageTrackingLog cmdlet.
    Get-ExchangeServer | where {$_.isHubTransportServer -eq $true -or $_.isMailboxServer -eq $true}  
  • When you search by message subject, it returns all results that include the snippet of text. This can make it hard to track down the specific message that you're looking for sometimes. For example, searching for "text" will include messages with "text" in the subject, but also "context","textbook", etc.
  • Only 1000 results are returned by the Get-MessageTrackingLog cmdlet. It's possible to override this, but if your query is returning more than 1000 results, you should probably be refining your query.
  • Times from Exchange 2007 servers seem off. I was testing in a Mixed 2013 and 2007 environment and the content coming back from the 2007 environment had timestamps outside the range I queried. I have not yet had time to investigate, but be aware of this when sorting results based on time.