Powershell : New user, New mailbox, Migrate to EXO

💀 Careful, this is a script that will create data in ADDS and Exchange and then move data to EXO, please ensure you understand what you are doing before you just execute a script from a public source.

I had another challenge that I thought I would try to fix with PowerShell, This particular mission was quite an interesting one that consisted of the following steps, which related to user account creation then a move to EXO, and this is how the plan was outlined in my head...

Plan : Steps of Script mapped out.

  1. Create a new user in Active Directory Based off CSV file that only contained the uses first name and surname and manager and OU they need to be in
  2. From the First and surname automatically generate the correct UPN, This would also form the bases for their email address
  3. Create a samAccount That followed internal naming conventions
  4. Ensure the name shown in Active Directory was the samAccount name 
  5. Create a valid and secure password of more than 20 characters, This would utilize uppercase and lowercase and numbers and special symbols
  6. Set the users manager, based on the value in the CSV file
  7. Create a local exchange mailbox for that user that match the UPN - this would need to be done after connecting your local Exchange environment
  8. Ensure that connection did not require a prompt dialogue, and at the storing of the account was secure to one server and an XML file
  9. Once the local exchange account was created output that to a Text file
  10. Factor in a 50 minute wait for that account to synchronize to Entra - The lowest synchronization time is 30 minutes then you would add an extra 20 for stabilization
  11. Ensure the 50 minute timer, would not let you continue without a special authorization code (This would only be used when you manually synchronized it and confirmed it was valid before you continued)
  12. Take the text file and format it into a correctly format XML file for the migration to EXO
  13. Remove the original text file as it’s no longer required once we have the XML file
  14. Start a migration to EXO where we are once again using the XML file for our local credentials to start the move

This would mean the goal would be to create a user have a local mailbox created, and then automatically synchronize or in this case move that mailbox to EXO - all this needs to be done with one single command that the user can run and everything else happens behind the scenes

Working through the issues, as they occur

Script "run" location

You need to run the script from the root folder as the paths in the script are "filename.extension" without the path, I like to work out of a working directory, as all the files are called by their filename not relative paths.

New-MoveRequest issue and wrong command.

That sounds pretty simple, but I came across some interesting issues that I thought I would explain here, The first being as I was creating accounts and mailboxes, I thought it would be a great idea to run this from the exchange management to remotely from one of my administration servers - this worked well until I tried to create the mailbox and I got the error:


I then if you logically step though the process I realised I was asking Exchange local to move the mailbox to EXO, but that is not how the process works, you need to connect to EXO and pull the mailbox with a remote move using the endpoint name, however this was also the wrong command to be using, I need the MigrationBatch not the MoveRequest command, this was fixed with the below:

$Credentials = Import-Clixml -Path "Credentials.xml"

$MigrationEndpointOnPrem = Get-MigrationEndpoint -Identity "<endpoint_identity>"

$OnboardingBatch = New-MigrationBatch -Name Onboard-EXO -SourceEndpoint

$MigrationEndpointOnprem.Identity -TargetDeliveryDomain <tenant>.mail.onmicrosoft.com -CSVData ([System.IO.File]::ReadAllBytes("migrate.csv")) -AutoComplete -AutoStart

Start-MigrationBatch -Identity $OnboardingBatch.Identity.Name

PowerShell Session Exhaustion

Next stop, during my testing, I ran out of powershell sessions because I was not disconnecting my previous sessions - rookie mistake, now I’ve now added the disconnect right after the command has finished doing its job, after a slight pause with the sleep command to make sure it’s finished.

Command integrated :  Disconnect-ExchangeOnline

Proxy required for internet actions

The next problem I had was we require a proxy server to be able to talk to the Internet, So that had to be added to the variables at the top of the script I created, If you have direct Internet access to the Internet, then you won’t require a step.

$proxy = 'proxy.bears.local:8080'
[system.net.webrequest]::defaultwebproxy = new-object system.net.webproxy($proxy)
[system.net.webrequest]::defaultwebproxy.BypassProxyOnLocal = $true

Lack of TLSv1.2

The next problem I had was not defining TLSv1.2 In my script, so it had trouble connecting with without a proxy to EXO - this needed to be fixed by specifying to use TLSv1.2 

