Monitoring with PowerShell: App hangs

I was talking to a friend the other day and he was using my user experience script in his RMM system for a while. He told me that he loved having the ability to measure the users experience but he had some clients with in-house applications that would write errors to the system log constantly, or he had other clients with crashing services that could not be prevented.

This caused him to disable the User Experience Monitoring script for those clients, which is a shame because it’s what we should be focusing on as MSPs these days. I figured I’d make a lighter version that does not rely on the Windows Reliability Index instead. That way we could avoid some of the crashing services or other issues. So lets get to the script!

The Script

Instead of grabbing the Reliability index – We’re collecting all logs for the last 15 minutes and counting how many AppHangs have been experienced. An AppHang is when the application gives the famous “Not responding” pop-up. We also grab hard application crashes, but filter our those that we don’t want to see such as the LOB application spoken about above.

$IDs = "1002", "1000"
$ExcludedApplications = "*Slack*"
$MaxCount = '1'

$LogFilter = @{
    LogName = 'Application'
    ID      = $IDs
    StartTime = (get-date).AddMinutes(-15)
} 

$Last15Minutes = Get-WinEvent -FilterHashTable $LogFilter -ErrorAction SilentlyContinue | where-object { $_.message -notlike $ExcludedApplications }


if ($Last15Minutes.count -ge $MaxCount) {
    write-host "Unhealthy - The maximum application crash logs are higher than $MaxCount"
}

if (!$Last15Minutes) {
    write-host "Healthy - No app crash logs found"
}

Now this is just an example on how you can achieve this really – If anything I’d suggest to expand on this and get some information to compile your own reliability score. I also don’t specifically love getting Windows Events instead of directly monitoring yourself, but Apphangs aren’t really documented elsewhere.

And that’s it! as always, Happy PowerShelling 🙂

Documenting with PowerShell: Documenting Print Servers

Before I start on this; I agree. Printers are the bane of our existence in IT and I am hoping for a paperless environment each day. Unfortunately we’re not at that future just yet, so we have to document print servers and their settings.

So today I’m going to show you how to document your print servers, gather their drivers so you can do future roll backs if required, and get their settings too. To do this we’re using the native printer cmdlets included in Windows Server, and get a little help from the print server management command line utilities.

We’re not using PRINTBRM completely, because we can’t make a per-printer backup with it. It would just be a single huge package and IT-Glue does not support attachments over 100MB. Instead we’re using get-PrinterDriver and we’re backing up all the files we need to restore functionality. As always I’ve made two version: one for IT-Glue and one for generic HTML documentation systems.

IT-Glue version

The IT-Glue version creates the flexible asset for you, documents the settings, and uploads a backup of the drivers. Remember to change the ORGID variable to the one you want to document.

#####################################################################
$APIKEy = "YourITGAPIKey"
$APIEndpoint = "https://api.eu.itglue.com"
$orgID = "ORGID"
$FlexAssetName = "ITGLue AutoDoc - Printers"
$Description = "All configuration settings for printers and a backup of their respective drivers"
$InstallPrintManagement = $true
$BackupDriver = $true
#####################################################################
If (Get-Module -ListAvailable -Name "ITGlueAPI") { Import-module ITGlueAPI } Else { install-module ITGlueAPI -Force; import-module ITGlueAPI }
Add-ITGlueBaseURI -base_uri $APIEndpoint
Add-ITGlueAPIKey $APIKEy
#Checking if the FlexibleAsset exists. If not, create a new one.
$FilterID = (Get-ITGlueFlexibleAssetTypes -filter_name $FlexAssetName).data
if (!$FilterID) { 
    $NewFlexAssetData = 
    @{
        type          = 'flexible-asset-types'
        attributes    = @{
            name        = $FlexAssetName
            icon        = 'sitemap'
            description = $description
        }
        relationships = @{
            "flexible-asset-fields" = @{
                data = @(
                    @{
                        type       = "flexible_asset_fields"
                        attributes = @{
                            order           = 1
                            name            = "Printer Name"
                            kind            = "Text"
                            required        = $true
                            "show-in-list"  = $true
                            "use-for-title" = $true
                        }
                    },
                    @{
                        type       = "flexible_asset_fields"
                        attributes = @{
                            order          = 2
                            name           = "Printer Config"
                            kind           = "Text"
                            required       = $false
                            "show-in-list" = $true
                        }
                    },
                    @{
                        type       = "flexible_asset_fields"
                        attributes = @{
                            order          = 3
                            name           = "Port Config"
                            kind           = "Textbox"
                            required       = $false
                            "show-in-list" = $false
                        }
                    },
                    @{
                        type       = "flexible_asset_fields"
                        attributes = @{
                            order          = 4
                            name           = "Printer Properties"
                            kind           = "Textbox"
                            required       = $false
                            "show-in-list" = $false
                        }
                    },
                    @{
                        type       = "flexible_asset_fields"
                        attributes = @{
                            order          = 5
                            name           = "Driver backup"
                            kind           = "Upload"
                            required       = $false
                            "show-in-list" = $false
                        }
                    }
                )
            }
        }
                
    }
    New-ITGlueFlexibleAssetTypes -Data $NewFlexAssetData 
    $FilterID = (Get-ITGlueFlexibleAssetTypes -filter_name $FlexAssetName).data
} 

$PrintManagementInstalled = get-windowsfeature -name 'RSAT-Print-Services' -ErrorAction SilentlyContinue
if ($InstallPrintManagement -and !$PrintManagementInstalled) {
    Add-WindowsFeature -Name 'RSAT-Print-Services'
}
import-module PrintManagement
$PrinterList = Get-Printer

$PrinterConfigurations = foreach ($Printer in $PrinterList) {
    $PrinterConfig = Get-PrintConfiguration -PrinterName $printer.Name
    $PortConfig = get-printerport -Name $printer.PortName
    $PrinterProperties = Get-PrinterProperty -PrinterName $printer.Name
    if ($BackupDriver) {
        $Driver = Get-PrinterDriver -Name $printer.DriverName | ForEach-Object { $_.InfPath; $_.ConfigFile; $_.DataFile; $_.DependentFiles } | Where-Object { $_ -ne $null }
        $BackupPath = new-item -path "$($ENV:TEMP)\$($printer.name)" -ItemType directory -Force 
        $Driver | foreach-object { copy-item -path $_ -Destination "$($ENV:TEMP)\$($printer.name)" -Force }
        Add-Type -assembly "system.io.compression.filesystem"
        [io.compression.zipfile]::CreateFromDirectory("$($ENV:TEMP)\$($printer.name)", "$($ENV:TEMP)\$($printer.name).zip")
        $ZippedDriver = ([convert]::ToBase64String(([IO.File]::ReadAllBytes("$($ENV:TEMP)\$($printer.name).zip"))))
        remove-item "$($ENV:TEMP)\$($printer.name)" -force -Recurse
        remove-item "$($ENV:TEMP)\$($printer.name).zip" -force -Recurse
    }
    [PSCustomObject]@{
        PrinterName       = $printer.name
        PrinterConfig     = $PrinterConfig | select-object DuplexingMode, PapierSize, Collate, Color | convertto-html -Fragment | Out-String
        PortConfig        = $PortConfig  | Select-Object Description, Name, PortNumber, PrinterHostAddress, snmpcommunity, snmpenabled | convertto-html -Fragment | out-string
        PrinterProperties = $PrinterProperties | Select-Object PropertyName, Value | convertto-html -Fragment | out-string
        ZippedDriver      = $ZippedDriver
    }
}

foreach ($printerconf in $PrinterConfigurations) {
    $FlexAssetBody = 
    @{
        type       = 'flexible-assets'
        attributes = @{
            name   = $FlexAssetName
            traits = @{
                "printer-name"       = $printerconf.Printername
                "printer-config"     = $printerconf.printerconfig
                "port-config"        = $printerconf.portconfig
                "printer-properties" = $printerconfig.properties
                "driver-backup"      = @{
                    "content"   = $printerconf.ZippedDriver
                    "file_name" = "Driver Backup.zip"
                }
            }
        }
    }
    

    #Upload data to IT-Glue. We try to match the Server name to current computer name.
    $ExistingFlexAsset = (Get-ITGlueFlexibleAssets -filter_flexible_asset_type_id $Filterid.id -filter_organization_id $orgID).data | Where-Object { $_.attributes.traits.name -eq $printerconf.PrinterName }
    #If the Asset does not exist, we edit the body to be in the form of a new asset, if not, we just upload.
    if (!$ExistingFlexAsset) {
        $FlexAssetBody.attributes.add('organization-id', $orgID)
        $FlexAssetBody.attributes.add('flexible-asset-type-id', $FilterID.id)
        Write-Host "Creating new flexible asset"
        $NewID = New-ITGlueFlexibleAssets -data $FlexAssetBody
        Set-ITGlueFlexibleAssets -id $newID.ID -data $Attachment
    }
    else {
        Write-Host "Updating Flexible Asset"
        $ExistingFlexAsset = $ExistingFlexAsset | select-object -last 1
        Set-ITGlueFlexibleAssets -id $ExistingFlexAsset.id  -data $FlexAssetBody
    }
}

HTML Version

The HTML version uses PSWriteHTML to create a simple HTML document for you. The backup is stored in C:\Temp instead of in the HTML file.

#####################################################################
$InstallPrintManagement = $true
$BackupDriver = $true
#####################################################################

$PrintManagementInstalled = get-windowsfeature -name 'RSAT-Print-Services' -ErrorAction SilentlyContinue
if ($InstallPrintManagement -and !$PrintManagementInstalled) {
    Add-WindowsFeature -Name 'RSAT-Print-Services'
}
import-module PrintManagement
$PrinterList = Get-Printer

