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.

Documenting with PowerShell: Hyper-v and physical server settings

So a while back I helped people documenting their physical servers, the biggest complaint about that blog was that “at a glance” you couldn’t really see what the server did, if it was a cluster member or not, and how the physical layout of the server was.

For this, I decided to rewrite that blog just a little – Especially now that my friend Gavin Stone (gavsto.com) made a pretty cool PowerShell function to get ‘cards’ into IT-Glue. To show you exactly what I mean, I think a screenshot works best.

IT-Glue Cards example

So having these cards opens op wonderful ways to display data in IT-Glue that isn’t just a boring table. I’ve grown to call these “glance cards” internally, but, lets get to the entire script!

IT-Glue version

So the IT-Glue version does the same as always; it creates a Flexible Asset for you, and uploads the data to IT-Glue. The script differs slightly from the earlier Hyper-v script; it also collects the current RAID and physical disk status, as that’s always useful information to have. I’ve only customized this for Dell servers, but it should be straightforward to change that to HP, or others.

########################## IT-Glue ############################
$OrgID = "ORGANIZATIONIDHERE"
$APIKEy = "ITGlueAPIKeyHere"
$APIEndpoint = "https://api.eu.itglue.com"
$FlexAssetName = "Physical Host - Autodoc v2"
$Description = "A network one-page document that displays the physical host settings, hyper-v virtual machines, etc."
$logoURL = 'https://google.com/coollogo.png'
#some layout options, change if you want colours to be different or do not like the whitespace.
$TableHeader = "<table class=`"table table-bordered table-hover`" style=`"width:80%`">"
$Whitespace = "<br/>"
$TableStyling = "<th>", "<th style=`"background-color:#4CAF50`">"
########################## IT-Glue ############################
#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
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            = "Host name"
                            kind            = "Text"
                            required        = $true
                            "show-in-list"  = $true
                            "use-for-title" = $true
                        }
                    },
                    @{
                        type       = "flexible_asset_fields"
                        attributes = @{
                            order          = 2
                            name           = "At a glance"
                            kind           = "Textbox"
                            required       = $false
                            "show-in-list" = $false
                        }
                    },
                    @{
                        type       = "flexible_asset_fields"
                        attributes = @{
                            order          = 3
                            name           = "Virtual Machines"
                            kind           = "Textbox"
                            required       = $false
                            "show-in-list" = $false
                        }
                    },
                    @{
                        type       = "flexible_asset_fields"
                        attributes = @{
                            order          = 4
                            name           = "Network Settings"
                            kind           = "Textbox"
                            required       = $false
                            "show-in-list" = $false
                        }
                    },
                    @{
                        type       = "flexible_asset_fields"
                        attributes = @{
                            order          = 5
                            name           = "Replication Settings"
                            kind           = "Textbox"
                            required       = $false
                            "show-in-list" = $false
                        }
                    },
                    @{
                        type       = "flexible_asset_fields"
                        attributes = @{
                            order          = 6
                            name           = "Host Settings"
                            kind           = "Textbox"
                            required       = $false
                            "show-in-list" = $false
                        }
                    },
                    @{
                        type       = "flexible_asset_fields"
                        attributes = @{
                            order          = 7
                            name           = "Physical Host Configuration"
                            kind           = "Textbox"
                            required       = $false
                            "show-in-list" = $false
                        }
                    }
                )
            }
        }
    }
    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
} 

function New-AtAGlancecard {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory)]  
        [boolean]$Enabled,
    
        [Parameter(Mandatory)]
        [string]$PanelContent
    )
    if ($enabled) {
        New-BootstrapSinglePanel -PanelShading "success" -PanelTitle "<img src='$LogoURL'" -PanelContent $PanelContent  -ContentAsBadge -PanelSize 3
    }
    else {
        New-BootstrapSinglePanel -PanelShading "danger" -PanelTitle "<img src='$LogoURL'" -PanelContent $PanelContent  -ContentAsBadge -PanelSize 3

    }

}

write-host "Start documentation process." -foregroundColor green