[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::
Tls12

ADDS replication "sleep" delay"

I had quite a few issues when trying to create the mailbox directly after creating the account that exchange didn’t actually know the account was there, So I had to build a slight sleep for 10 seconds to make sure the account actually appeared in ADDS before exchange try to activate it.

Write-Host "Pending ADDS replication... (10 seconds)"
Start-Sleep -Seconds 10

Migration Batch Name Randomisation

You need to ensure when you get to the name of the batch migration it cannot be called "EXOMigrate" more than once you will get the error from your script saying:

Write-ErrorMessage : |Microsoft.Exchange.Data.Storage.Management.MigrationPermanentException|The migration batch already exists.

This means I now need to create a unique name for every migration job with the help of this:

$RandomHex = -join ((48..57) + (97..102) | Get-Random -Count 8 | % { [char]$_ })
$UniqueName = "EXO-$RandomHex"

Then when you name the job, use that variable in the script for the uniqueness: 

New-MigrationBatch -Name $UniqueName 

The Script - Create-ADUsers-AutoLoginID (Base Script)

This does the majority of the work this does the user account creation, the password, the groups, the local mailbox then it calls 2x external scripts with status checking and does the migration all from a CSV file, the bits you can customise are in bold in the script.

$proxy = 'proxy.bear.local:3129'
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
[system.net.webrequest]::defaultwebproxy = new-object system.net.webproxy($proxy)
[system.net.webrequest]::defaultwebproxy.BypassProxyOnLocal = $true

#Disconnect current EXO session
Disconnect-ExchangeOnline -Confirm:$false

# Import the Active Directory module
Import-Module ActiveDirectory

# Read user information from a CSV file
$users = Import-Csv -Path "users.csv"

# Define the list of groups
$groups = @("Users", "User-Password-Policy", "Remote-Users", "Protected Users")

# Create an array to store UPNs
$upns = @()

# Function to find the next available username with a unique number
function Get-NextUniqueUsername {

  param (
    [string]$baseUsername
  )

  $counter = 1
  $available = $false

  while (-not $available) {
    $proposedUsername = "$baseUsername$counter"
    if (-not (Get-ADUser -Filter {SamAccountName -eq $proposedUsername})) {
      $available = $true
      return $proposedUsername
    }
    $counter++
  }
}

# Loop through each user in the CSV
foreach ($user in $users) {

  $firstName = $user.FirstName
  $lastName = $user.LastName
  $managerName = $user.Manager
  $ou = $user.OU

# Generate a password with a word and 2 special characters repeated three times

$password = -join ((48..57) + (65..90) + (97..122) | Get-Random -Count 25 | ForEach-Object {[char]$_})

# Create a base username using the first letter of the first name and the first 5 letters of the last name

$baseUsername = ($firstName.Substring(0, 1) + $lastName.Substring(0, [Math]::Min(5, $lastName.Length))).ToLower()

# Find the next available username by appending a unique number
 $username = Get-NextUniqueUsername -baseUsername $baseUsername

# Set the User Principal Name (UPN)
$upn = "$firstName.$lastName@bythepowerofgreyskull.com"
  $upns += $upn # Add UPN to the array as a string

# Set the Common Name (CN) to the username
  $cn = $username

# Wait for Active Directory replication
  Write-Host "Creating ADDS User..."
  Start-Sleep -Seconds 1

# Create the user
New-ADUser -Name $cn -GivenName $firstName -Surname $lastName -DisplayName "$firstName $lastName" -SamAccountName $username -UserPrincipalName $upn -AccountPassword (ConvertTo-SecureString -AsPlainText $password -Force) -Enabled $true -Path $ou -Manager $managerName

# Wait for Active Directory replication
  Write-Host "Adding ADDS User to Groups..."
  Start-Sleep -Seconds 1

# Add the user to specified groups
  foreach ($group in $groups) {
    Add-ADGroupMember -Identity $group -Members $username
  }

 # Wait for Active Directory replication
Write-Host "Pending ADDS replication... (10 seconds)"
Start-Sleep -Seconds 10

# Enable mailbox on Exchange for the user
$UserCredential = Import-Clixml -Path "Credentials.xml"
$Session = New-PSSession -ConfigurationName Microsoft.Exchange -ConnectionUri http://exch-roadrunner.bear.local/PowerShell/ -Authentication Kerberos -Credential $UserCredential
Import-PSSession $Session -DisableNameChecking
Enable-Mailbox -Identity $username -Alias $username -Database "Coyote1a"
Remove-PSSession $Session

# Output the user details and password
  Write-Host "User created: $firstName $lastName"
  Write-Host "Username: $username"
  Write-Host "Password: $password `n"
}

# Export UPNs to a CSV file
$upns | ForEach-Object { $_ | Out-File -FilePath "migrate.txt" -Append}

# Section for waiting for Entra sync replication for 50 minutes with countdown timer and override option
$startTime = Get-Date
$endTime = $startTime.AddMinutes(50)
$overrideCode = "synced"
Write-Host "Waiting for Entra sync replication..."

while ((Get-Date) -lt $endTime) {
    $timeLeft = New-TimeSpan -Start (Get-Date) -End $endTime
    Write-Host "Time remaining: $($timeLeft.ToString('mm\:ss'))"
    $input = Read-Host "Enter override code or press Enter to continue waiting"
    if ($input -eq $overrideCode) {
        Write-Host "Manual Overide code approved by user - EXO Migrate starting....."
        break
    }
    Start-Sleep -Seconds 2
}

if ((Get-Date) -ge $endTime) {
    Write-Host "Entra sync timer expired, EXO migrate starting......"
}

# Wait for Active Directory replication

Write-Host "Checking and Converting File......."
Start-Sleep -Seconds 2

# Call the script to make the CSV file

& ".\CreateCSV.ps1"

# Check the return value of the CSV file script
if ($LASTEXITCODE -eq 0) {
    Write-Host "CSV file script executed successfully."
} else {

    Write-Host "CSV file script encountered an error."
}

# Call the script to make the Migrate to EXO
& ".\EXOMigrate.ps1"

# Check the return value of the CSV file script

if ($LASTEXITCODE -eq 0) {
    Write-Host "Migrate to EXO script executed successfully."
} else {
    Write-Host "Migrate to EXO script encountered an error."
}

Write-Host "Saving reports of valid Batch Names, once moment please...."
Start-Sleep -Seconds 2
Get-MigrationBatch | Where-Object { $_.Identity -like "EXO*" } > exo-batch.txt
Write-Host "Saved report to the current folder named exo-batch.txt"
Write-Host "Disconnection from Exchnage Online (10 seconds wait)"
Start-Sleep -Seconds 10
Disconnect-ExchangeOnline -Confirm:$false

The Script - CreateCSV

This will take the migrate.txt file and covert it to a migrate.csv and add the require header for the EXO migration called "Emailaddress" - then it will delete the migrate.txt file as it is no longer required, we then also need to return a status "0" so the master script knows its done it job and can then report back as completed.

# Define the path to your original text file
$originalTextFilePath = "migrate.txt"

# Define the path to your desired CSV file
$csvFilePath = "migrate.csv"

# Add the header "Emailaddress" to the first line of the text file
$header = "Emailaddress`r`n"
$content = Get-Content $originalTextFilePath

# Add a new line after each entry in the content
$contentWithNewLines = $content -join "`r`n"

Set-Content -Path $csvFilePath -Value ($header + $contentWithNewLines)

Write-Host "CSV file created at: $csvFilePath"
Start-Sleep -Seconds 2
Remove-Item migrate.txt

# Return 0 to indicate success
exit 0

The Script - CredenitalXMLMaker

This is the script that makes your XML file which you can use instead of the credentials in a nasty "basic" dialogue box, this will remove this requirement for a very script unfriendly box:


This is the script:

# Create credential object
$Credential = Get-Credential

# Save credential object to file
$Credential | Export-Clixml -Path "Credentials.xml"

When you run this and enter your credentials and then they will be saved in the file specified, obviously keep this file secure, but the details are not stored in clear text using this method, they are encrypted, as you can see below from a sample account called fakeaccount (which is not real!!!)

<Objs Version="1.1.0.1" xmlns="http://schemas.microsoft.com/powershell/2004/04">
  <Obj RefId="0">
    <TN RefId="0">
      <T>System.Management.Automation.PSCredential</T>
      <T>System.Object</T>
    </TN>
    <ToString>System.Management.Automation.PSCredential</ToString>
    <Props>
      <S N="UserName">bear.local\fakeaccount</S>
      <SS N="Password">01000000d08c9ddf0115d1118c7a00c04fc297eb01000000df454100dc187d409226cc24f20f36680000000002000000000003660000c0000000100000004bc7dd51032ae28efeba3c0576926fa10
000000004800000a000000010000000d0d2d9161e9d2a7cf814accb0efafe75380000001ef9dffd1d8a58a3d6cd6fee7984ad02e27b810a8dfada6cc6323fcb43879bc862a24e7adb1cf3fe1e1d4705b74513e00e856976759338fc14000000799ee6ff7499034392ef341d5e0ee9de64a4809a</SS>
    </Props>
  </Obj>
</Objs>

Powershell - EXOMigrate

This is the manual step to perform the migration from the CSV file that the script created earlier, this also needs to create a value that is unique for the migration name, so where we use EXO-hhhhhhhh (the h stands for hex letters)  as it will create the job for example EXO-2a3d6d5 - this will keep them unique, it will also output a text file with the name of the migration in the script runtime folder

Note : This process requires the absolute path to work, you cannot specify a filename here
Note : We need to end this with exit code "0" so the script knows there are no errors

Write-Host "Connecting to Exchange Online via MFA...."
Connect-ExchangeOnline

$Credentials = Import-Clixml -Path "Credentials.xml"

$MigrationEndpointOnPrem = Get-MigrationEndpoint -Identity "<endpoint_identity>"

$RandomHex = -join ((48..57) + (97..102) | Get-Random -Count 8 | % { [char]$_ })
$UniqueName = "EXO-$RandomHex"

$OnboardingBatch = New-MigrationBatch -Name $UniqueName -SourceEndpoint
$MigrationEndpointOnPrem.Identity -TargetDeliveryDomain <tenant>.mail.onmicrosoft.com -CSVData ([System.IO.File]::ReadAllBytes("c:\EXOMigrate\migrate.csv")) -AutoComplete

Start-MigrationBatch -Identity $OnboardingBatch.Identity.Name

# Return 0 to indicate success
exit 0

Powershell - exo-batch.txt

This will track the name of the batch numbers you have generated for the migrations this will be save to a text file, which will look like this, here you can see they have completed - yayy!

Identity     Status    Type               TotalCount
--------     ------    ----               ----------
EXO-6e10af92 Completed ExchangeRemoteMove 1         
EXO-9d24387a Completed ExchangeRemoteMove 1         

By the power of greyskull mission accomplished.

Previous Post Next Post

نموذج الاتصال