foreach ($Printer in $PrinterList) {
    $PrinterConfig = Get-PrintConfiguration -PrinterName $printer.Name
    $PortConfig = get-printerport -Name $printer.PortName
    $PrinterProperties = Get-PrinterProperty -PrinterName $printer.Name
    if ($BackupDriver) {
        $Driver = Get-PrinterDriver -Name $printer.DriverName | ForEach-Object { $_.InfPath; $_.ConfigFile; $_.DataFile; $_.DependentFiles } | Where-Object { $_ -ne $null }
        $BackupPath = new-item -path "$($ENV:TEMP)\$($printer.name)" -ItemType directory -Force 
        $Driver | foreach-object { copy-item -path $_ -Destination "$($ENV:TEMP)\$($printer.name)" -Force }
        remove-item "$($ENV:TEMP)\$($printer.name)" -force -Recurse
        copy-item "$($ENV:TEMP)\$($printer.name).zip" -Destination "C:\Temp"
        remove-item "$($ENV:TEMP)\$($printer.name).zip" -force -Recurse
    }

    New-HTML {
        New-HTMLTab -Name $printer.name {
            New-HTMLSection -Invisible {
                New-HTMLSection -HeaderText 'Configuration' {
                    New-HTMLTable -DataTable $PrinterConfig
                }
            }
            New-HTMLSection -Invisible {
                New-HTMLSection -HeaderText "Port Config" {
                    New-HTMLTable -DataTable $PortConfig
                }
               
                New-HTMLSection -HeaderText "Printer Properties" { 
                    New-HTMLTable -DataTable $PrinterProperties
                }
            }
        }
                   
    } -FilePath "C:\temp\$($Printer.name) PrinterDocumentation.html" -Online



}

And that’s it! As always, Happy PowerShelling. 🙂

Automating with PowerShell: Checking if you can move to M365 BP from O365 E3

So with M365 BP having nearly all the features that E3 gives you, albeit with some limitations a lot of our clients are moving their E3 licenses over. M365BP has the added benefit of Intune/Autopilot, P1 licenses, etc. So all pretty awesome stuff.

Before you can move a client over, you’ll have to check some small things. We have a fairly lengthy internal script to perform all these checks, but I figured to share a part of this script for the public at large. 😉

The Script

This script is a simplified version of our internal one; It checks the following things for you;

  • If you are currently using more than 300 licenses, because if you are you will have to Mix-and-Match all the overage of the licenses.
  • If there are mailboxes larger than 50GB. If so, these have to be licensed differently or cleaned up first.
  • If your clients connecting are all recent clients. Automatic downgrades of the licenses without reinstallation requires a recent version of the installed O365 application.

There’s some more caveats, but these are the most major ones you can tackle with this script. Licenses are tricky business so customize this script to your wishes if you want to add/remove anything. 🙂

As with most of my scripts you’ll need the secure application model. The resulting files will be written to C:\Temp.

######### Secrets #########
$ApplicationId = 'YourApplicationID'
$ApplicationSecret = 'YourAppicationSecret' | ConvertTo-SecureString -Force -AsPlainText
$TenantID = 'YourTenantID'
$RefreshToken = 'insanelylongtoken'
######### Secrets #########
install-module PSWriteHTML
$credential = New-Object System.Management.Automation.PSCredential($ApplicationId, $ApplicationSecret)

$aadGraphToken = New-PartnerAccessToken -ApplicationId $ApplicationId -Credential $credential -RefreshToken $refreshToken -Scopes 'https://graph.windows.net/.default' -ServicePrincipal -Tenant $tenantID 
$graphToken = New-PartnerAccessToken -ApplicationId $ApplicationId -Credential $credential -RefreshToken $refreshToken -Scopes 'https://graph.microsoft.com/.default' -ServicePrincipal -Tenant $tenantID 

Connect-MsolService -AdGraphAccessToken $aadGraphToken.AccessToken -MsGraphAccessToken $graphToken.AccessToken

$customers = Get-MsolPartnerContract -All

foreach ($customer in $customers) {

  
    $UserLicenses = Get-MsolAccountSku -TenantId $customer.TenantId
    if ($UserLicenses.AccountSkuId -like "*ENTERPRISEPACK*") {
        write-host "$($Customer.DefaultDomainName) has ENTERPRISEPACK"
    }
    else {
        write-host "Skipping $($Customer.DefaultDomainName) as they do not have an ENTERPRISEPACK" -ForegroundColor Yellow 
        continue 
    }
    $LicObject = foreach ($UserLicense in $UserLicenses) {
        if ($UserLicense.ConsumedUnits -gt '300') {
            [PSCustomObject]@{
                Name          = ($UserLicense.AccountSkuID -split ':')
                ConsumedUnits = $UserLicense.ConsumedUnits
                Warning       = "Microsoft 365 BP can only be used for the first 300 users. If this license is contained in a package. Please note to buy a seperate license for the users with overage."
            }
        }
    }
    if (!$LicObject) {
        $LicObject = [PSCustomObject]@{
            Report = 'No licensing conflicts found. License migration can be performed without issues.'
        }
    }

    write-host "Generating token for $($Customer.name)" -ForegroundColor Green
    $graphToken = New-PartnerAccessToken -ApplicationId $ApplicationId -Credential $credential -RefreshToken $refreshToken -Scopes 'https://graph.microsoft.com/.default' -ServicePrincipal -Tenant $customer.TenantID
    $Header = @{
        Authorization = "Bearer $($graphToken.AccessToken)"
    }
    $MailboxUsage = (Invoke-RestMethod -Uri  "https://graph.microsoft.com/beta/reports/getMailboxUsageDetail(period='D7')" -Headers $Header -Method Get -ContentType "application/json") -replace "", "" | ConvertFrom-Csv
    $LargeMailboxes = foreach ($Mailbox in $MailboxUsage) {
        if ([int64]$Mailbox.'Storage Used (Byte)' -gt 45GB) { 
            [PSCustomObject]@{
                Username      = $Mailbox.'User Principal Name'
                DisplayName   = $Mailbox.'DisplayName'
                StorageUsedGB = [math]::round($Mailbox.'Storage Used (Byte)' / 1gb, 2)
            }
        }
    }
    if (!$LargeMailboxes) {
        $LargeMailboxes = [PSCustomObject]@{
            Report = 'No mailbox sizing issues found. Technical mailbox migration can be performed without issues.'
        }
    }

    $VersionReport = (Invoke-RestMethod -Uri "https://graph.microsoft.com/beta/reports/getEmailAppUsageVersionsUserCounts(period='D7')" -Headers $Header -Method get -ContentType "application/json") -replace "", "" | ConvertFrom-Csv
    $LegacyClients = if ($versionreport.'Outlook 2007' -or $versionreport.'Outlook 2010' -or $versionreport.'Outlook 2013' -or $versionreport.'Outlook 2016') {
        $VersionReport
    }
    if (!$LegacyClients) {
        $LegacyClients = [PSCustomObject]@{
            Report = 'No Legacy outlook clients found. License change can be performed without issues.'
        }
    }

    start-sleep -Milliseconds 500  #sleep to prevent CSV Throttle on Graph API

    New-HTML {
        New-HTMLTab -Name "O365 to M365 Preperation Report" {
            New-HTMLSection -Invisible {
                New-HTMLSection -HeaderText 'License Settings' {
                    New-HTMLTable -DataTable $LicObject
                }
            }
            New-HTMLSection -Invisible {
                New-HTMLSection -HeaderText "Mailbox Settings" {
                    New-HTMLTable -DataTable $LargeMailboxes
                }
              
                New-HTMLSection -HeaderText "O365 Client Usage" { 
                    New-HTMLTable -DataTable $LegacyClients
                }
            }
        }
                  
    } -FilePath "C:\temp\$($customer.DefaultDomainName).html" -Online
}

I hope this helps you migrate your clients over, as always, Happy PowerShelling!

Monitoring with PowerShell: Monitoring Domain Admins logon

So this is one I’ve been researching for a new tool I’m creating. AzPAM, AzPAM will be a Privledged Access Management tool that will be living in your Azure environment, mostly designed for MSPs. If you want to see how AzPam looks or contribute, check out the Github page about it here. I should be pretty close to releasing an alpha version soon! 🙂

To make sure AzPAM can also work with local accounts and domain admin accounts I figured I might try to monitor when the account has logged on. It then dawned on me that this might be something you’ll want to monitor in general. We’ve talked about monitoring new admins and groups before, but never directly if a Domain Admin has logged on.

The Script

So this script checks the lastloggedon time stamp in Active Directory, and checks if this account has logged on in the last 24 hours. You can exclude accounts by adding it to the $ExcludeList variable.

$ExludedAdmins = "JamesDoe", "JohnDoe"

$GroupMembers = Get-ADGroupMember -Identity 'Domain Admins'
$LoggedOntoday = foreach ($member in $GroupMembers) {
    if ($member.name -in $ExludedAdmins) {
        write-host "Skipping $($member.name)" -ForegroundColor Green
        continue
    }
    $ADUser = Get-ADUser -Identity $member.sid -Properties 'LastLogonTimeStamp'
    if ($ADUser.lastlogontimestamp -eq $null) { continue }
    if ([datetime]::FromFileTime($ADUser.LastLogonTimeStamp) -gt (get-date).AddHours(-24)) { 
        "$($member.name) has logged on in the last 24 hours"
    }

}

if (!$LoggedOntoday) { "Healthy. No Domain Admins have logged on today" }

And that’s it! I know it’s a bit of a short one, but with all the work I’m doing on AzPAM I’ll be sure to make it up to you guys soon! As always, Happy PowerShelling.

Monitoring with PowerShell: Monitoring Outlook offline mode and OST Sizes, and active PSTS.

As some of you have noticed I haven’t really been blogging for the past 2 weeks. My father recently died and I had to take some me-time. I’m going to be getting back to blogging regularly again starting now 🙂

Todays blog I’m going to be showing how to monitor if outlook has been set to offline mode by the user, and if the OST size is nearing it’s maximum size, as a bonus I’m also giving you the option of alerting on active PST files. The offline mode is just a handy gizmo to notify users that they might’ve misclicked – It still happens to our users from time to time.

We have a lot of users that work in shared mailboxes. These shared mailboxes get added to the user via automapping. Automapping dumps all the information into a single users OST. The official maximum OST size is 100GB, so if you have 10 shared mailboxes of 10GB, the OST can get full and the user won’t be able to send or receive e-mails.

Monitoring offline mode

So this script uses my RunAsUser Module. This is because Outlook only runs in user mode and as such you need to run these commands as the user itself.