$VirtualMachines = get-vm | select-object VMName, Generation, Path, Automatic*, @{n = "Minimum(gb)"; e = { $_.memoryminimum / 1gb } }, @{n = "Maximum(gb)"; e = { $_.memorymaximum / 1gb } }, @{n = "Startup(gb)"; e = { $_.memorystartup / 1gb } }, @{n = "Currently Assigned(gb)"; e = { $_.memoryassigned / 1gb } }, ProcessorCount | ConvertTo-Html -Fragment | Out-String
$VirtualMachines = $TableHeader + ($VirtualMachines -replace $TableStyling) + $Whitespace
$NetworkSwitches = Get-VMSwitch | select-object name, switchtype, NetAdapterInterfaceDescription, AllowManagementOS | convertto-html -Fragment -PreContent "<h3>Network Switches</h3>" | Out-String
$VMNetworkSettings = Get-VMNetworkAdapter * | Select-Object Name, IsManagementOs, VMName, SwitchName, MacAddress, @{Name = 'IP'; Expression = { $_.IPaddresses -join "," } } | ConvertTo-Html -Fragment -PreContent "<br><h3>VM Network Settings</h3>" | Out-String
$NetworkSettings = $TableHeader + ($NetworkSwitches -replace $TableStyling) + ($VMNetworkSettings -replace $TableStyling) + $Whitespace
$ReplicationSettings = get-vmreplication | Select-Object VMName, State, Mode, FrequencySec, PrimaryServer, ReplicaServer, ReplicaPort, AuthType | convertto-html -Fragment | Out-String
$ReplicationSettings = $TableHeader + ($ReplicationSettings -replace $TableStyling) + $Whitespace
$HostSettings = get-vmhost | Select-Object  Computername, LogicalProcessorCount, iovSupport, EnableEnhancedSessionMode, MacAddressMinimum, *max*, NumaspanningEnabled, VirtualHardDiskPath, VirtualMachinePath, UseAnyNetworkForMigration, VirtualMachineMigrationEnabled | convertto-html -Fragment -as List | Out-String

$AtAGlanceHash = @{
    'Hyper-v server'   = if ((Get-WindowsOptionalFeature -FeatureName *hyper-v* -Online).state -eq 'enabled') { $true } else { $False }
    'Hyper-v Replicas' = if ($ReplicationSetting) { $true } else { $False }
    'Hyper-v Cluster'  = if ($null -ne (Get-CimInstance -Class MSCluster_ResourceGroup -Namespace root\mscluster -ErrorAction SilentlyContinue)) { $true } else { $false }
    'Dell server'      = if ((Get-CimInstance -Class Win32_ComputerSystem).Manufacturer -like '*Dell*') { $true } else { $false }
}
$ATaGlanceHTML = foreach ($Hash in $AtAGlanceHash.GetEnumerator()) {
    New-AtAGlancecard -Enabled $hash.value -PanelContent $hash.name
}

$PhysicalConfig = if ($AtAGlanceHash.'Dell server' -eq $true) {

    $Preferences = omconfig preferences cdvformat delimiter=pipe
    $ControllerList = (omreport storage controller -fmt xml)
    $DiskLayoutRaw = foreach ($Controller in $ControllerList.oma.controllers.DCStorageObject.GlobalNo.'#text') {
        omreport storage pdisk controller=$Controller -fmt cdv
    }

    ($DiskLayoutRaw |  select-string -SimpleMatch "ID|Status|" -context 0, ($DiskLayoutRaw).Length | convertfrom-csv -Delimiter "|" | Select-Object Name, Status, Capacity, State, "Bus Protocol", "Product ID", "Serial No.", "Part Number", Media | convertto-html -Fragment)
    $DiskNumbers = (0..1000)
    $RAIDLayoutRaw = omreport storage vdisk -fmt cdv
    ($RAIDLayoutRaw |  select-string -SimpleMatch "ID|Status|" -context 0, ($RAIDLayoutRaw).Length | convertfrom-csv -Delimiter "|" | Select-Object '> ID', Name, Status, State, Layout, "Device Name", "Read Policy", "Write Policy", Media | Where-Object {$_.'> ID' -in $DiskNumbers} |  convertto-html -Fragment)
}
else {
    "Could not retrieve physical host settings - This server is not a Dell Physical machine"
}



$FlexAssetBody =
@{
    type       = 'flexible-assets'
    attributes = @{
        traits = @{
            'host-name'                   = $env:COMPUTERNAME
            'at-a-glance'                 = ($ATaGlanceHTML | out-string)
            'virtual-machines'            = $VirtualMachines
            'network-settings'            = $NetworkSettings
            'replication-settings'        = $ReplicationSettings
            'host-settings'               = $HostSettings
            'physical-host-configuration' = $PhysicalConfig
        }
    }
}
 

write-host "Documenting to IT-Glue"  -ForegroundColor Green
$ExistingFlexAsset = (Get-ITGlueFlexibleAssets -filter_flexible_asset_type_id $($filterID.ID) -filter_organization_id $OrgID).data | Where-Object { $_.attributes.traits.'host-name' -eq $ENV:computername } | 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', $OrgID)
    $FlexAssetBody.attributes.add('flexible-asset-type-id', $($filterID.ID))
    write-host "  Creating Hyper-v into IT-Glue organisation $OrgID" -ForegroundColor Green
    New-ITGlueFlexibleAssets -data $FlexAssetBody
}
else {
    write-host "  Editing Hyper-v into IT-Glue organisation $OrgID"  -ForegroundColor Green
    Set-ITGlueFlexibleAssets -id $ExistingFlexAsset.id -data $FlexAssetBody
}

HTML Version

Of course there are people without IT-Glue and for them I have the HTML version right here. We’re using PSWriteHTML to get a nice looking overview. This doesn’t include the glance cards right now.


#Grabbing PsWriteHTML module and documenting
write-host "Start documentation process." -foregroundColor green
install-module PsWriteHtml -force

$VirtualMachines = get-vm | select-object VMName, Generation, Path, Automatic*, @{n = "Minimum(gb)"; e = { $_.memoryminimum / 1gb } }, @{n = "Maximum(gb)"; e = { $_.memorymaximum / 1gb } }, @{n = "Startup(gb)"; e = { $_.memorystartup / 1gb } }, @{n = "Currently Assigned(gb)"; e = { $_.memoryassigned / 1gb } }, ProcessorCount
$NetworkSwitches = Get-VMSwitch | select-object name, switchtype, NetAdapterInterfaceDescription, AllowManagementOS 
$VMNetworkSettings = Get-VMNetworkAdapter * | Select-Object Name, IsManagementOs, VMName, SwitchName, MacAddress, @{Name = 'IP'; Expression = { $_.IPaddresses -join "," } }
$ReplicationSettings = get-vmreplication | Select-Object VMName, State, Mode, FrequencySec, PrimaryServer, ReplicaServer, ReplicaPort, AuthType 
$HostSettings = get-vmhost | Select-Object  Computername, LogicalProcessorCount, iovSupport, EnableEnhancedSessionMode, MacAddressMinimum, *max*, NumaspanningEnabled, VirtualHardDiskPath, VirtualMachinePath, UseAnyNetworkForMigration, VirtualMachineMigrationEnabled

$AtAGlanceHash = @{
    'Hyper-v server'   = if ((Get-WindowsOptionalFeature -FeatureName *hyper-v* -Online).state -eq 'enabled') { $true } else { $False }
    'Hyper-v Replicas' = if ($ReplicationSetting) { $true } else { $False }
    'Hyper-v Cluster'  = if ($null -ne (Get-CimInstance -Class MSCluster_ResourceGroup -Namespace root\mscluster -ErrorAction SilentlyContinue)) { $true } else { $false }
    'Dell server'      = if ((Get-CimInstance -Class Win32_ComputerSystem).Manufacturer -like '*Dell*') { $true } else { $false }
}

$PhysicalConfig = if ($AtAGlanceHash.'Dell server' -eq $true) {
    $Preferences = omconfig preferences cdvformat delimiter=pipe
    $ControllerList = (omreport storage controller -fmt xml)
    $DiskLayoutRaw = foreach ($Controller in $ControllerList.oma.controllers.DCStorageObject.GlobalNo.'#text') {
        omreport storage pdisk controller=$Controller -fmt cdv
    }

    ($DiskLayoutRaw |  select-string -SimpleMatch "ID|Status|" -context 0, ($DiskLayoutRaw).Length | convertfrom-csv -Delimiter "|" | Select-Object Name, Status, Capacity, State, "Bus Protocol", "Product ID", "Serial No.", "Part Number", Media)
    $DiskNumbers = (0..1000)
    $RAIDLayoutRaw = omreport storage vdisk -fmt cdv
    ($RAIDLayoutRaw |  select-string -SimpleMatch "ID|Status|" -context 0, ($RAIDLayoutRaw).Length | convertfrom-csv -Delimiter "|" | Select-Object '> ID', Name, Status, State, Layout, "Device Name", "Read Policy", "Write Policy", Media | Where-Object { $_.'> ID' -in $DiskNumbers })
}
else {
    "Could not retrieve physical host settings - This server is not a Dell Physical machine"
}