Install-module RunAsUser -Force
$ScriptBlock = { 
    try {
        $outlook = new-object -comobject outlook.application
        $State = if ($outlook.session.offline) {"Outlook has been set to work offline mode." } else { "Healthy - Outlook is in online mode." }
        set-content "C:\programdata\Outlookmonitoring.txt" -value $State -force
    }
    catch {
        set-content "C:\programdata\Outlookmonitoring.txt" -Value  "Could not connect to outlook. " -Force
    }
}
Invoke-AsCurrentUser -UseWindowsPowerShell -NonElevatedSession -scriptblock $ScriptBlock
$Errorstate = get-content "C:\programdata\Outlookmonitoring.txt"

$Errorstate

Monitoring OST Sizes

So like I said before; the maximum size of a OST is 100GB, above that you’ll experience lots of performance loss so we want to keep it nice and small. Let’s say around 60GB. By using this monitoring method you can find exactly which OSTS are in use and how large they are.

Install-module RunAsUser -Force
$ScriptBlock = { 
    try {
        $FileSizeAlert = 60GB
        $outlook = new-object -comobject outlook.application
        $OSTS = ($outlook.session.stores | where-object {$_.filepath -ne ""}).filepath
        $State = foreach ($OST in $OSTS) {
            $File = get-item $OST
            if($File.Length -gt $FileSizeAlert){ "$OST is larger than alert size" }
        }
        if(!$State){ $State = "Healthy - No Large OST found."}
        set-content "C:\programdata\OutlookOSTmonitoring.txt" -value $State -force
    }
    catch {
        set-content "C:\programdata\OutlookOSTmonitoring.txt" -Value  "Could not connect to outlook. " -Force
    }
}
Invoke-AsCurrentUser -UseWindowsPowerShell -NonElevatedSession -scriptblock $ScriptBlock
$Errorstate = get-content "C:\programdata\OutlookOSTmonitoring.txt"

$Errorstate

Finding actively used PST files

Of course we all want to avoid PST files as much as possible, they are prone to dataloss and just a pretty fragile format in general. To find if users have a PST actively mounted in Oulook you can use the following script:

Install-module RunAsUser -Force
$ScriptBlock = { 
    try {
        $outlook = new-object -comobject outlook.application
        $PSTS = ($outlook.session.stores | where-object { $_.filepath -like "*pst" }).filepath
        if (!$PSTS) { $PSTS = "Healthy - No active PST found." }
        set-content "C:\programdata\OutlookPSTmonitoring.txt" -value $PSTS -force
    }
    catch {
        set-content "C:\programdata\OutlookPSTmonitoring.txt" -Value  "Could not connect to outlook. " -Force
    }
}
Invoke-AsCurrentUser -UseWindowsPowerShell -NonElevatedSession -scriptblock $ScriptBlock
$Errorstate = get-content "C:\programdata\OutlookPSTmonitoring.txt"

$Errorstate

And that’s all! As always, Happy PowerShelling

Automating with PowerShell: Changing Modern and Basic authentication settings

A friend of mine recently asked the question on how he could edit the Modern Authentication settings in Office365. He found that when he went to the new Settings Pane for Modern Authentication he could change settings specifically to block older clients.

If you don’t know where to find this, check it out in your Office365 Portal by going to Settings -> Org Settings -> Modern Authentication;

Modern authentication settings portal 2020.

His question was how he could centrally manage these checkboxes. I explained to him that these checkboxes are actually based on something that’s existed for ages; Exchange Authentication policies. It turns out that when you change these settings a new policy is created called “No Basic Auth” and this is set as the default for the entire tenant.

So to centrally manage this it’s actually straight forward – We create our own policy called “No Basic Auth” and enable this as the default policy. From the moment this policy is deployed you’ll also be able to change these settings via the portal and they will be reflected immediately. So let’s get to the script.

This script disables all forms of basic authentication for you, so it copies the screenshot above. It’s a good plan to slowly start implementing this at all your clients – In just 6 months basic authentication is being completely deprecated with no way to turn it back on. Doing this now allows you and your clients to prepare.

The Script

######### Secrets #########
$ApplicationId = 'AppID'
$ApplicationSecret = 'AppSecret' | ConvertTo-SecureString -Force -AsPlainText
$TenantID = 'TenantID'
$RefreshToken = 'VeryLongReFreshToken'
$ExchangeRefreshToken = 'ExchangeRefreshToken'
$UPN = "UPNUSedToGenerateTokens"
######### Secrets #########


$credential = New-Object System.Management.Automation.PSCredential($ApplicationId, $ApplicationSecret)

$aadGraphToken = New-PartnerAccessToken -ApplicationId $ApplicationId -Credential $credential -RefreshToken $refreshToken -Scopes 'https://graph.windows.net/.default' -ServicePrincipal -Tenant $tenantID 
$graphToken = New-PartnerAccessToken -ApplicationId $ApplicationId -Credential $credential -RefreshToken $refreshToken -Scopes 'https://graph.microsoft.com/.default' -ServicePrincipal -Tenant $tenantID 

Connect-MsolService -AdGraphAccessToken $aadGraphToken.AccessToken -MsGraphAccessToken $graphToken.AccessToken
$CurrentConf = $customers = Get-MsolPartnerContract -All
foreach ($customer in $customers) {
    write-host " Processing $($customer.DefaultDomainName)"
    $token = New-PartnerAccessToken -ApplicationId 'a0c73c16-a7e3-4564-9a95-2bdf47383716'-RefreshToken $ExchangeRefreshToken -Scopes 'https://outlook.office365.com/.default' -Tenant $customer.TenantId
    $tokenValue = ConvertTo-SecureString "Bearer $($token.AccessToken)" -AsPlainText -Force
    $credential = New-Object System.Management.Automation.PSCredential($upn, $tokenValue)
    $customerId = $customer.DefaultDomainName
    $session = New-PSSession -ConfigurationName Microsoft.Exchange -ConnectionUri "https://ps.outlook.com/powershell-liveid?DelegatedOrg=$($customerId)&BasicAuthToOAuthConversion=true" -Credential $credential -Authentication Basic -AllowRedirection
    $null = Import-PSSession $session -AllowClobber -CommandName "Set-AuthenticationPolicy", "Get-AuthenticationPolicy", "Get-OrganizationConfig", "New-AuthenticationPolicy", "Get-OrganizationConfig", "set-OrganizationConfig"

    $null = New-AuthenticationPolicy -Name "No Basic Auth" -AllowBasicAuthActiveSync:$FALSE -AllowBasicAuthAutodiscover:$FALSE -AllowBasicAuthImap:$false -AllowBasicAuthMapi:$FALSE  -AllowBasicAuthOfflineAddressBook:$FALSE  -AllowBasicAuthOutlookService:$FALSE  -AllowBasicAuthPop:$FALSE  -AllowBasicAuthPowershell:$FALSE  -AllowBasicAuthReportingWebServices:$FALSE  -AllowBasicAuthRpc:$FALSE -AllowBasicAuthSmtp -AllowBasicAuthWebServices:$FALSE -erroraction silentlycontinue
    $null = Set-OrganizationConfig -DefaultAuthenticationPolicy "No Basic Auth"
    Remove-PSSession $session
}

And that’s it! As always, Happy PowerShelling!

Monitoring with PowerShell: Monitoring driver issues & Monitoring ZeroLogon

So today I’m tackling two monitoring blogs again. We’re going to have a small script that checks if all devices currently have drivers installed, and if they are not in a alerting state. This is mostly useful for when a docking station or network card, or other USB device is having issues and reporting that in the Device manager. It allows you to proactively get help the user get the most out of their system.

Of course, all drivers should already be installed on the system but you never know what kind of devices a user adds. It could be anything from a USB controlled rocket launcher to a USB Com port.

Monitoring Potential Driver Issues

$DeviceState = Get-WmiObject -Class Win32_PnpEntity -ComputerName localhost -Namespace Root\CIMV2 | Where-Object {$_.ConfigManagerErrorCode -gt 0 
}



$DevicesInError = foreach($Device in $DeviceState){
 $Errortext = switch($device.ConfigManagerErrorCode){
        0  {"This device is working properly."}
        1  {"This device is not configured correctly."}
        2  {"Windows cannot load the driver for this device."}
        3  {"The driver for this device might be corrupted, or your system may be running low on memory or other resources."}
        4  {"This device is not working properly. One of its drivers or your registry might be corrupted."}
        5  {"The driver for this device needs a resource that Windows cannot manage."}
        6  {"The boot configuration for this device conflicts with other devices."}
        7  {"Cannot filter."}
        8  {"The driver loader for the device is missing."}
        9  {"This device is not working properly because the controlling firmware is reporting the resources for the device incorrectly."}
        10  {"This device cannot start."}
        11  {"This device failed."}
        12  {"This device cannot find enough free resources that it can use."}
        13  {"Windows cannot verify this device's resources."}
        14  {"This device cannot work properly until you restart your computer."}
        15  {"This device is not working properly because there is probably a re-enumeration problem."}
        16  {"Windows cannot identify all the resources this device uses."}
        17  {"This device is asking for an unknown resource type."}
        18  {"Reinstall the drivers for this device."}
        19  {"Failure using the VxD loader."}
        20  {"Your registry might be corrupted."}
        21  {"System failure: Try changing the driver for this device. If that does not work, see your hardware documentation. Windows is removing this device."}
        22  {"This device is disabled."}
        23  {"System failure: Try changing the driver for this device. If that doesn't work, see your hardware documentation."}
        24  {"This device is not present, is not working properly, or does not have all its drivers installed."}
        25  {"Windows is still setting up this device."}
        26  {"Windows is still setting up this device."}
        27  {"This device does not have valid log configuration."}
        28  {"The drivers for this device are not installed."}
        29  {"This device is disabled because the firmware of the device did not give it the required resources."}
        30  {"This device is using an Interrupt Request (IRQ) resource that another device is using."}
        31  {"This device is not working properly because Windows cannot load the drivers required for this device."}
                }
    [PSCustomObject]@{
        ErrorCode = $device.ConfigManagerErrorCode
        ErrorText = $Errortext
        Device = $device.Caption
        Present = $device.Present
        Status = $device.Status
        StatusInfo = $device.StatusInfo
    }
}