New-HTML {
   
    New-HTMLTab -Name 'Hyper-V Settings' {
        New-HTMLSection -Invisible {
            New-HTMLSection -HeaderText 'Virtual Machines' {
                New-HTMLTable -DataTable $VirtualMachines
            }
            New-HTMLSection -HeaderText "Virtual Network Settings" {
                New-HTMLTable -DataTable $VMNetworkSettings
            }
        }
    
        New-HTMLSection -Invisible {
            New-HTMLSection -HeaderText 'Replication Settings' {
                New-HTMLTable -DataTable $ReplicationSettings
            }

            New-HTMLSection -HeaderText "Host Settings" {
                New-HTMLTable -DataTable $HostSettings
            }
        }
        New-HTMLSection -HeaderText "Physical Settings" {
            New-HTMLTable -DataTable $PhysicalConfig
        }
    }
    
} -FilePath "C:\temp\doc.html" -Online

And this script gives you this pretty result:

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

Monitoring with PowerShell: Monitoring network traffic

My holidays are over, and it’s back to blogging! I hope you all enjoyed the previous webinar I did in my holidays. For me it was a lot of fun and I’m doing a more advanced one soon, when I find some time. for now I’m going to be quite busy with other speaking engagements such as a couple of Solarwinds Events, and Huntress Hack_It!

In any case, let’s get back to our regular scheduled program: Monitoring and documentation scripts! 🙂 This time we’re tackling three issues in one; We’re going to monitor traffic usage to see if a connection isn’t saturated. We’re also going to check if the NIC speed is correct and we’re going to check if the connection is metered and if it is alert on it.

The use case for this can be pretty diverse; the connection saturation can help you find if the machine isn’t flooding the network or internet connection. The connection speed of course speaks for itself; these days everything should be full duplex gigabit, and it helps in finding old devices or devices looped through a voip phone.

The metered connection one is just a safety measure – Sometimes you’re remotely working on a machine that’s metered and you download an ISO, or a large update and the client won’t be too happy… 🙂

Monitoring bandwidth usage

$BandwidthAlertThreshold = "800" #megabits per second

$Counter = 0
$UsedBandwidth = do {
    $counter ++
    (Get-CimInstance -Query "Select BytesTotalPersec from Win32_PerfFormattedData_Tcpip_NetworkInterface" | Select-Object BytesTotalPerSec).BytesTotalPerSec / 1Mb * 8
} while ($counter -le 10)

$AvgBandwidth = [math]::round(($UsedBandwidth | Measure-Object -Average).average, 2)
$BandwidthAlert = if ($AvgBandwidth -gt $BandwidthAlertThreshold) { "Unhealthy - Bandwidth is at $AvgBandwidth" } else { "Healthy" }

This creates a poll of 10 intances, which is a little more than 5 seconds. It’ll show how much bandwidth the current machine is using. If the threshold is passed than it alerts that a unhealthy state has been reached. This is also great for localizing which machine is using most bandwidth on a network.

Monitoring link speeds


$Linkspeeds = (Get-NetAdapter -Physical | Where-Object { $_.MediaType -eq "802.3" -and $_.status -ne "Disconnected" })

$LinkspeedState = foreach ($Linkspeed in $Linkspeeds) {
    if ($Linkspeed.speed -lt 1000000000) { "$($Linkspeed.name) linkspeed is lower than 1000mb" }
}
if (!$Linkspeeds) { $LinkspeedState = "No physical links found" }
if (!$LinkspeedState) { $LinkspeedState = "Healthy" }

So this alerts on any machine that is connected to any port that is not 1gbps. In these days, I’m pretty sure you want everything gigabit connected. 🙂

Monitoring Metered Connections

[void][Windows.Networking.Connectivity.NetworkInformation, Windows, ContentType = WindowsRuntime]
$MeteredConnections = [Windows.Networking.Connectivity.NetworkInformation]::GetInternetConnectionProfile().GetConnectionCost() | Where-Object {$_.Networkcosttype -ne "Unrestricted"}

$RunningOnMetered = if($MeteredConnections){
    "Unhealthy - Currently running on a metered connection."
    $MeteredConnections
} else {
    "Healthy - Not running on metered connection"
}

So this is one that’s good to train your engineers to check if you have a lot of mobile users, it gives you some extra info if a user is on the road or not.

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

Powershell for beginners webinar Part 2

Lots of people joined the webinar, It was super cool and hopefully I taught you all something.

The recording can be found here. I’m still sorting through the Q&A but gonna get a bite to eat first, feel free to add questions as the Q&A is still open, we have a total of 124 questions right now so It’s gonna take me some time.

The scripts can be found at https://github.com/KelvinTegelaar/Webinars.

I’ll be following this up with an intermediate session on a later date. I hope you guys enjoyed it because I really did! 🙂

Monitoring with PowerShell: Monitoring legacy authentication logons

This is going to be the last blog for a couple of weeks, as I’m going to be enjoying some vacation time. I’ll be seeing you all soon! 🙂

So Microsoft has announced a while back that legacy authentication is no longer going to be supported, due to COVID we had some extra time to prepare our clients for this change as its been postponed to July of 2021. We’ve helped all of our clients move to modern authentication last year but I understood there is still a bit of a struggle for other MSPs to achieve this.

With a P1 subscription retrieving the Legacy Authentication reports is very straightforward, but I’ve created these methods for users without P1 in mind. You have to keep in mind that this method is slightly less accurate than using the P1 sign in report.

There’s two scripts in this case; one to detect if Azure Security Defaults are enabled, which disables Legacy Authentication for you. And another to detect if there are apps being used that might use legacy authentication. You’ll still have to do some manual investigation and move users over to newer apps, but it should help you tackle modern auth in no time. 🙂

Detecting if Azure Security Defaults are enabled

Before running this script, you’ll have to add some permissions to your Secure Application Model, do that by performing this:

  • 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 “Policy.read.all”. Click on add permission
  • Do the same for “Delegate Permissions”.
  • Finally, click on “Grant Admin Consent for Company Name.
######### Secrets #########
$ApplicationId = 'AppID'
$ApplicationSecret = 'AppSecret' | ConvertTo-SecureString -Force -AsPlainText
$TenantID = 'Your-TenantID'
$RefreshToken = 'VeryLongRefreshToken'
$UPN = "UPN-Used-To-Generate-Tokens"
######### Secrets #########
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
$SecDefaults = foreach ($Tenant in $Tenants) {
    write-host "Processing $($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)"
    }
    $Enabled = (Invoke-RestMethod -Uri "https://graph.microsoft.com/beta/policies/identitySecurityDefaultsEnforcementPolicy" -Headers $Header -Method get -ContentType "application/json").IsEnabled
    [PSCustomObject]@{
        TenantName                  = $tenant.DisplayName
        'Security Defaults Enabled' = $enabled
    }
}

$SecDefaults

Detecting Legacy Application Usage

So finding legacy application usage is the biggest step in finding out if users are still using Legacy Authentication, for that use the following script:

######### Secrets #########
$ApplicationId = 'AppID'
$ApplicationSecret = 'AppSecret' | ConvertTo-SecureString -Force -AsPlainText
$TenantID = 'Your-TenantID'
$RefreshToken = 'VeryLongRefreshToken'
$UPN = "UPN-Used-To-Generate-Tokens"
######### Secrets #########
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
$LegacyAuth = foreach ($Tenant in $Tenants) {
    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)"
    }
    $VersionReport = (Invoke-RestMethod -Uri "https://graph.microsoft.com/beta/reports/getEmailAppUsageVersionsUserCounts(period='D7')" -Headers $Header -Method get -ContentType "application/json") | ConvertFrom-Csv
    $LegacyClients = if ($versionreport.'Outlook 2007' -or $versionreport.'Outlook 2010' -or $versionreport.'Outlook 2013') {
        $VersionReport
    }
    $AppReports = (Invoke-RestMethod -Uri "https://graph.microsoft.com/beta/reports/getEmailAppUsageAppsUserCounts(period='D7')" -Headers $Header -Method get -ContentType "application/json") | ConvertFrom-Csv

    $LegacyApplications = if ($AppReports.'Other For Mobile' -or $AppReports.'POP3 App' -or $AppReports.'SMTP App' -or $AppReports.'IMAP4 App' -or $AppReports.'Mail For Mac') {
        $AppReports
    }
    $UserDetails = (Invoke-RestMethod -Uri "https://graph.microsoft.com/beta/reports/getEmailAppUsageUserDetail(period='D7')" -Headers $Header -Method get -ContentType "application/json") | ConvertFrom-Csv

    [PSCustomObject]@{
        Tenant        = $tenant.DisplayName
        LegacyClients = $LegacyClients
        LegacyApps    = $LegacyApplications
        UserDetails   = $UserDetails
    }

}