if(!$DevicesInError){
    write-host "Healthy"
} else {
    $DevicesInError
}

Monitoring Zerologon

So Zerologon is a pretty big issue and at the start there was some confusion – Is just installing the patch enough to be safe? well, to be completely clear: No. Just installing the patch is not enough. Microsoft understood the confusion and added an addendum to their own here.

So, to quote Microsoft:

Mitigation consists of installing the update on all DCs and RODCs, monitoring for new events, and addressing non-compliant devices that are using vulnerable Netlogon secure channel connections. Machine accounts on non-compliant devices can be allowed to use vulnerable Netlogon secure channel connections; however, they should be updated to support secure RPC for Netlogon and the account enforced as soon as possible to remove the risk of attack.

Microsoft – CVE-2020-1472

So to make sure we don’t get affected by the bug we have to start monitoring for two events and alert on it. That’s quite simple with PowerShell and you can use the following script for it.

$Events = Get-WinEvent -FilterXPath "Event[ System[ (Level=2 or Level=3) and (EventID=5827 or EventID=5828 or EventID=5829 or EventID=5830 or EventID=5831) ] ] ]" 
if(!$Events){
    write-host "Healthy - No events found"
} else {
    write-host "Unhealthy - Events found. Immediate action required"
}

Of course you could also just take a shotgun to the problem, and enable the FullSecureChannelProtection mode. This will also be done automatically after February 2021.

New-ItemProperty "HKLM:\system\CurrentControlSet\services\netlogon\parameters" -Name 'FullSecureChannelProtection' -Value 1 -PropertyType "DWord" -Force

And that’s it! As always, Happy PowerShelling!

Automating with PowerShell: Creating your own password push

I was recently talking with some friends in MSPGeek about Password pushing options, and that it’s kind of strange of relying on a third party closed service to generate and send passwords to clients. You’re not 100% in control and often it misses some form of functionality like a password generator or somesuch.

As an experiment I’ve decided to create my own password pushing tool with an Azure Function. The Azure Function has the following functionality(haha)

  • You can generate passwords programmatically (/generate). You can use these passwords in other scripts for example.
  • And create a URL with either a generated password in it, or a self-typed password. (/Create)
  • You can also retrieve the password, immediately destroying it in the process (/Get)
  • And the passwords are also destroyed after the Maximum age you’ve set during set-up.

If you want a demonstration on how it works, check out https://pw.cyberdrain.com/create. If you don’t want to roll your own? feel free to use mine. 🙂

So what’s the use case?

You could also use it as validation when a user calls for something that requires a second factor. Simply create a link for them, e-mail it and await confirmation. Of course the primary reason for a tool like this is to securely share ‘first logon’ passwords.

There’s actually a lot of options; You could use it to send a license code to an end user, or you could use it to generate passwords for your application.

And that brings us to our second point; the passwords. The passwords are generated from a 10000 (English) words wordlist I’ve found online. They also get 5 random characters added to the end. Most passwords will look something like “BullfrogSymptomaticSmartphone$5%^3”.

These passwords are fairly long, still easy to type and often comply with password requirements that’s applications have.

Alright I’ve heard enough, How do I use it?

Simply click the Deploy to Azure button below. This will create the application for you. The cost will be somewhere between 50 cents and 1,50 a month, depending on how heavily you use it of course.

After deployment you can click on “Go to deployment” and “Custom Domains” to find your password push URL. This will be a little bit of an ugly URL like “azpwpushujkfh.azurewebsites.net”. You can decide to use this, or add your custom domain right away.

To add a custom domain click on the “Add Custom Domain” button and follow the instructions. You’ll have to add two DNS records to your DNS provider. One TXT record named “asuid.YOURDOMAIN.COM” with the value Microsoft gives you for validation, and a CNAME record to forward to the Azure Function.

I’d strongly suggest to also enable HTTPS-Only, and add a HTTPS certificate. You can add any pre-existing PFX file so you don’t have to buy a new one if you already have it.

So that’s it! If you have any feature requests, please drop them on the github page here. As always, Happy PowerShelling!

Automating with PowerShell: Enabling Secure Defaults (And SD explained)

In one of the groups I am in there was some confusion about how Secure Defaults work and how to deploy the Secure Defaults centrally, so I figured I would try to help with this.

Secure Defaults is Microsoft’s answer to our questions about deploying multi factor authentication to an entire tenant, of course security defaults does a lot more than just that.

So what does Security Defaults do?

  • Requires users to register for Multi-factor authentication. This allows a user to take up to 14 days to register MFA.
  • It also Disables legacy authentication protocols
  • Protects all privileged account logons, like your global administrator.
  • It requires MFA for each login into a protected portal such as Azure, and the O365 admin portal.
  • This one is key: it requires users to logon with MFA only when the logon is seen as risky.

So that last point is pretty big; Users are not prompted for MFA each time they logon. This has been done on purpose by our friends at Microsoft. Microsoft believes that with the data they gathered around security and multi-factor authentication this is the best solution as it avoids creating a pattern of “muscle” memory where users are continually prompted for MFA and just start quickly clicking “Approve”.

Users get a little less trigger-happy on MFA prompts when they are unusual, so that should help you in your security practices. If you want to know exactly what a risky event is, click here for some more information. Microsoft has a neat little table with the exact definition.

If you still want users to get prompted as each logon, as opposed to only some. You’ll have to go to the Multifactor admin page and click ‘enable’ for each user, after enabling Security Defaults. The users will then always get prompted on the method they configured for Security Defaults, you can use the script below to enable Security Defaults on all tenants, or a single tenant.

Permissions

As always you’ll need the secure application model for this script. You’ll also need to add some permissions:

  • Go to the Azure Portal.
  • Click on Azure Active Directory, now click on “App Registrations”.
  • Find your Secure App Model application. You can search based on the ApplicationID.
  • Go to “API Permissions” and click Add a permission.
  • Choose “Microsoft Graph” and “Application permission”.
  • Search for “Policy” and click on “Policy.Read.All and Policy.ReadWrite.ConditionalAccess”. Click on add permission.
  • Do the same for “Delegate Permissions”.
  • Finally, click on “Grant Admin Consent for Company Name.

Single Tenant Script

######### Secrets #########
$ApplicationId = 'AppID'
$ApplicationSecret = 'AppSecret' | ConvertTo-SecureString -Force -AsPlainText
$RefreshToken = 'VeryLongRefreshToken'
######### Secrets #########
$CustomerTenant = "YourClient.onmicrosoft.com"
########################## Script Settings  ############################
$Baseuri = "https://graph.microsoft.com/beta"
write-host "Generating token to log into Azure AD." -ForegroundColor Green
$credential = New-Object System.Management.Automation.PSCredential($ApplicationId, $ApplicationSecret)
$CustGraphToken = New-PartnerAccessToken -ApplicationId $ApplicationId -Credential $credential -RefreshToken $refreshToken -Scopes "https://graph.microsoft.com/.default" -ServicePrincipal -Tenant $CustomerTenant
$Header = @{
    Authorization = "Bearer $($CustGraphToken.AccessToken)"
}

$SecureDefaultsState = (Invoke-RestMethod -Uri "$baseuri/policies/identitySecurityDefaultsEnforcementPolicy" -Headers $Header -Method get -ContentType "application/json")
 
if ($SecureDefaultsState.IsEnabled -eq $true) {
    write-host "Secure Defaults is already enabled for $CustomerTenant. Taking no action."-ForegroundColor Green
}
else {
    write-host "Secure Defaults is disabled. Enabling for $CustomerTenant" -ForegroundColor Yellow
    $body = '{ "isEnabled": true }'
    (Invoke-RestMethod -Uri "$baseuri/policies/identitySecurityDefaultsEnforcementPolicy" -Headers $Header -Method patch -Body $body -ContentType "application/json")
}

This script checks, and sets the Security Defaults to on, for a single tenant.

All tenants scripts

######### Secrets #########
$ApplicationId = 'AppID'
$ApplicationSecret = 'AppSecret' | ConvertTo-SecureString -Force -AsPlainText
$RefreshToken = 'VeryLongRefreshToken'
######### Secrets #########
$Skiplist = "Bla1.onmicrosoft.com", "bla2.onmicrosoft.com"
########################## Script Settings  ############################
 
$Baseuri = "https://graph.microsoft.com/beta"
write-host "Generating token to log into Azure AD." -ForegroundColor Green
$credential = New-Object System.Management.Automation.PSCredential($ApplicationId, $ApplicationSecret)

$aadGraphToken = New-PartnerAccessToken -ApplicationId $ApplicationId -Credential $credential -RefreshToken $refreshToken -Scopes 'https://graph.windows.net/.default' -ServicePrincipal
$graphToken = New-PartnerAccessToken -ApplicationId $ApplicationId -Credential $credential -RefreshToken $refreshToken -Scopes 'https://graph.microsoft.com/.default'

Connect-MsolService -AdGraphAccessToken $aadGraphToken.AccessToken -MsGraphAccessToken $graphToken.AccessToken
 
$customers = Get-MsolPartnerContract -All | Where-Object { $_.DefaultDomainName -notin $skiplist }

foreach ($customer in $customers) {
    $CustomerTenant = $customer.defaultdomainname
    $CustGraphToken = New-PartnerAccessToken -ApplicationId $ApplicationId -Credential $credential -RefreshToken $refreshToken -Scopes "https://graph.microsoft.com/.default" -ServicePrincipal -Tenant $CustomerTenant
    $Header = @{
        Authorization = "Bearer $($CustGraphToken.AccessToken)"
    }

    $SecureDefaultsState = (Invoke-RestMethod -Uri "$baseuri/policies/identitySecurityDefaultsEnforcementPolicy" -Headers $Header -Method get -ContentType "application/json")
 
    if ($SecureDefaultsState.IsEnabled -eq $true) {
        write-host "Secure Defaults is already enabled for $CustomerTenant. Taking no action."-ForegroundColor Green
    }
    else {
        write-host "Secure Defaults is disabled. Enabling for $CustomerTenant" -ForegroundColor Yellow
        $body = '{ "isEnabled": true }'
        (Invoke-RestMethod -Uri "$baseuri/policies/identitySecurityDefaultsEnforcementPolicy" -Headers $Header -Method patch -Body $body -ContentType "application/json")
    }

}