if ($LegacyAuth.LegacyClients -or $LegacyAuth.LegacyApps) {
    write-host "Unhealthy - Clients with legacy authenticaiton or Legacy clients have been detected"
    $LegacyAuth | Where-Object {$_.LegacyClients -ne $null -or $_.LegacyApps -ne $null}
}

And that’s it! It might not be as accurate as the normal P1 report, but it surely helps in capturing Legacy Authentication. As always, Happy PowerShelling!

Monitoring with PowerShell: user experience issues & Unifi EOL Monitoring

This time I’m tackling two blogs in one go again as both are fairly small and straightforward.

Monitoring user Experience

So I’ve been focussed on a lot of documentation, server, and office365 issues lately and I was kind of ‘forgetting’ to also blog about another key experience indicator users have; their own workstation.

Monitoring workstations is becoming more and more important with the flow of users into cloud environments. Just monitoring RAM, CPU, Disk space, etc is no longer really a thing you should focus on. These days you should be looking more into security monitoring and user experience. I’ve blogged about the Windows Experience Index and Diskspeed before as early indicators something might be wrong.

This time we’re going to delve a little deeper into experience monitoring; specifically application crashes and hangs. Windows has an internal monitoring system for this called the “System Reliability Index”. Each time a application hang, or crash occurs this is logged to the index. We can retrieve this information using PowerShell to report on with our RMM.

Windows Reliability script

$ExpectedIndex = "6.0"
$ExpectedTimetoRun = (get-date).AddDays(-1)
$Metrics = Get-CimInstance -ClassName win32_reliabilitystabilitymetrics | Select-Object -First 1
$Records = Get-CimInstance -ClassName win32_reliabilityRecords | Where-Object { $_.TimeGenerated -gt $Metrics.StartMeasurementDate }

$CombinedMetrics = [PSCustomObject]@{
    SystemStabilityIndex = $Metrics.SystemStabilityIndex
    'Start Date'         = $Metrics.StartMeasurementDate
    'End Date'           = $Metrics.EndMeasurementDate
    'Stability Records'  = $Records
}

if ($CombinedMetrics.SystemStabilityIndex -lt $ExpectedIndex) { 
    write-host "The system stability index is higher than expected. This computer might not be performing in an optimal state. The following records have been found:"
    $CombinedMetrics.'Stability Records'
}

if ($CombinedMetrics.'Start Date' -lt $ExpectedTimetoRun) {
    write-host "The system stability index has not been updated since $($CombinedMetrics.'Start Date'). This could indicate an issue with event logging or WMI."
}

So this script actually helps you out in finding where applications are crashing or hanging often. This should help you localize workstations that are performing poorly and are giving a bad user experience. The great thing is that this also monitors those pesky “Outlook is not responding” issues as these are logged too.

Unifi EOL monitoring

So this one was request by a friend that quickly wanted to find all his unsupported devices for replacement. Replace the URL and credentials with your own and you should be good to go. 🙂

##########
#Find EOL devices
$UnifiBaseUri = "https://yoururl:8443/api"
$UnifiUser = "APIUSER"
$UnifiPassword = "appassword"
##############
$UniFiCredentials = @{
    username = $UnifiUser
    password = $UnifiPassword
    remember = $true
} | ConvertTo-Json
 
 
write-host "Logging in to Unifi API." -ForegroundColor Green
try {
    Invoke-RestMethod -Uri "$UnifiBaseUri/login" -Method POST -Body $uniFiCredentials -SessionVariable websession
}
catch {
    write-host "Failed to log in on the Unifi API. Error was: $($_.Exception.Message)" -ForegroundColor Red
}
write-host "Collecting sites from Unifi API." -ForegroundColor Green
try {
    $sites = (Invoke-RestMethod -Uri "$UnifiBaseUri/self/sites" -WebSession $websession).data
}
catch {
    write-host "Failed to collect the sites. Error was: $($_.Exception.Message)" -ForegroundColor Red
}
 
$AllDevices = foreach ($site in $sites) {
    $Devices = (Invoke-RestMethod -Uri "$UnifiBaseUri/s/$($site.name)/stat/device" -WebSession $websession).data
    $Devices | Select-Object _id, name, ip, mac, model, type, version, unsupported,@{label = 'site'; expression = { $site.desc } }
}

$AllDevices | Where-Object { $_.unsupported -ne $false } | Out-GridView

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