And this one processes all tenants. If you want to skip a couple of them, just enter their default domain names in the skiplist variable.

That’s it! as always, Happy PowerShelling.

Documenting with PowerShell: Autotask Stack Documentation

This blog is based on Gavin Stones amazing work on gavsto.com. Gavin is a good friend of mine and he helps out our community a lot. As before I worked with his glance cards within IT-Glue that make documentation prettier.

When he showed me how he used the Glance cards to create a stack overview I loved the idea and figured I would make the same for Autotask users, unfortunately I haven’t been able to stick a lot of time into this as other stuff is taking priority. I figured I would share the rough draft and have the community create it into something even better. 🙂

All you have to do for this version is to fill out the services list variable with the name of the services inside of your Autotask PSA. It will then create a glance card to show if its active or not. To show how this could look in your PSA I’ve included a screenshot, with some contract names blocked out:

The script

So like I said, it’s a little bit rough on the edges and I haven’t had time to perfect it. Feel free to change it to your own needs of course. Our internal version focusses more on different contract types, services, etc so I cannot share that. 🙂


########################## Autotask ############################
$ATAPICODE = "AutotaskAPIIntrgrationcode"
$ATCreds = get-credential
########################## Autotask ############################

########################## IT-Glue ############################
$APIKEy = "ITGLUEAPIKEY"
$APIEndpoint = "https://api.eu.itglue.com"
$FlexAssetName = "Stack overview"
$Description = "A network one-page document that shows the configured stack"
########################## IT-Glue ############################
$LogoURL = "https://google.com/logo.png"
$ServicesList = "Office 365", "Microsoft 365", "Online Backup", "24/7 support", "E-mail filtering"



#Grabbing ITGlue Module and installing.
If (Get-Module -ListAvailable -Name "ITGlueAPI") { 
    Import-module ITGlueAPI 
}
Else { 
    Install-Module ITGlueAPI -Force
    Import-Module ITGlueAPI
}
#Settings IT-Glue logon information
Add-ITGlueBaseURI -base_uri $APIEndpoint
Add-ITGlueAPIKey $APIKEy


#Grabbing ITGlue Module and installing.
If (Get-Module -ListAvailable -Name "AutotaskAPI") { 
    Import-module AutotaskAPI
}
Else { 
    Install-Module AutotaskAPI -Force
    Import-Module AutotaskAPI
}
#Settings IT-Glue logon information
add-Autotaskapiauth -ApiIntegrationcode $ATAPICode -credentials $ATcreds


write-host "Checking if Flexible Asset exists in IT-Glue." -foregroundColor green
$FilterID = (Get-ITGlueFlexibleAssetTypes -filter_name $FlexAssetName).data
if (!$FilterID) { 
    write-host "Does not exist, creating new." -foregroundColor green
    $NewFlexAssetData = 
    @{
        type          = 'flexible-asset-types'
        attributes    = @{
            name        = $FlexAssetName
            icon        = 'sitemap'
            description = $description
        }
        relationships = @{
            "flexible-asset-fields" = @{
                data = @(
                    @{
                        type       = "flexible_asset_fields"
                        attributes = @{
                            order           = 1
                            name            = "Stack Info"
                            kind            = "Textbox"
                            required        = $true
                            "show-in-list"  = $true
                            "use-for-title" = $true
                        }
                    }
                )
            }
        }
    }
    New-ITGlueFlexibleAssetTypes -Data $NewFlexAssetData 
    $FilterID = (Get-ITGlueFlexibleAssetTypes -filter_name $FlexAssetName).data
}


function New-BootstrapSinglePanel {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory)]  
        [ValidateSet('active', 'success', 'info', 'warning', 'danger', 'blank')]
        [string]$PanelShading,
    
        [Parameter(Mandatory)]
        [string]$PanelTitle,
    
        [Parameter(Mandatory)]
        [string]$PanelContent,
    
        [switch]$ContentAsBadge,
    
        [string]$PanelAdditionalDetail,
    
        [Parameter(Mandatory)]
        [int]$PanelSize = 3
    )
    
    if ($PanelShading -ne 'Blank') {
        $PanelStart = "<div class=`"col-sm-$PanelSize`"><div class=`"panel panel-$PanelShading`">"
    }
    else {
        $PanelStart = "<div class=`"col-sm-$PanelSize`"><div class=`"panel`">"
    }
    
    $PanelTitle = "<div class=`"panel-heading`"><h3 class=`"panel-title text-center`">$PanelTitle</h3></div>"
    
    
    if ($PSBoundParameters.ContainsKey('ContentAsBadge')) {
        $PanelContent = "<div class=`"panel-body text-center`"><h4><span class=`"label label-$PanelShading`">$PanelContent</span></h4>$PanelAdditionalDetail</div>"
    }
    else {
        $PanelContent = "<div class=`"panel-body text-center`"><h4>$PanelContent</h4>$PanelAdditionalDetail</div>"
    }
    $PanelEnd = "</div></div>"
    $FinalPanelHTML = "{0}{1}{2}{3}" -f $PanelStart, $PanelTitle, $PanelContent, $PanelEnd
    return $FinalPanelHTML
}


$AllActiveClients = Get-AutotaskAPIResource -Resource Companies -SimpleSearch 'isactive eq true' | Where-Object { $_.companytype -eq 1 }
$allContracts = Get-AutotaskAPIResource -Resource Contracts -SimpleSearch "contracttype eq 7" 
$AllContractservices = Get-AutotaskAPIResource -Resource ContractServices -SimpleSearch "id noteq 1" 
$AllServicesNames = Get-AutotaskAPIResource -Resource services -SimpleSearch "isActive eq true" 


$ATInfo = foreach ($ActiveClient in $AllActiveClients) {
    write-host "Working on client $($ActiveClient.companyname)"
    $currentContracts = $allContracts | Where-Object { $_.Companyid -eq $ActiveClient.id -and $_.status -eq 1 }
    $services = foreach ($Contract in $currentContracts) {
        $AllContractservices | where-object { $_.contractid -eq $Contract.id } | ForEach-Object {
            $serviceid = $_.ServiceID
            $AllServicesNames  | Where-Object { $_.id -eq $serviceid }
        }
    }

    [PSCustomObject]@{
        ClientName   = $ActiveClient.companyName
        ClientID     = $ActiveClient.id
        ClientDomain = $ActiveClient.webAddress
        Services     = $services.name
        Contracts    = $currentContracts.Contractname
    }
}




foreach ($Clientinfo in $ATInfo) {
    $Org = (Get-ITGlueOrganizations -filter_name $Clientinfo.ClientName).data.id | select-object -last 1
    $HTML = foreach ($Service in $ServicesList) {
        if ($Clientinfo.Services -like "*$($service)*") {
            New-BootstrapSinglePanel -PanelShading "success" -PanelTitle "<img src='$($LogoURL)'" -PanelContent $($service)  -ContentAsBadge -PanelSize 3
        }
        else {
            New-BootstrapSinglePanel -PanelShading "danger" -PanelTitle "<img src='$($LogoURL)'" -PanelContent $($service) -ContentAsBadge -PanelSize 3

        }
    }

    $FlexAssetBody =
    @{
        type       = 'flexible-assets'
        attributes = @{
            traits = @{
                'stack-info' = $HTML
            }
        }
    }

    $ExistingFlexAsset = (Get-ITGlueFlexibleAssets -filter_flexible_asset_type_id $($filterID.ID) -filter_organization_id $org).data | select-object -last 1
    #If the Asset does not exist, we edit the body to be in the form of a new asset, if not, we just upload.
    if (!$ExistingFlexAsset) {
        $FlexAssetBody.attributes.add('organization-id', $org)
        $FlexAssetBody.attributes.add('flexible-asset-type-id', $($filterID.ID))
        write-host "                      Uploading $($Clientinfo.clientname) to $org" -ForegroundColor Green
        New-ITGlueFlexibleAssets -data $FlexAssetBody
    }
    else {
        write-host "                      Updating  $($Clientinfo.clientname) to $org"  -ForegroundColor Green
        $ExistingFlexAsset = $ExistingFlexAsset | select-object -last 1
        Set-ITGlueFlexibleAssets -id $ExistingFlexAsset.id -data $FlexAssetBody
    }

}

As always, Happy PowerShelling 🙂

Automating with PowerShell: Deploying Azure Functions

First I have to make a little comment about the previous weekend; namely I’ve been awarded the Microsoft MVP status last Thursday. It’s still a surreal and bizarre experience really. I’d like to thank all my readers and my friends for supporting me. It’s great to get the acknowledgement that I’m doing the right thing for our community. So again, thanks. 🙂

Let’s get to scripting now! The biggest complaint I’ve had with my more complex stuff is that at times it’s pretty hard to implement. There are a lot of dependencies and the interfaces changes from time to time. So with the help from some super useful blogs I was able to find a way to ease implementation for a lot of my Azure Functions.

So, following are single click deployment buttons for some of my Azure Functions. I’ve also included two examples for generic functions. You can change these examples to run something on a schedule in Azure or to run something based on visiting the HTTPS page.

AzGlue

AzGlue is an API proxy for IT-Glue, Azglue was made to work around the rate-limiting and security concerns. The original blog can be found here. This script has 2 version; ‘lite’ is the version that is currently on the blog, with some minor improvements. Master is the version updated by AngusWarren which focuses on security even more.

The lite version is for IT-Glue users that want to dip their toe into making the API more secure and are worried about rate limits, it was also made available by popular request as a lot of people tend to change this version into something more suitable to their organization.

The Master version is more complex and for people that really need to limit API access and control this.

The Azure Calculation for this can be found here. The cost is around 4€ per month with 5 million API executions.

AzDynDNS

AzDynDNS is a replacement for the EOL managed DNS that Oracle used to have. This function gives you a compatible DynDNS updater for only about €2,- a month. The original blog can be found here. You’ll only need to point your NS records to Azure.

The cool thing about this one is that you can update it from any client, be it a router, PowerShell, etc, I also really like the single API key approach. 🙂

The Azure Calculation for this can be found here. The cost is around 2€ per month with 2 million DNS queries.

AzAutoMapper

So AzAutoMapper is my baby, quite popular with our team as it allows you to automatically map Microsoft Teams in the OneDrive client, without that annoying wait that exists with the registry/intune method. This is instant and thus fantastic during migrations. You’ll need the Secure Application Model configured, the original blog is here.

The Azure Calculation for this can be found here. The cost is around 1€ per month

Timer & HTTP Function

So this one is a example of how you can run script on a timer. This one only has the “main” branch as its for testing purposes. 🙂 This script runs every hour, or via a HTTPs call.

To replace the example script pulled from Github with one of your own, perform the following steps:

  • Go to the Azure Portal
  • Click the function name
  • Click on Deployment Center
  • Click on “Disconnect”
  • You can now edit the script via the Functions button -> HTTPTrigger or TimerTrigger -> Code & Test

And that’s it! As always, Happy PowerShelling!

Edit: One of my good friends suggested I’d also add a little estimated cost thingy, each blog now has a estimated cost via Azure Calculator 🙂 You can use that fairly easily to estimate the cost of your Azure Functions.

Monitoring with PowerShell: Monitoring Dynamic VHDX files.

Using Dynamic VHDX files isn’t a real big problem in most cases – Especially when you pay a lot of attention to how your storage layer is designed and how you’ll give VM access to that data, but at times with many administrators it can become confusing to monitor if the dynamic disk total does not exceed the physical disk total.

This could cause issues – Just imagine explosive growth due to an issue in the VM. It could cause all other VMs to lock-up and go into paused mode. Or imagine that you have a file share with User Profile Disks where its often invisible that users are added but in the long run you’ll get space issues.

So to solve, we can use the following script. The script gets all VHD/VHDX files recursively and compares the combined maximum size of them to the physical drive where the VHDX files are located. It also gives us the information just how full a VHDx file is, giving us a chance to shrink it if we need to.

The Script

$VHDPath = "D:\VHD"
$VHDXFiles = get-childitem $VHDPath -Filter "*.vhd*" -Recurse
if (!(get-module 'hyper-v' -ListAvailable)) { Enable-WindowsOptionalFeature -Online -FeatureName Microsoft-Hyper-V-Management-PowerShell }

if (!$VHDXFiles) { write-host "No VHD(x) files found. Please change the path to a location that stores VHD(x) files." break }

$VHDInfo = foreach ($VHD in $VHDXFiles) {
    $info = get-vhd -path $VHD.FullName
    [PSCustomObject]@{
        MaxSize        = ($info.Size / 1gb)
        CurrentVHDSize = ($info.FileSize / 1gb)
        MinimumSize    = ($info.MinimumSize / 1gb)
        VHDPath        = $info.path
        Type           = $info.vhdtype
        PercentageFull   = ($info.size / $info.FileSize *100 )
    }
}

$CombinedSize = (($VHDINFO | Where-Object { $_.type -eq 'Dynamic' }  ).Maxsize | measure-object -sum).sum
$DiskSize = [math]::round((Get-Volume ($VHDPath.Split(':') | Select-Object -First 1)).size / 1gb)

if ($CombinedSize -gt $DiskSize) {
    write-host "The combined VHD(x) is greater than the disk: VHD: $($combinedSize)GB - Disk: $($DiskSize)GB"
}

And that’s it! as always, Happy PowerShelling

Monitoring with PowerShell: Monitoring O365 unused products

As an MSP we manage a lot of clients, and I’m pretty sure we’ve all been in situations where a client had some leavers in the company and not notify us as the administrators, or that the client had some very inactive users that don’t really need to be licensed or could be converted to a shared mailbox for example.

To help these clients we monitor the users activity and remove licenses when they are not required or start the off-boarding procedure when a client has someone has left the company. This often generates goodwill at our client and gives us the feeling of really being in control of each environment.

Another great side-effect of this monitoring script is being able to alert if users aren’t using all resources too; for example directly after a teams deployment you’ll want users to start showing Teams Activity. If they’re not, you can jump in on that and help with something like more user-training. So, lets get to scripting.

User Activity Report Monitoring

To use this script, you’ll need the Secure Application Model. You’ll also need some extra permissions on your application.

  • Go to the Azure Portal.
  • Click on Azure Active Directory, now click on “App Registrations”.
  • Find your Secure App Model application. You can search based on the ApplicationID.
  • Go to “API Permissions” and click Add a permission.
  • Choose “Microsoft Graph” and “Application permission”.
  • Search for “Reports” and click on “Reports.Read.All”. Click on add permission
  • Do the same for “Delegate Permissions”.
  • Finally, click on “Grant Admin Consent for Company Name.
######### Secrets #########
$ApplicationId = 'YourApplicationID'
$ApplicationSecret = 'YourApplicationSecret' | ConvertTo-SecureString -Force -AsPlainText
$TenantID = 'YourTenantID'
$RefreshToken = 'VeryLongRefreshToken'
$UPN = "UPN-Used-to-Generate-Tokens"
$Skiplist = "bla1.onmicrosoft.com", "bla2.onmicrosoft.com"
######### Secrets #########
$AlertingDate = (get-date).AddMonths(-2) #two months of inactivity is our alerting moment.

#######
write-host "Generating token to log into Azure AD. Grabbing all tenants" -ForegroundColor Green
$credential = New-Object System.Management.Automation.PSCredential($ApplicationId, $ApplicationSecret)
$aadGraphToken = New-PartnerAccessToken -ApplicationId $ApplicationId -Credential $credential -RefreshToken $refreshToken -Scopes 'https://graph.windows.net/.default' -ServicePrincipal -Tenant $tenantID
$graphToken = New-PartnerAccessToken -ApplicationId $ApplicationId -Credential $credential -RefreshToken $refreshToken -Scopes 'https://graph.microsoft.com/.default' -ServicePrincipal -Tenant $tenantID
Connect-AzureAD -AadAccessToken $aadGraphToken.AccessToken -AccountId $upn -MsAccessToken $graphToken.AccessToken -TenantId $tenantID | Out-Null
$tenants = Get-AzureAdContract -All:$true
Disconnect-AzureAD
$ActivityReport = foreach ($Tenant in $Tenants | Where-Object {$_.DefaultDomainName -notin $Skiplist}) {
    write-host "Processing tenant $($tenant.displayname)"
    $CustGraphToken = New-PartnerAccessToken -ApplicationId $ApplicationId -Credential $credential -RefreshToken $refreshToken -Scopes "https://graph.microsoft.com/.default" -ServicePrincipal -Tenant $tenant.CustomerContextId
    $Header = @{
        Authorization = "Bearer $($CustGraphToken.AccessToken)"
    }
    (Invoke-RestMethod -Uri "https://graph.microsoft.com/beta/reports/getOffice365ActiveUserDetail(period='D90')" -Headers $Header -Method get -ContentType "application/json") | ConvertFrom-Csv
}

$UserReports = foreach ($User in $ActivityReport) {
    $Onedriveused = if ($user.'Has OneDrive License' -eq $true -and $user.'OneDrive Last Activity Date' -gt $AlertingDate) { $true } else { $false }
    $ExchangeUsed = if ($user.'Has Exchange License' -eq $true -and $user.'Exchange Last Activity Date' -gt $AlertingDate) { $true } else { $false }
    $TeamsUsed = if ($user.'Has Teams License' -eq $true -and $user.'Teams Last Activity Date' -gt $AlertingDate) { $true } else { $false }
    $SharePointUsed = if ($user.'Has SharePoint License' -eq $true -and $user.'Sharepoint Last Activity Date' -gt $AlertingDate) { $true } else { $false }
    [PSCustomObject]@{
        OneDriveUsed    = $Onedriveused
        ExchangeUsed    = $ExchangeUsed
        TeamsUsed       = $TeamsUsed
        SharePointUsed  = $SharePointUsed
        AssignedProduct = $user.'Assigned Products'
        Username        = $user.'User Principal Name'
        Displayname     = $user.'Display Name'
        IsDeleted       = $user.'Is Deleted'
        DeletedOn       = $user.'Deleted Date'
    }
}

$UserReports | Out-GridView

This gives you a little grid to check out the data, you can also edit it easily to create a monitoring component out of it for your RMM.

And that’s it! As always, Happy PowerShelling

Documenting with PowerShell: O365 Groups (And Warranty updates)

This one was request by a pal of mine that I know via a MSP discord; He wanted a way to document the users in O365 groups and see what type of group it was in one go. To do this, we’re leveraging the Graph API. As in most of my blogs I have a IT-Glue version and a version in HTML for these that use different documentation systems.

For the script you’ll also need the Secure App Model ready, if you haven’t set that up yet, check out the blog and come back to this one 🙂

ITGlue version

The IT-Glue version creates a new Flexible Asset for you, and uploads each groups into their own Flexible Asset. It creates a table of the members and owners, but also tags them. That way you can easily browse to a contact and see what groups they are in.

################### Secure Application Model Information ###################
$ApplicationId = 'YourApplicationID'
$ApplicationSecret = 'YourApplicationSecret'
$RefreshToken = 'YourVeryLongRefreshToken'
################# /Secure Application Model Information ####################
  
################# IT-Glue Information ######################################
$ITGkey = "YourITGAPIKEY"
$ITGbaseURI = "https://api.eu.itglue.com"
$FlexAssetName = "Teams - Autodoc"
$Description = "Teams information automatically retrieved."
$TableStyling = "<th>", "<th style=`"background-color:#4CAF50`">"
################# /IT-Glue Information #####################################
  
  
  
write-host "Checking if IT-Glue Module is available, and if not install it." -ForegroundColor Green
#Settings IT-Glue logon information
If (Get-Module -ListAvailable -Name "ITGlueAPI") { 
    Import-module ITGlueAPI 
}
Else { 
    Install-Module ITGlueAPI -Force
    Import-Module ITGlueAPI
}

#Setting IT-Glue logon information
Add-ITGlueBaseURI -base_uri $ITGbaseURI
Add-ITGlueAPIKey $ITGkey
   
write-host "Getting IT-Glue contact list" -ForegroundColor Green
$i = 0
$AllITGlueContacts = do {
    $Contacts = (Get-ITGlueContacts -page_size 1000 -page_number $i).data.attributes
    $i++
    $Contacts
    Write-Host "Retrieved $($Contacts.count) Contacts" -ForegroundColor Yellow
}while ($Contacts.count % 1000 -eq 0 -and $Contacts.count -ne 0) 
   
write-host "Generating unique ID List" -ForegroundColor Green
 
$DomainList = foreach ($Contact in $AllITGlueContacts) {
    $ITGDomain = ($contact.'contact-emails'.value -split "@")[1]
    [PSCustomObject]@{
        Domain   = $ITGDomain
        OrgID    = $Contact.'organization-id'
        Combined = "$($ITGDomain)$($Contact.'organization-id')"
    }
} 
$DomainList = $DomainList | sort-object -Property Combined -Unique
   
write-host "Checking if Flexible Asset exists in IT-Glue." -foregroundColor green
$FilterID = (Get-ITGlueFlexibleAssetTypes -filter_name $FlexAssetName).data
if (!$FilterID) { 
    write-host "Does not exist, creating new." -foregroundColor green
    $NewFlexAssetData = 
    @{
        type          = 'flexible-asset-types'
        attributes    = @{
            name        = $FlexAssetName
            icon        = 'sitemap'
            description = $description
        }
        relationships = @{
            "flexible-asset-fields" = @{
                data = @(
                    @{
                        type       = "flexible_asset_fields"
                        attributes = @{
                            order           = 1
                            name            = "Group Name"
                            kind            = "Text"
                            required        = $true
                            "show-in-list"  = $true
                            "use-for-title" = $true
                        }
                    },
                    @{
                        type       = "flexible_asset_fields"
                        attributes = @{
                            order           = 2
                            name            = "Group Settings"
                            kind            = "Textbox"
                            required        = $true
                            "show-in-list"  = $false
                            "use-for-title" = $false
                        }
                    },
                    @{
                        type       = "flexible_asset_fields"
                        attributes = @{
                            order          = 3
                            name           = "Group Members"
                            kind           = "Textbox"
                            required       = $false
                            "show-in-list" = $false
                        }
                    },
                    @{
                        type       = "flexible_asset_fields"
                        attributes = @{
                            order          = 4
                            name           = "Group Owners"
                            kind           = "Textbox"
                            required       = $false
                            "show-in-list" = $false
                        }
                    },
                    @{
                        type       = "flexible_asset_fields"
                        attributes = @{
                            order          = 5
                            name           = "Tagged Members"
                            kind           = "Tag"
                            "tag-type"     = "Contacts"
                            required       = $false
                            "show-in-list" = $false
                        }
                    },
                    @{
                        type       = "flexible_asset_fields"
                        attributes = @{
                            order          = 6
                            name           = "Tagged Owners"
                            kind           = "Tag"
                            "tag-type"     = "Contacts"
                            required       = $false
                            "show-in-list" = $false
                        }
                    }
                    
                )
            }
        }
    }
    New-ITGlueFlexibleAssetTypes -Data $NewFlexAssetData
    $FilterID = (Get-ITGlueFlexibleAssetTypes -filter_name $FlexAssetName).data
}
   
  
  
write-host "Creating credentials and tokens." -ForegroundColor Green
  
$credential = New-Object System.Management.Automation.PSCredential($ApplicationId, ($ApplicationSecret | Convertto-SecureString -AsPlainText -Force))
$aadGraphToken = New-PartnerAccessToken -ApplicationId $ApplicationId -Credential $credential -RefreshToken $refreshToken -Scopes 'https://graph.windows.net/.default' -ServicePrincipal
$graphToken = New-PartnerAccessToken -ApplicationId $ApplicationId -Credential $credential -RefreshToken $refreshToken -Scopes 'https://graph.microsoft.com/.default' -ServicePrincipal
  
write-host "Creating body to request Graph access for each client." -ForegroundColor Green
$body = @{
    'resource'      = 'https://graph.microsoft.com'
    'client_id'     = $ApplicationId
    'client_secret' = $ApplicationSecret
    'grant_type'    = "client_credentials"
    'scope'         = "openid"
}
  
write-host "Connecting to Office365 to get all tenants." -ForegroundColor Green
Connect-MsolService -AdGraphAccessToken $aadGraphToken.AccessToken -MsGraphAccessToken $graphToken.AccessToken
$customers = Get-MsolPartnerContract -All
foreach ($Customer in $Customers) {
    write-host "Grabbing domains for client $($Customer.name)." -ForegroundColor Green
    $CustomerDomains = Get-MsolDomain -TenantId $Customer.TenantId
    write-host "Finding possible organisation IDs" -ForegroundColor Green
    $orgid = foreach ($customerDomain in $customerdomains) {
        ($domainList | Where-Object { $_.domain -eq $customerDomain.name }).'OrgID' | Select-Object -Unique
    }
    write-host "Documenting in the following organizations." -ForegroundColor Green
    $ClientToken = Invoke-RestMethod -Method post -Uri "https://login.microsoftonline.com/$($customer.tenantid)/oauth2/token" -Body $body -ErrorAction Stop
    $headers = @{ "Authorization" = "Bearer $($ClientToken.access_token)" }
    write-host "Starting documentation process for $($customer.name)." -ForegroundColor Green
    Write-Host "Grabbing all Teams" -ForegroundColor Green
    $groups = (Invoke-RestMethod -Uri "https://graph.microsoft.com/beta/groups?&`$top=999&quot; -Headers $Headers -Method Get -ContentType "application/json").value
    Write-Host "Grabbing all Team Settings" -ForegroundColor Green
    foreach ($group in $Groups) {
        $Owners = (Invoke-RestMethod -Uri "https://graph.microsoft.com/beta/groups/$($group.id)/owners" -Headers $Headers -Method Get -ContentType "application/json").value
        $Members = (Invoke-RestMethod -Uri "https://graph.microsoft.com/beta/groups/$($group.id)/members" -Headers $Headers -Method Get -ContentType "application/json").value
        $TaggedMembers = foreach ($Member in $Members) {
            $email = $member.mail
            #Tagging devices
            if ($email) {
                #Write-Host "Finding all related contacts - Based on email: $email"
                (Get-ITGlueContacts -page_size "1000" -filter_primary_email $email).data
                start-sleep -miliseconds 110
            }
        }

        $TaggedOwners = foreach ($Owner in $Owners) {
            $email = $owner.mail
            #Tagging devices
            if ($email) {
             #   Write-Host "Finding all related contacts - Based on email: $email"
                (Get-ITGlueContacts -page_size "1000" -filter_primary_email $email).data
                start-sleep -miliseconds 110
            }
        }
        $GroupSettings = ($group | Select-Object @{Label = "Created on"; Expression = { $_.createdDateTime } },
            @{Label = "Created by Application"; Expression = { if (!$_.createdByAppId) { $false } else { $_.createdByAppId } } },
            @{Label = "Description"; Expression = { $_.description } },
            @{Label = "Mail Enabled"; Expression = { if ($_.Mailenabled) { "Yes" } else { "No " } } },
            @{Label = "Security Enabled"; Expression = { if ($_.SecurityEnabled) { "Yes" } else { "No " } } },
            @{Label = "Mail Nickname"; Expression = { $_.MailNickname } },
            @{Label = "E-Mail addresses"; Expression = { $_.Proxyaddress -join "," } } | convertto-html -Fragment | out-string) -replace $TableStyling

        $FlexAssetBody =
        @{
            type       = 'flexible-assets'
            attributes = @{
                traits = @{
                    'group-name'     = $group.displayName
                    'group-settings' = $GroupSettings
                    'group-members'  = ($members | Select-Object Displayname, Userprincipalname | convertto-html -Fragment | out-string) -replace $TableStyling
                    "group-owners"   = ($owners | Select-Object Displayname, Userprincipalname | convertto-html -Fragment | out-string) -replace $TableStyling
                    "tagged-owners"  = $TaggedOwners.id
                    'tagged-members' = $TaggedMembers.id
                }
            }
        }
  
        write-host "   Uploading $($group.displayName) into IT-Glue" -foregroundColor green
        foreach ($org in $orgID | Select-Object -Unique) {
            $ExistingFlexAsset = (Get-ITGlueFlexibleAssets -filter_flexible_asset_type_id $FilterID.id -filter_organization_id $org).data | Where-Object { $_.attributes.traits.'group-name' -eq $group.displayName }
            #If the Asset does not exist, we edit the body to be in the form of a new asset, if not, we just upload.
            if (!$ExistingFlexAsset) {
                if ($FlexAssetBody.attributes.'organization-id') {
                    $FlexAssetBody.attributes.'organization-id' = $org
                }
                else { 
                    $FlexAssetBody.attributes.add('organization-id', $org)
                    $FlexAssetBody.attributes.add('flexible-asset-type-id', $FilterID.id)
                }
                write-output "                      Creating new Team: $($Settings.displayName) into IT-Glue organisation $org"
                New-ITGlueFlexibleAssets -data $FlexAssetBody
      
            }
            else {
                write-output "                      Updating Team: $($Settings.displayName)into IT-Glue organisation $org"
                $ExistingFlexAsset = $ExistingFlexAsset | select-object -Last 1
                Set-ITGlueFlexibleAssets -id $ExistingFlexAsset.id  -data $FlexAssetBody
            }
      
        }
    }
}

Generic HTML version

The HTML version uses PsWriteHTML to make a nice overview for you. It doesn’t have the tagging options but it’s still pretty nifty.

################### Secure Application Model Information ###################
$ApplicationId = 'YourApplicationID'
$ApplicationSecret = 'YourApplicationSecret'
$RefreshToken = 'YourVeryLongRefreshToken'
################# /Secure Application Model Information ####################
write-host "Creating credentials and tokens." -ForegroundColor Green
  
$credential = New-Object System.Management.Automation.PSCredential($ApplicationId, ($ApplicationSecret | Convertto-SecureString -AsPlainText -Force))
$aadGraphToken = New-PartnerAccessToken -ApplicationId $ApplicationId -Credential $credential -RefreshToken $refreshToken -Scopes 'https://graph.windows.net/.default' -ServicePrincipal
$graphToken = New-PartnerAccessToken -ApplicationId $ApplicationId -Credential $credential -RefreshToken $refreshToken -Scopes 'https://graph.microsoft.com/.default' -ServicePrincipal
  
write-host "Creating body to request Graph access for each client." -ForegroundColor Green
$body = @{
    'resource'      = 'https://graph.microsoft.com'
    'client_id'     = $ApplicationId
    'client_secret' = $ApplicationSecret
    'grant_type'    = "client_credentials"
    'scope'         = "openid"
}
  
write-host "Connecting to Office365 to get all tenants." -ForegroundColor Green
Connect-MsolService -AdGraphAccessToken $aadGraphToken.AccessToken -MsGraphAccessToken $graphToken.AccessToken
$customers = Get-MsolPartnerContract -All
foreach ($Customer in $Customers) {
    $ClientToken = Invoke-RestMethod -Method post -Uri "https://login.microsoftonline.com/$($customer.tenantid)/oauth2/token" -Body $body -ErrorAction Stop
    $headers = @{ "Authorization" = "Bearer $($ClientToken.access_token)" }
    write-host "Starting documentation process for $($customer.name)." -ForegroundColor Green
    Write-Host "Grabbing all Teams" -ForegroundColor Green
    $groups = (Invoke-RestMethod -Uri "https://graph.microsoft.com/beta/groups?&`$top=999&quot; -Headers $Headers -Method Get -ContentType "application/json").value
    Write-Host "Grabbing all Team Settings" -ForegroundColor Green
    foreach ($group in $Groups) {
        $Owners = (Invoke-RestMethod -Uri "https://graph.microsoft.com/beta/groups/$($group.id)/owners" -Headers $Headers -Method Get -ContentType "application/json").value
        $Members = (Invoke-RestMethod -Uri "https://graph.microsoft.com/beta/groups/$($group.id)/members" -Headers $Headers -Method Get -ContentType "application/json").value

        $GroupSettings = ($group | Select-Object @{Label = "Created on"; Expression = { $_.createdDateTime } },
            @{Label = "Created by Application"; Expression = { if (!$_.createdByAppId) { $false } else { $_.createdByAppId } } },
            @{Label = "Description"; Expression = { $_.description } },
            @{Label = "Mail Enabled"; Expression = { if ($_.Mailenabled) { "Yes" } else { "No " } } },
            @{Label = "Security Enabled"; Expression = { if ($_.SecurityEnabled) { "Yes" } else { "No " } } },
            @{Label = "Mail Nickname"; Expression = { $_.MailNickname } },
            @{Label = "E-Mail addresses"; Expression = { $_.Proxyaddress -join "," } } | convertto-html -Fragment | out-string) -replace $TableStyling

     
        New-HTML {
            New-HTMLTab -Name "365 Groups: $($group.displayName)" {
                New-HTMLSection -Invisible {
                    New-HTMLSection -HeaderText 'Settings' {
                        New-HTMLTable -DataTable $GroupSettings
                    }
                }
                New-HTMLSection -Invisible {
                    New-HTMLSection -HeaderText "Members" {
                        New-HTMLTable -DataTable ($members | Select-Object Displayname, Userprincipalname)
                    }
         
                    New-HTMLSection -HeaderText "Owners" { 
                        New-HTMLTable -DataTable ($owners | Select-Object Displayname, Userprincipalname)
                    }
                }
            }
             
        } -FilePath "C:\temp\$($customer.DefaultDomainName).html" -Online
    }
}

but that’s not all! I have some other cool news that’s not really worth a blog of its own, but I’d still like to let you know.

Warranty Script updates

So a while back I created this quickly wacked together script to lookup warranties with PowerShell and create nice looking reports. The main reason for this is that there is a vendor that is showing very shady business practices around Warranty lookups and pricing them.

After a lot of requests of adding functionality or manufacturers I’ve decided to improve the script, turn it into a module and publish it to the world for easier usage. It still needs some love; especially on the help-file side and I’m hoping other community members will contribute to make this the best warranty options available. You can find the project on my Github here.

Feel free to contribute! I’d love to have other people working with me to make sure that these warranty lookup tools price themselves out of the market 😉

And that’s all! as always, Happy PowerShelling.

Documenting with PowerShell: Documenting mobile devices

This was a request by a friend in one of the peer groups I operate in. He wanted a method to document which mobile devices exist in a O365 tenant and put them into IT-Glue as configurations. I figured I’d help him real quick and blog about it.

The IT-Glue version creates configurations of each asset in O365 and documents the serial number etc. The HTML version is a simple overview of which devices exist. This should help in localizing which mobile devices are completely managed, or only partially too.

IT-Glue version

######### Secrets #########
$ApplicationId = 'AppID'
$ApplicationSecret = 'AppSecret' | ConvertTo-SecureString -Force -AsPlainText
$RefreshToken = 'RefreshToken'
$APIKEy = "LangeITGlueAPIKeyHier"
$APIEndpoint = "https://api.eu.itglue.com"
######### Secrets #########
$CustomerTenant = "Client.onmicrosoft.com"
$ITGlueORGID = "123456"
########################## Script Settings  ############################

If (Get-Module -ListAvailable -Name "ITGlueAPI") { 
    Import-module ITGlueAPI 
}
Else { 
    Install-Module ITGlueAPI -Force
    Import-Module ITGlueAPI
}
#Settings IT-Glue logon information
Add-ITGlueBaseURI -base_uri $APIEndpoint
Add-ITGlueAPIKey $APIKEy
$Baseuri = "https://graph.microsoft.com/beta"
write-host "Generating token to log into Azure AD. Grabbing all tenants" -ForegroundColor Green
$credential = New-Object System.Management.Automation.PSCredential($ApplicationId, $ApplicationSecret)
$CustGraphToken = New-PartnerAccessToken -ApplicationId $ApplicationId -Credential $credential -RefreshToken $refreshToken -Scopes "https://graph.microsoft.com/.default" -ServicePrincipal -Tenant $CustomerTenant
write-host "$($Tenant.Displayname): Starting process." -ForegroundColor Green
$Header = @{
    Authorization = "Bearer $($CustGraphToken.AccessToken)"
}
write-host "Finding devices in $($CustomerTenant)" -ForegroundColor Green
$devicesList = (Invoke-RestMethod -Uri "$baseuri/devices" -Headers $Header -Method get -ContentType "application/json").value | Where-Object { $_.Operatingsystem -ne "Windows" }

$Configurationtype = (Get-ITGlueConfigurationTypes).data | Select-Object ID -ExpandProperty attributes | Out-GridView -PassThru -Title "Select a configuration type"

$Configurationstatus = (Get-ITGlueConfigurationStatuses).data | Select-Object ID -ExpandProperty attributes | Out-GridView -PassThru -Title "Select a status type"

$manafacture = (Get-ITGlueManufacturers).data | Select-Object ID -ExpandProperty attributes | Out-GridView -PassThru -Title "Select a manufacturer"


foreach ($device in $devicesList) {
    $Existing = Get-ITGlueConfigurations -filter_serial_number $device.deviceId
    if ($Existing) { write-host "$($device.displayname) exists, skipping."; continue }
    $dataobj = @{
        type       = "configurations"
        attributes = @{
            "organization-id"         = $ITGlueOrgID
            "name"                    = $device.displayName
            "configuration-type-id"   = $configurationtype.id
            "configuration-status-id" = $Configurationstatus.id
            "manufacturer-id"         = $manafacture.id
            "serial-number"           = $device.deviceId
        }
    }
    New-ITGlueConfigurations -organization_id $ITGlueORGID -data $dataobj
    $dataobj = $null
}


So this version requires a little manual work – You’ll need to select the device type, and the manufacturer once. I often just select the bulk and fix the few manual ones later on.

HTML version

######### Secrets #########
$ApplicationId = 'AppID'
$ApplicationSecret = 'AppSecret' | ConvertTo-SecureString -Force -AsPlainText
$RefreshToken = 'RefreshToken'
$CustomerTenant = "ClientDomain.onmicrosoft.com"
########################## Script Settings  ############################


$Baseuri = "https://graph.microsoft.com/beta"
write-host "Generating token to log into Azure AD. Grabbing all tenants" -ForegroundColor Green
$credential = New-Object System.Management.Automation.PSCredential($ApplicationId, $ApplicationSecret)
$CustGraphToken = New-PartnerAccessToken -ApplicationId $ApplicationId -Credential $credential -RefreshToken $refreshToken -Scopes "https://graph.microsoft.com/.default" -ServicePrincipal -Tenant $CustomerTenant
write-host "Starting process." -ForegroundColor Green
$Header = @{
    Authorization = "Bearer $($CustGraphToken.AccessToken)"
}
write-host "Finding devices in $($CustomerTenant)" -ForegroundColor Green
$devicesList = (Invoke-RestMethod -Uri "$baseuri/devices" -Headers $Header -Method get -ContentType "application/json").value | Where-Object { $_.Operatingsystem -ne "Windows" }

New-HTML {   
    New-HTMLTab -Name 'Mobile Devices' {

        New-HTMLSection -Invisible {
            New-HTMLSection -HeaderText 'Android Devices' {
                New-HTMLTable -DataTable ($devicesList | Where-Object { $_.OperatingSystem -eq "Android" } | Sort-Object profileType, deviceOwnership, deviceVersion, operatingSystem, operatingSystemVersion, registrationDateTime, trustType, deviceId)
            }
                New-HTMLSection -HeaderText "Apple Devices" {
                    New-HTMLTable -DataTable ($devicesList | Where-Object { $_.OperatingSystem -eq "iOS" } | Sort-Object profileType, deviceOwnership, deviceVersion, operatingSystem, operatingSystemVersion, registrationDateTime, trustType, deviceId) 
                }
            }
        }
     
    } -FilePath "C:\temp\doc.html" -Online -ShowHTML

And as requested so many times before; the eventual output looks like this:

So that’s it for today! as always, Happy PowerShelling.