Automating with PowerShell: Using the new Autotask REST API

So I’m a bit later than normal with blogging, that’s mostly because I was working on this project a little longer than usual. Autotask recently released update 2020.2 and this update includes a new REST API.

This is super cool, because the old API was a SOAP api and terribly inconvenient to actively use. To help people with using the new Autotask API I’ve created a module. The module is still in alpha/beta but you can download it from the PSGallery now.

The project page is here. Feel free to report any issues or do a pull request if you want to help develop the module! Just be prepared for breakage in the first couple of weeks, I’m still working on finding the best methods ūüôā

 Installation instructions

The module is published to the PSGallery, download it using:

   install-module AutotaskAPI

Usage

To¬†get¬†items¬†using¬†the¬†Autotask¬†API¬†you’ll¬†first¬†have¬†to¬†add¬†the¬†authentication¬†headers¬†using¬†the`¬†Add-AutotaskAPIAuth`¬†function.

$Creds = get-credential    Add-AutotaskAPIAuth -ApiIntegrationcode 'ABCDEFGH00100244MMEEE333 -credentials $Creds

When the command runs, You will be asked for credentials. Using these we will try to decide the correct webservices URL for your zone based on the email address. If this fails you must manually set the webservices URL.

Add-AutotaskBaseURI -BaseURI https://webservices1.autotask.net/atservicesrest

The Base URI value has tab completion to help you find the correct one easily.

To find resources using the API, execute the Get-autotaskAPIResource function. For the Get-AutotaskAPIResource function you will need either the ID of the resource you want to retrieve, or the JSON SearchQuery you want to execute. 

Examples


To find the company with ID 12345

Get-AutotaskAPIResource -Resource Companies -ID 12345

 To get all companies that are Active:

Get-AutotaskAPIResource -Resource Companies -SearchQuery "{filter='active -eq True'}"


To create a new company, we can either make the entire JSON body ourselves, or use the New-AutotaskBody function.

$Body = New-AutotaskBody -Definitions CompanyModel 


 This creates a body for the model Company. Definitions can be tab-completed. The body will contain all expected values. If you want an empty body instead use:

  $Body = New-AutotaskBody -Definitions CompanyModel -NoContent

After setting the values for the body you want, execute: New-AutotaskAPIResource -Resource Companies -Body $body

Contributions

Feel free to send pull requests or fill out issues when you encounter any.

Documenting with PowerShell: Documenting Microsoft Teams

I was thinking of creating an automated teams mapping tool, which runs when a user logs on to a new machine to automatically sync all the Teams sites required and joined. I mostly wanted to do this because the current implementation of the registry/GPO method can take up to 8 hours.

During the creation of my tool I noticed I didn’t have an up to date document for all teams. So I figured I would create one. To do this, I’m using the Secure Application Model as always. The only difference is the method we are using to log onto the Graph API.

Logging onto the Graph API requires a token, normally we generate this token using the PartnerCenter module. This works fine for most tasks, but for using Teams with the Graph API we need a different claim, namely the “Client_Credentials” claim.

So instead of using the partner center module, we will create our own request for a token. I’ll show you how in the script.

I’ve created two versions again, one generic, and one for IT-Glue.

Generic version

The generic version creates 1 html file per team in C:\Temp. Feel free to modify this for your own documentation system. The result will look something like this:

################### Secure Application Model Information ###################
$ApplicationId = 'AppID'
$ApplicationSecret = 'AppSecret' 
$TenantID = 'YourTenantID'
$RefreshToken = 'VeryLongRefreshToken'
################# /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 "Setting HTML Headers" -ForegroundColor Green
$head = @"
<script>
function myFunction() {
    const filter = document.querySelector('#myInput').value.toUpperCase();
    const trs = document.querySelectorAll('table tr:not(.header)');
    trs.forEach(tr => tr.style.display = [...tr.children].find(td => td.innerHTML.toUpperCase().includes(filter)) ? '' : 'none');
  }</script>
<Title>Audit Log Report</Title>
<style>
body { background-color:#E5E4E2;
      font-family:Monospace;
      font-size:10pt; }
td, th { border:0px solid black; 
        border-collapse:collapse;
        white-space:pre; }
th { color:white;
    background-color:black; }
table, tr, td, th {
     padding: 2px; 
     margin: 0px;
     white-space:pre; }
tr:nth-child(odd) {background-color: lightgray}
table { width:95%;margin-left:5px; margin-bottom:20px; }
h2 {
font-family:Tahoma;
color:#6D7B8D;
}
.footer 
{ color:green; 
 margin-left:10px; 
 font-family:Tahoma;
 font-size:8pt;
 font-style:italic;
}
#myInput {
  background-image: url('https://www.w3schools.com/css/searchicon.png'); /* Add a search icon to input */
  background-position: 10px 12px; /* Position the search icon */
  background-repeat: no-repeat; /* Do not repeat the icon image */
  width: 50%; /* Full-width */
  font-size: 16px; /* Increase font-size */
  padding: 12px 20px 12px 40px; /* Add some padding */
  border: 1px solid #ddd; /* Add a grey border */
  margin-bottom: 12px; /* Add some space below the input */
}
</style>
"@



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.accesstoken)" }
    
    write-host "Starting documentation process for $($customer.name)." -ForegroundColor Green
    $headers = @{ "Authorization" = "Bearer $($CustgraphToken.AccessToken)" }
    Write-Host "Grabbing all Teams" -ForegroundColor Green
    $AllTeamsURI = "https://graph.microsoft.com/beta/groups?`$filter=resourceProvisioningOptions/Any(x:x eq 'Team')&`$top=999"
    $Teams = (Invoke-RestMethod -Uri $AllTeamsURI -Headers $Headers -Method Get -ContentType "application/json").value
    Write-Host "Grabbing all Team Settings" -ForegroundColor Green
    $TeamSettings = foreach ($Team in $Teams) {
        $Settings = (Invoke-RestMethod -Uri "https://graph.microsoft.com/beta/Teams/$($team.id)" -Headers $Headers -Method Get -ContentType "application/json")
        $Members = (Invoke-RestMethod -Uri "https://graph.microsoft.com/beta/groups/$($team.id)/members?`$top=999" -Headers $Headers -Method Get -ContentType "application/json").value
        $Owners = (Invoke-RestMethod -Uri "https://graph.microsoft.com/beta/groups/$($team.id)/Owners?`$top=999" -Headers $Headers -Method Get -ContentType "application/json").value

        [PSCustomObject]@{
            'Team Name'          = $settings.displayname
            'Team ID'            = $settings.id
            'Description'        = $Settings.description
            'Teams URL'          = $settings.webUrl
            'Messaging Settings' = $settings.messagingSettings
            'Member Settings'    = $Settings.memberSettings
            'Guest Settings'     = $Settings.guestSettings
            'Fun Settings'       = $settings.funSettings
            'Discovery Settings' = $settings.discoverySettings
            'Is Archived'        = $Settings.isArchived
            'Team Owners'        = $Owners | Select-Object Displayname, UserPrincipalname
            'Team Members'       = $Members | Where-Object { $_.UserType -eq "Member" } | Select-Object Displayname, UserPrincipalname
            'Team Guests'        = $Members | Where-Object { $_.UserType -eq "Guest" } | Select-Object Displayname, UserPrincipalname
        }
        $SettingsHTML = $Settings | ConvertTo-Html -as List - -Fragment -PreContent "<h1>Settings<h2>" | Out-String
        $OwnersHTML = $Owners | Select-Object Displayname, UserPrincipalname | ConvertTo-Html -Fragment -PreContent "<h1>Owners<h2>" | Out-String
        $MembersHTML = $Members | Where-Object { $_.UserType -eq "Member" } | Select-Object Displayname, UserPrincipalname | ConvertTo-Html  -Fragment -PreContent "<h1>Members<h2>" | Out-String
        $GuestsHTML = $Members | Where-Object { $_.UserType -eq "Guest" } | Select-Object Displayname, UserPrincipalname | ConvertTo-Html -Fragment -PreContent "<h1>Guests<h2>" | Out-String
    
        $head, $SettingsHTML, $OwnersHTML, $MembersHTML, $GuestsHTML -replace "<th>", "<th style=`"background-color:#4CAF50`">" | Out-File "C:\Temp\$($Customer.name) - $($Settings.displayname).html"

    }


}

IT-Glue version

So the IT-Glue version is quite a bit longer; I’ve also improved the previous domain matching logic we used to make the script run much faster. This script creates the Flexible Asset for you when running it the first time. We match the teams based on the Team Name. This script can also run from an Azure Function.

################### 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
do {
    $AllITGlueContacts += (Get-ITGlueContacts -page_size 1000 -page_number $i).data.attributes
    $i++
    Write-Host "Retrieved $($AllITGlueContacts.count) Contacts" -ForegroundColor Yellow
}while ($AllITGlueContacts.count % 1000 -eq 0 -and $AllITGlueContacts.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            = "Team Name"
                            kind            = "Text"
                            required        = $true
                            "show-in-list"  = $true
                            "use-for-title" = $true
                        }
                    },
                    @{
                        type       = "flexible_asset_fields"
                        attributes = @{
                            order           = 2
                            name            = "Team URL"
                            kind            = "Text"
                            required        = $true
                            "show-in-list"  = $false
                            "use-for-title" = $false
                        }
                    },
                    @{
                        type       = "flexible_asset_fields"
                        attributes = @{
                            order          = 3
                            name           = "Team Message settings"
                            kind           = "Textbox"
                            required       = $false
                            "show-in-list" = $false
                        }
                    },
                    @{
                        type       = "flexible_asset_fields"
                        attributes = @{
                            order          = 4
                            name           = "Team Member settings"
                            kind           = "Textbox"
                            required       = $false
                            "show-in-list" = $false
                        }
                    },
                    @{
                        type       = "flexible_asset_fields"
                        attributes = @{
                            order          = 5
                            name           = "Team Guest settings"
                            kind           = "Textbox"
                            required       = $false
                            "show-in-list" = $false
                        }
                    },
                    @{
                        type       = "flexible_asset_fields"
                        attributes = @{
                            order          = 6
                            name           = "Team Fun Settings"
                            kind           = "Textbox"
                            required       = $false
                            "show-in-list" = $false
                        }
                    },
                    @{
                        type       = "flexible_asset_fields"
                        attributes = @{
                            order          = 7
                            name           = "Team Owners"
                            kind           = "Textbox"
                            required       = $false
                            "show-in-list" = $false
                        }
                    },
                    @{
                        type       = "flexible_asset_fields"
                        attributes = @{
                            order          = 8
                            name           = "Team Members"
                            kind           = "Textbox"
                            required       = $false
                            "show-in-list" = $false
                        }
                    },                    @{
                        type       = "flexible_asset_fields"
                        attributes = @{
                            order          = 9
                            name           = "Team Guests"
                            kind           = "Textbox"
                            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
    $AllTeamsURI = "https://graph.microsoft.com/beta/groups?`$filter=resourceProvisioningOptions/Any(x:x eq 'Team')&`$top=999"
    $Teams = (Invoke-RestMethod -Uri $AllTeamsURI -Headers $Headers -Method Get -ContentType "application/json").value
    Write-Host "Grabbing all Team Settings" -ForegroundColor Green
    foreach ($Team in $Teams) {
        $Settings = (Invoke-RestMethod -Uri "https://graph.microsoft.com/beta/Teams/$($team.id)" -Headers $Headers -Method Get -ContentType "application/json")
        $Members = (Invoke-RestMethod -Uri "https://graph.microsoft.com/beta/groups/$($team.id)/members?`$top=999" -Headers $Headers -Method Get -ContentType "application/json").value
        $Owners = (Invoke-RestMethod -Uri "https://graph.microsoft.com/beta/groups/$($team.id)/Owners?`$top=999" -Headers $Headers -Method Get -ContentType "application/json").value
 
        $FlexAssetBody =
        @{
            type       = 'flexible-assets'
            attributes = @{
                traits = @{
                    'team-name'             = $settings.displayname
                    'team-url'              = $settings.webUrl
                    'team-message-settings' = ($settings.messagingSettings | convertto-html -Fragment -as list | out-string) -replace $TableStyling
                    'team-member-settings'  = ($Settings.memberSettings | convertto-html -Fragment -as list | out-string) -replace $TableStyling
                    "team-guest-settings"   = ($Settings.guestSettings | convertto-html -Fragment -as list | out-string) -replace $TableStyling
                    "team-fun-settings"     = ($settings.funSettings | convertto-html -Fragment -as list | out-string) -replace $TableStyling
                    'team-owners'           = ($Owners | Select-Object Displayname, UserPrincipalname | convertto-html -Fragment | out-string) -replace $TableStyling
                    'team-members'          = ($Members | Where-Object { $_.UserType -eq "Member" } | Select-Object Displayname, UserPrincipalname | convertto-html -Fragment | out-string) -replace $TableStyling
                    'team-guests'           = ($Members | Where-Object { $_.UserType -eq "Guest" } | Select-Object Displayname, UserPrincipalname | convertto-html -Fragment | out-string) -replace $TableStyling
                }
            }
        }
 
        write-host "   Uploading $($Settings.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.'team-name' -eq $Settings.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
            }
     
        }
    }
}

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

Automating with PowerShell: an Azure DynDNS replacement.

We’ve been using DynDNS Managed DNS for a long time. We use Managed DNS to offer dynamically updating DNS records for clients with either on-site services, or where we believe that dynamic updating of records is needed.

Oracle has bought DynDNS somewhere in 2019 and decided to slowly start killing off the DynDNS Managed DNS services. This has caused us to look for a different solution. Of course, I immediately thought of Azure, with an Azure DNS hosted zone.

The only issue was that I had some constraints;

  • I wanted to be able to keep using the clients we are using, sometimes built into devices such as the Unifi USG Gateway
  • I needed to have the ability to dynamically create records, instead of always having to do this manually.
  • I also hoped for a method that would allow us to authenticate with an API key, instead of a username/password.
  • and of course a business requirement; I needed to get this for the same price or lower than Managed DNS.

After looking into it for some moment, I immediately found the business constraint would be no issue. Azure DNS zones are extremely cheap. Combined with an Azure Function I would be able to fill in all other issues. The total price for our solution is less than 2‚ā¨ a month.(Azure Calculator) That’s a big difference with the 200‚ā¨ a month we paid for Managed DNS.

So, lets start the setup and get our own DynDNS service going!

Setup

First we’ll need a domain name. I’ll leave it up to you where you’ll get the domain. After buying the domain, click on this link to create a new Azure DNS Zone. After creating the zone, point your NS servers to the listed NS servers Azure gives you.

Next, we’ll create our Azure function, you can jump to that by clicking this link. You can follow the default manual for this like with our AzGlue function. There are just some small differences:

  • When asked for the language, choose PowerShell Core 6.
  • After creating the application, click on “Identity” in the left hand menu. Enable the system Identity here.
  • Now click on ‘Configuration” and add a property called APIKey. This will be the key clients will use to update their DNS configuration. Put any string you’d like here.
  • Close this blade, and go to the Azure DNS zone we’ve created, and click on “Security”. Add the Function App as a “DNS Zone Contributor”.
  • Close everything, and return to the Function App. Now click on Functions -> Add -> HTTP Trigger
  • Name your trigger. I’ve called mine “AzDynaDNS”. Select the “Anonymous” Authorization level.
  • When the trigger is deployed, click on “Integration” and then on the “HTTP Trigger”. change the route template to “{*path}” and click save.
  • now click on “Code & Test” and paste in the PowerShell code found below.

This PowerShell code simulates the way the DynDNS client works, this means we can use this in conjunction with routers, or IoT devices that support the DynDNS client.

We’ve made some modifications though; we’re dumping the username, and only using an API key instead. If you do want to use username/password based authentication, or 1 API key per client, feel free to modify the script.

The Script

So the process is pretty straight forward; Whenever the Azure Function API is called using a DynDNS client, the script compares the password if it contains the API key, it then checks if the hostname exists, and creates it if not. It also compares the current IP to the new IP and updates this, if required.

using namespace System.Net
param($Request, $TriggerMetadata)
############### Settings ##############
#Only used if Autodetection fails, e.g. multiple DNS zones, etc.
$ResourceGroup = "YOURRESOURCEGROUPNAME"
$ZoneName = "YOURZONENAME.COM"
############### /Settings ##############
if (!$request.headers.authorization) {
    write-host "No API key"
    Push-OutputBinding -Name Response -Value ([HttpResponseContext]@{
            StatusCode = [HttpStatusCode]::OK
            Body       = "401 - No API token or token is invalid."
        })
    exit
}

$Base64APIKey = $request.headers.authorization -split " " | select-object -last 1
$APIKey = [Text.Encoding]::Utf8.GetString([Convert]::FromBase64String($Base64APIKey)) -split ":" | select-object -last 1

if ($APIKey -ne $env:APIKey) {
    write-host "Invalid API key"
    Push-OutputBinding -Name Response -Value ([HttpResponseContext]@{
            StatusCode = [HttpStatusCode]::OK
            Body       = "401 - No API token or token is invalid."
        })
    exit
}

$AutoDetect = (get-azresource -ResourceType "Microsoft.Network/dnszones")
If ($AutoDetect) { 
    write-host "Using Autodetect."
    $ZoneName = $Autodetect.name
    $ResourceGroup = $AutoDetect.ResourceGroupName
}


$Domain = $request.Query.hostname.split('.') | select-object -first 1
$NewIP = $request.Query.myip
write-host "$domain has $newip. Checking record and creating if required."
$ExistingRecord = Get-AzDnsRecordSet -ResourceGroupName $ResourceGroup -ZoneName $ZoneName -Name $Domain -RecordType A -ErrorAction SilentlyContinue
if (!$ExistingRecord) {
    write-host "Creating new record for $domain"
    New-AzDnsRecordSet -name $domain -Zonename $ZoneName -ResourceGroupName $ResourceGroup -RecordType A -Ttl 60 -DnsRecords (New-AzDnsRecordConfig -Ipv4Address $NewIP)
    Push-OutputBinding -Name Response -Value (  [HttpResponseContext]@{
            StatusCode = [HttpStatusCode]::OK
            Body       = "good $newip"
        })
    exit 
}
else {
    if ($ExistingRecord.Records[-1].Ipv4Address -ne $NewIP) { 
        write-host "Updating record for $domain - new IP is $newIP"
        $ExistingRecord.Records[-1].Ipv4Address = $NewIP
        Set-AzDnsRecordSet -RecordSet $ExistingRecord
        Push-OutputBinding -Name Response -Value ([HttpResponseContext]@{
                StatusCode = [HttpStatusCode]::OK
                Body       = "good $newip"
            })
    }
    else {
        write-host "No Change - $domain IP is still $newIP"
        Push-OutputBinding -Name Response -Value ([HttpResponseContext]@{
                StatusCode = [HttpStatusCode]::OK
                Body       = "nochg $NewIP"
            })
    }
    exit
}

So, now that we’ve done all of this, we can point our DynDNS settings towards the Azure Function, and you should see records getting created and updated:

And that’s it! an easy way to create a faster, and cheaper alternative to Oracles DynDNS Managed DNS service. As always, Happy PowerShelling.

Automating with PowerShell: Storing Office 365 audit logs longer than 90 days

A friend of mine recently bumped into an issue; his client wanted to know when a specific user logged on for the last time. The problem was that he did not have the unified audit log enabled, but even if he did the time-span was too long. We got lucky at using just the mailbox audit log over the past days so he could help his clients. He still worried about the Unified Audit logs and only having 90 days of logging.

I’ve told him about a couple of SIEM products, including Azure Sentinel which is able to ingest logs from Office 365. The problem is that for SIEM tooling you require a lot of a setup and a team to keep everything running smoothly, next to that most SIEMs aren’t really focused on the MSP-workflow. It was a bit overkill for what he wanted too.

So first I helped him setup the unified audit log for all his clients, and afterwards shared a script with him to store the audit logs in a readable format, with both a .CSV file and a HTML representation.

You can run this script in an Azure Runbook, Function, or just scheduled on a secure workstation.

The script downloads the information over the previous 1.2 days. We do this to cover the gap just in case the script runs a bit longer than expected.

The Script

As always, we’re using the Secure Application model. The script creates the folder structure for you.

# Write to the Azure Functions log stream.
Write-Host "Start at $(get-date)"
##########################################
$ApplicationId = 'ApplicationID'
$ApplicationSecret = 'YoursecretySecret' | Convertto-SecureString -AsPlainText -Force
$TenantID = 'YourTenantID'
$RefreshToken = 'verylongtoken'
$ExchangeRefreshToken = 'anotherverylongtoken'
$UPN = "any-valid-partner-upn"
$outputfolder = "D:\home\site\wwwroot\reports"
$ModulePath = "D:\home\site\wwwroot\AuditLogRetrieval\Modules"
##########################################
$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
#Logged in. Moving on to creating folders and getting data.
$folderName = (Get-Date).tostring("yyyy-MM-dd")
New-item -Path $outputfolder -ItemType Directory -Name $folderName -Force
$customers = Get-AzureADContract -All:$true
foreach ($customer in $customers) {
    New-item "$($outputfolder)" -name $Customer.DisplayName -ItemType Directory -Force
    $token = New-PartnerAccessToken -ApplicationId 'a0c73c16-a7e3-4564-9a95-2bdf47383716'-RefreshToken $ExchangeRefreshToken -Scopes 'https://outlook.office365.com/.default' -Tenant $customer.CustomerContextId
    $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
    Import-PSSession $session -allowclobber -DisableNameChecking -CommandName "Search-unifiedAuditLog"

    $startDate = (Get-Date).AddDays( - '1.2')
    $endDate = (Get-Date)
    $Logs = @()
    Write-Host "Retrieving logs for $($customer.displayname)" -ForegroundColor Blue
    do {
        $logs += Search-unifiedAuditLog -SessionCommand ReturnLargeSet -SessionId $customer.displayname -ResultSize 5000 -StartDate $startDate -EndDate $endDate
        Write-Host "Retrieved $($logs.count) logs" -ForegroundColor Yellow
    }while ($Logs.count % 5000 -eq 0 -and $logs.count -ne 0)


    Write-Host "Finished Retrieving logs" -ForegroundColor Green
    $ObjLogs = foreach ($Log in $Logs) {
        $log.auditdata | convertfrom-json
    }


    $PreContent = @"
<H1> $($Customer.DisplayName) - Audit Log from $StartDate until $EndDate </H1><br>
 
<br> Please note that this log is not complete - It is a representation where fields have been selected that are most commonly filtered on. .<br>
To analyze the complete log for this day, please click here for the complete CSV file log: <a href="$($folderName).csv"/>CSV Logbook</a>
<br/>
<br/>
 
<input type="text" id="myInput" onkeyup="myFunction()" placeholder="Search...">
"@
    $head = @"
<script>
function myFunction() {
    const filter = document.querySelector('#myInput').value.toUpperCase();
    const trs = document.querySelectorAll('table tr:not(.header)');
    trs.forEach(tr => tr.style.display = [...tr.children].find(td => td.innerHTML.toUpperCase().includes(filter)) ? '' : 'none');
  }</script>
<Title>Audit Log Report</Title>
<style>
body { background-color:#E5E4E2;
      font-family:Monospace;
      font-size:10pt; }
td, th { border:0px solid black; 
        border-collapse:collapse;
        white-space:pre; }
th { color:white;
    background-color:black; }
table, tr, td, th {
     padding: 2px; 
     margin: 0px;
     white-space:pre; }
tr:nth-child(odd) {background-color: lightgray}
table { width:95%;margin-left:5px; margin-bottom:20px; }
h2 {
font-family:Tahoma;
color:#6D7B8D;
}
.footer 
{ color:green; 
 margin-left:10px; 
 font-family:Tahoma;
 font-size:8pt;
 font-style:italic;
}
#myInput {
  background-image: url('https://www.w3schools.com/css/searchicon.png'); /* Add a search icon to input */
  background-position: 10px 12px; /* Position the search icon */
  background-repeat: no-repeat; /* Do not repeat the icon image */
  width: 50%; /* Full-width */
  font-size: 16px; /* Increase font-size */
  padding: 12px 20px 12px 40px; /* Add some padding */
  border: 1px solid #ddd; /* Add a grey border */
  margin-bottom: 12px; /* Add some space below the input */
}
</style>
"@
    #$ObjLogs
    $Logs | export-csv "$($outputfolder)\$($Customer.DisplayName)\$($FolderName).csv" -NoTypeInformation
    $ObjLogs | Select-object CreationTime, UserID, Operation, ResultStatus, ClientIP, Workload, ClientInfoString | Convertto-html -head $head -PreContent $PreContent | out-file "$($outputfolder)\$($Customer.DisplayName)\$($FolderName).html"
  
    write-host "Generating root HTML file with all dates" -ForegroundColor Green
    $Items = get-childitem "$($outputfolder)\$($Customer.DisplayName)" -Filter "*.html"
    $tableheader = "<table><tr><th>Date</th><th>HTML Report</th><th>CSV Report</th></tr>" 
    $URLS = foreach ($item in $Items) {
        @"
        <tr><td>$($item.basename)</td><td><a href=`"$($item.basename).html`"/>HTML Report</a></td><td><a href=`"$($item.basename).CSV`"/>CSV Report</a></td></tr>
"@
    }

    $Head, $tableheader, $URLS | out-file "$($outputfolder)\$($Customer.DisplayName)\index.html"
}

write-host "Generating index file for all clients." -ForegroundColor Green
$tableheader = "<table><tr><th>Customer Name</th><th>Onmicrosoft domain</th><th>Reports</th></tr>" 
$customerhtml = foreach ($customer in $customers) {
    @"
    <tr><td>$($customer.DisplayName)</td><td>$($customer.DefaultDomainName)</td><td><a href=`"$($customer.DisplayName)\index.html`"/>Reports</a></td></tr>
"@

}

$Head, $tableheader, $customerhtml | out-file "$($outputfolder)\index.html"
write-host "Ended at $(Get-date)"

And that’s it! an easy way to store the log files for longer than 90 days, and not have the investment of a SIEM. I’d still suggest looking into a SIEM for the long run though. As always, Happy PowerShelling!

Automating with PowerShell: Using the Secure Application model updates.

I have a feeling I might be giving people a blogging overdose, but I’ve been playing with so much cool stuff the last couple of days. So lets get the ball rolling; I’ve finally found a method to connect to the SCC succesfully using the Secure Application model.

I’ve also found that non-partners can use the Secure Application Model too, for Exchange and the SCC. This is thanks to some people in the PowerShell Discord that inspired me to get to coding. ūüôā

First off, partners can still use the older blog here to use the secure application model. This script is targeted to Microsoft Partners.

So, what exactly is the Secure Application Model?

The Secure application model is a method of connecting to Office365 services by using oauth instead of a regular username/password combination. By using oauth you use tokens instead. These tokens have a specific life-time and are revoked if they are not used.

The great benefit it gives is that you can run headless scripts, while still having MFA enabled. You won’t need to authenticate with MFA each time the script runs.

Non-Microsoft partners

To get started, you’ll need to give consent to the Exchange Application for your tenant. Microsoft uses the well-known-application-id “a0c73c16-a7e3-4564-9a95-2bdf47383716” for this. You can give consent by executing the following code:

$Exchangetoken = New-PartnerAccessToken -ApplicationId 'a0c73c16-a7e3-4564-9a95-2bdf47383716' -Scopes 'https://outlook.office365.com/.default' -Tenant $TenantID -UseDeviceAuthentication
write-host "Exchange Token: $($ExchangeToken.RefreshToken)"

After this, you should store this token somewhere safe like an Azure Keyvault or password manager. With this token we can start connecting to resources.

Connecting to the Security center can be done with the following code. If you want to connect to both Exchange, and the Security center you will have to use a different token for each, as the token gets invalidated after use by one of the applications.

$ExchangeRefreshToken = 'ExchangeRefreshToken'
$UPN = "Exisiting-UPN-with-Permissions"

$token = New-PartnerAccessToken -ApplicationId 'a0c73c16-a7e3-4564-9a95-2bdf47383716'-RefreshToken $ExchangeRefreshToken -Scopes 'https://outlook.office365.com/.default'
$tokenValue = ConvertTo-SecureString "Bearer $($token.AccessToken)" -AsPlainText -Force
$credential = New-Object System.Management.Automation.PSCredential($upn, $tokenValue)

$SccSession = New-PSSession -ConfigurationName Microsoft.Exchange -ConnectionUri "https://ps.compliance.protection.outlook.com/powershell-liveid?BasicAuthToOAuthConversion=true&DelegatedOrg=$($customerId)" -Credential $credential -AllowRedirection -Authentication Basic
import-session $SccSession -disablenamechecking -allowclobber

To connect to Exchange, you can use this code.

$ExchangeRefreshToken = 'ExchangeRefreshToken'
$UPN = "Exisiting-UPN-with-Partner-Permissions"

$token = New-PartnerAccessToken -ApplicationId 'a0c73c16-a7e3-4564-9a95-2bdf47383716'-RefreshToken $Exchangetoken -Scopes 'https://outlook.office365.com/.default'
$tokenValue = ConvertTo-SecureString "Bearer $($token.AccessToken)" -AsPlainText -Force
$credential = New-Object System.Management.Automation.PSCredential($upn, $tokenValue)

$session = New-PSSession -ConfigurationName Microsoft.Exchange -ConnectionUri "https://ps.outlook.com/powershell-liveid?BasicAuthToOAuthConversion=true" -Credential $credential -Authentication Basic -AllowRedirection
Import-PSSession $session -AllowClobber -DisableNameChecking

And that’s it for non partners. With this you can run unattended, headless scripts for O365 as a non-microsoft partner. You will need to install the PartnerCenter module to create the authentication tokens.

Partner Methods

After you collect your tokens, you can use this method to connect to the Security Center using the Secure Application Model for partners. This allows your to connect to each tenant under your administration.

$ApplicationId = 'YourApplicationID'
$ApplicationSecret = 'ApplicationSecret' | Convertto-SecureString -AsPlainText -Force
$TenantID = 'Your-tenantID'
$RefreshToken = 'RefreshToken'
$ExchangeRefreshToken = 'ExchangeRefreshToken'
$UPN = "Exisiting-UPN-with-Partner-Permissions"

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

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

Connect-MsolService -AdGraphAccessToken $aadGraphToken.AccessToken -MsGraphAccessToken $graphToken.AccessToken
$customers = Get-MsolPartnerContract -All
foreach($customer in $customers){
$customerId = $customer.DefaultDomainName
$token = New-PartnerAccessToken -ApplicationId 'a0c73c16-a7e3-4564-9a95-2bdf47383716'-RefreshToken $ExchangeRefreshToken -Scopes 'https://outlook.office365.com/.default'
$tokenValue = ConvertTo-SecureString "Bearer $($token.AccessToken)" -AsPlainText -Force
$credential = New-Object System.Management.Automation.PSCredential($upn, $tokenValue)
$SccSession = New-PSSession -ConfigurationName Microsoft.Exchange -ConnectionUri "https://ps.compliance.protection.outlook.com/powershell-liveid?BasicAuthToOAuthConversion=true&DelegatedOrg=$($customerId)" -Credential $credential -AllowRedirection -Authentication Basic
import-pssession $SccSession -disablenamechecking -allowclobber
#YourCommands here

#/End of Commands

}

The rest of the partner methods remain the same, unless you want to connect to both the Security center and Exchange at the same time, then use the following code.

$ApplicationId = 'YourApplicationID'
$ApplicationSecret = 'ApplicationSecret' | Convertto-SecureString -AsPlainText -Force
$TenantID = 'Your-tenantID'
$RefreshToken = 'RefreshToken'
$ExchangeRefreshToken = 'ExchangeRefreshToken'
$UPN = "Exisiting-UPN-with-Partner-Permissions"

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

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

Connect-MsolService -AdGraphAccessToken $aadGraphToken.AccessToken -MsGraphAccessToken $graphToken.AccessToken
$customers = Get-MsolPartnerContract -All
foreach ($customer in $customers) {

    $customerId = $customer.DefaultDomainName

    write-host "Connecting to the Security Center for client $($customer.name)"
    $SCCToken = New-PartnerAccessToken -ApplicationId 'a0c73c16-a7e3-4564-9a95-2bdf47383716'-RefreshToken $ExchangeRefreshToken -Scopes 'https://outlook.office365.com/.default'
    $SCCTokenValue = ConvertTo-SecureString "Bearer $($SCCToken.AccessToken)" -AsPlainText -Force
    $SCCcredential = New-Object System.Management.Automation.PSCredential($upn, $SCCTokenValue)
    $SccSession = New-PSSession -ConfigurationName Microsoft.Exchange -ConnectionUri "https://ps.compliance.protection.outlook.com/powershell-liveid?BasicAuthToOAuthConversion=true&DelegatedOrg=$($customerId)" -Credential $SCCcredential -AllowRedirection -Authentication Basic
    import-session $SccSession -disablenamechecking -allowclobber
    #YourCommands here

    #/End of Commands

    Remove-session $SccSession
    write-host "Connecting to the Exchange managed console for client $($customer.name)"

    Write-host "Enabling all settings for $($Customer.Name)" -ForegroundColor Green
    $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
    $credentialExchange = New-Object System.Management.Automation.PSCredential($upn, $tokenValue)

    $ExchangeOnlineSession = New-PSSession -ConfigurationName Microsoft.Exchange -ConnectionUri "https://ps.outlook.com/powershell-liveid?DelegatedOrg=$($customerId)&BasicAuthToOAuthConversion=true" -Credential $credentialExchange -Authentication Basic -AllowRedirection -erroraction Stop
    Import-PSSession -Session $ExchangeOnlineSession -AllowClobber -DisableNameChecking
    #YourCommands here

    #/End of Commands
    Remove-PSSession $ExchangeOnlineSession
}

And that’s it! I hope that helps partners and non-partners alike. As always, Happy PowerShelling. ūüôā

Automating with PowerShell: Creating dynamic distribution groups in all O365 tenants

Someone on the /r/msp reddit, and a Slack that I frequent asked if it is possible to create an “all users” distribution group with PowerShell, and keep it up to date. I figured to spend some time on it.

To achieve a list that is always up to date, we can use Dynamic Distribution groups. Dynamic groups allow you to add specific users based on their properties. for example; if a user has a full mailbox, it will be added. You can make these queries as complex as you want.

Our script uses the Secure Application Model to create a couple of new dynamic distribution groups; a single one that contains all users called “AllCompanyUsers” and one per domain that the client has. The domain group only contains people that use that domain’s email address. For example; Cybedrain.com@cyberdrain.com contains John, Hank, and Bob. It does not contain Janet because she uses @Janetdrain.com as an e-mail.

The script

$ApplicationId = 'APPLICATIONID'
$ApplicationSecret = 'APPLICATIONSECRET' | Convertto-SecureString -AsPlainText -Force
$TenantID = 'YOUURTENANTID'
$RefreshToken = 'Freakishly long refreshtoken'
$ExchangeRefreshToken = 'Freakishly long refresh'
$UPN = "UPN-Of-User-Generating-Tokens"

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

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

Connect-MsolService -AdGraphAccessToken $aadGraphToken.AccessToken -MsGraphAccessToken $graphToken.AccessToken
$customers = Get-MsolPartnerContract -All
foreach ($customer in $customers) {
    $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
    Import-PSSession $session -AllowClobber -DisableNameChecking -CommandName "Get-DynamicDistributionGroup","New-DynamicDistributionGroup","Set-DynamicDistributionGroup"
    write-host "Logged into tenant $($customer.defaultdomainname)" -ForegroundColor Green
    write-host "Checking if all users list exist, if not, creating them." -ForegroundColor Green
    $ExisitingAllUserList = Get-DynamicDistributionGroup -anr "AllCompanyUsers"

    if (!$ExisitingAllUserList) { 
        write-host "Creating AllCompanyUsers group" -ForegroundColor Green
        New-DynamicDistributionGroup -Name "AllCompanyUsers" -RecipientFilter "(RecipientType -eq 'UserMailbox')" | out-null
        Get-DynamicDistributionGroup -anr "AllCompanyUsers" | Set-DynamicDistributionGroup -RequireSenderAuthenticationEnabled $False -HiddenFromAddressListsEnabled $true | out-null
    }
    
    write-host "Checking all domains, and creating a list for each domain."  -ForegroundColor Green
    $Domains = Get-MsolDomain -TenantId $customer.TenantId
    foreach ($Domain in $domains.name) {
        write-host "Checking domain $($Domain) creating if it does not exist."  -ForegroundColor Green
        $ExisitingDomainList = Get-DynamicDistributionGroup -anr $Domain
        if (!$ExisitingDomainList) {
            write-host "   Creating $domain list"  -ForegroundColor Green
            New-DynamicDistributionGroup -Name "$domain" -RecipientFilter "(RecipientType -eq 'UserMailbox') -and (EmailAddresses -like '$Domain')" | out-null
            Get-DynamicDistributionGroup -anr "$Domain" | Set-DynamicDistributionGroup -RequireSenderAuthenticationEnabled $False -HiddenFromAddressListsEnabled $true | out-null
        }
    }
    Remove-PSSession $session
}

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

Documenting with PowerShell: creating a device audit log

My engineers sometimes have questions about past events on a specific machine, to which I often point them towards our RMM system. The RMM system contains a bulk of data that can be used to make analysis.

The problem is just that – It’s unedited bulk data, often with different locations for different pieces of information.

So to assist them in gathering this info quicker I’ve decided to create an IT-Glue Automatic documentation for a device audit log of sorts. This audit log can be reviewed to check information easier and quicker than in the RMM system. It also contains more legacy information. e.g. “Who logged onto this computer last year”.

As always, I’ve created two scripts. One for IT-Glue users, the other for generic documentation systems. Both scripts collect the following info:

  • All user profiles on the computer
  • Installed updates
  • Installed software
  • Events such as the first time a user logs on, software installations/removal, kb installations and if the WAN IP has changed and to what. You can easily add new events if you’d like.

IT-Glue version

The IT-Glue version grabs the current asset from IT-Glue and tags this. That way you can find the log attached to the correct device. Cool huh!? It also creates the flexible asset for you, if it does not exist.

###############
$ITGkey = "ITGLUEAPIKEY"
$ITGbaseURI = "https://api.eu.itglue.com"
$FlexAssetName = "Device logbook - Autodoc"
$ITGlueOrgID = "ORGID"
$Description = "A logbook for each device that contains information about the last logged on users, dates of software installation."
$TableStyling = "<th>", "<th style=`"background-color:#4CAF50`">"
##############
 

#Settings IT-Glue logon information
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 $ITGbaseURI
Add-ITGlueAPIKey $ITGkey
 
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            = "Device Name"
                            kind            = "Text"
                            required        = $true
                            "show-in-list"  = $true
                            "use-for-title" = $true
                        }
                    },
                    @{
                        type       = "flexible_asset_fields"
                        attributes = @{
                            order          = 2
                            name           = "Events"
                            kind           = "Textbox"
                            required       = $false
                            "show-in-list" = $false
                        }
                    },
                    @{
                        type       = "flexible_asset_fields"
                        attributes = @{
                            order          = 3
                            name           = "User Profiles"
                            kind           = "Textbox"
                            required       = $false
                            "show-in-list" = $false
                        }
                    },
                    @{
                        type       = "flexible_asset_fields"
                        attributes = @{
                            order          = 4
                            name           = "Installed Updates"
                            kind           = "Textbox"
                            required       = $false
                            "show-in-list" = $false
                        }
                    },
                    @{
                        type       = "flexible_asset_fields"
                        attributes = @{
                            order          = 5
                            name           = "Installed Software"
                            kind           = "Textbox"
                            required       = $false
                            "show-in-list" = $false
                        }
                    },
                    @{
                        type       = "flexible_asset_fields"
                        attributes = @{
                            order          = 6
                            name           = "Device"
                            kind           = "Tag"
                            "tag-type"     = 'Configurations'
                            required       = $false
                            "show-in-list" = $false
                        }
                    }
                )
            }
        }
    }
    New-ITGlueFlexibleAssetTypes -Data $NewFlexAssetData
    $FilterID = (Get-ITGlueFlexibleAssetTypes -filter_name $FlexAssetName).data
}


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

write-host "Getting update history." -foregroundColor green
$date = Get-Date 
$hotfixesInstalled = get-hotfix

write-host "Getting User Profiles." -foregroundColor green

$UsersProfiles = Get-CimInstance win32_userprofile | Where-Object { $_.special -eq $false } | select-object localpath, LastUseTime, Username
$UsersProfiles = foreach ($Profile in $UsersProfiles) {
    $profile.username = ($profile.localpath -split '\', -1, 'simplematch') | Select-Object -Last 1
    $Profile
}
write-host "Getting Installed applications." -foregroundColor green

$InstalledSoftware = (Get-ChildItem "HKLM:\Software\Microsoft\Windows\CurrentVersion\Uninstall" | Get-ItemProperty) + ($software += Get-ChildItem "HKLM:\Software\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall" | Get-ItemProperty) | Select-Object Displayname, Publisher, Displayversion, InstallLocation, InstallDate
$installedSoftware = foreach ($Application in $installedSoftware) {
    if ($null -eq $application.InstallLocation) { continue }
    if ($null -eq $Application.InstallDate) { $application.installdate = (get-item $application.InstallLocation -ErrorAction SilentlyContinue).CreationTime.ToString('yyyyMMdd')  }
    $Application.InstallDate = [datetime]::parseexact($Application.InstallDate, 'yyyyMMdd', $null).ToString('yyyy-MM-dd HH:mm') 
    $application
}


write-host "Checking WAN IP" -foregroundColor green
$events = @()
$previousIP = get-content "$($env:ProgramData)/LastIP.txt" -ErrorAction SilentlyContinue | Select-Object -first 1
if (!$previousIP) { Write-Host "No previous IP found. Compare will fail." }
$Currentip = (Invoke-RestMethod -Uri "https://ipinfo.io/ip") -replace "`n", ""
$Currentip | out-file "$($env:ProgramData)/LastIP.txt" -Force

if ($Currentip -ne $previousIP) {
    $Events += [pscustomobject]@{
        date  = $date.ToString('yyyy-MM-dd HH:mm') 
        Event = "WAN IP has changed from $PreviousIP to $CurrentIP"
        type  = "WAN Event"
    }
}
write-host "Getting Installed applications in last 24 hours for events list" -foregroundColor green
$InstalledInLast24Hours = $installedsoftware | where-object { $_.installDate -ge $date.addhours(-24).tostring('yyyy-MM-dd') }
foreach ($installation in $InstalledInLast24Hours) {
    $Events += [pscustomobject]@{
        date  = $installation.InstallDate
        Event = "New Software: $($Installation.displayname) has been installed or updated."
        type  = "Software Event"
    }
}
write-host "Getting KBs in last 24 hours for events list" -foregroundColor green
$hotfixesInstalled = get-hotfix | where-object { $_.InstalledOn -ge $date.adddays(-2) }
foreach ($InstalledHotfix in $hotfixesInstalled) {
    $Events += [pscustomobject]@{
        date  = $InstalledHotfix.installedOn.tostring('yyyy-MM-dd HH:mm') 
        Event = "Update $($InstalledHotfix.Hotfixid) has been installed."
        type  = "Update Event"
    }

}
write-host "Getting user logon/logoff events of last 24 hours." -foregroundColor green
$UserProfilesDir = get-childitem "C:\Users"
foreach ($Users in $UserProfilesDir) {
    if ($users.CreationTime -gt $date.AddDays(-1)) { 
        $Events += [pscustomobject]@{
            date  = $users.CreationTime.tostring('yyyy-MM-dd HH:mm') 
            Event = "First time logon: $($Users.name) has logged on for the first time."
            type  = "User event"
        }
    }
    $NTUser = get-item "$($users.FullName)\NTUser.dat" -force -ErrorAction SilentlyContinue
    if ($NTUser.LastWriteTime -gt $date.AddDays(-1)) {
        $Events += [pscustomobject]@{
            date  = $NTUser.LastWriteTime.tostring('yyyy-MM-dd HH:mm') 
            Event = "Logoff: $($Users.name) has logged off or restarted the computer."
            type  = "User event"
        }
    }
    if ($NTUser.LastAccessTime -gt $date.AddDays(-1)) {
        $Events += [pscustomobject]@{
            date  = $NTUser.LastAccessTime.tostring('yyyy-MM-dd HH:mm') 
            Event = "Logon: $($Users.name) has logged on."
            type  = "User event"
                
        }
    }
}
$events = $events | Sort-Object -Property date -Descending
$TaggedResource = (Get-ITGlueConfigurations -organization_id $ITGlueOrgID -filter_serial_number (get-ciminstance win32_bios).serialnumber).data
$eventshtml = ($Events | convertto-html -fragment | out-string) -replace $TableStyling
$ProfilesHTML = ($UsersProfiles | convertto-html -Fragment | out-string)  -replace $TableStyling
$updatesHTML = ($hotfixesInstalled | select-object InstalledOn, Hotfixid, caption, InstalledBy  | convertto-html -Fragment | out-string) -replace $TableStyling
$SoftwareHTML = ($installedSoftware | convertto-html -Fragment | out-string) -replace $TableStyling

write-host "Uploading to IT-Glue." -foregroundColor green
$FlexAssetBody = @{
    type       = 'flexible-assets'
    attributes = @{
        traits = @{
            'device-name'        = $env:computername
            'events'             = $eventshtml
            'user-profiles'      = $ProfilesHTML
            'installed-updates'  = $UpdatesHTML
            'installed-software' = $SoftwareHTML
            'device'             = $TaggedResource.id
        }
    }
}
write-host "Documenting to IT-Glue"  -ForegroundColor Green
$ExistingFlexAsset = (Get-ITGlueFlexibleAssets -filter_flexible_asset_type_id $($filterID.ID) -filter_organization_id $ITGlueOrgID).data | Where-Object { $_.attributes.traits.'device-name' -eq $env:computername }

#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', $ITGlueOrgID)
    $FlexAssetBody.attributes.add('flexible-asset-type-id', $($filterID.ID))
    write-host "  Creating Device Asset Log into IT-Glue organisation $ITGlueOrgID" -ForegroundColor Green
    New-ITGlueFlexibleAssets -data $FlexAssetBody
}
else {
    write-host "  Editing Device Asset Log into IT-Glue organisation $ITGlueOrgID"  -ForegroundColor Green
    $ExistingFlexAsset = $ExistingFlexAsset | select-object -last 1
    write-host "  Adding previous events from asset to current one."  -ForegroundColor Green
    $CombinedList =  "$($Eventshtml)`n$($ExistingFlexAsset.attributes.traits.events)" -split "`n" | Select-Object -Unique | Out-String
    $FlexAssetBody.attributes.traits.events = $CombinedList
    Set-ITGlueFlexibleAssets -id $ExistingFlexAsset.id -data $FlexAssetBody
}
 

Generic version

The generic version collects the same info, but outputs this to C:\Temp\Auditoutput.html.

###############
$TableStyling = "<th>", "<th style=`"background-color:#4CAF50`">"
##############
 

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

write-host "Getting update history." -foregroundColor green
$date = Get-Date 
$hotfixesInstalled = get-hotfix

write-host "Getting User Profiles." -foregroundColor green

$UsersProfiles = Get-CimInstance win32_userprofile | Where-Object { $_.special -eq $false } | select-object localpath, LastUseTime, Username
$UsersProfiles = foreach ($Profile in $UsersProfiles) {
    $profile.username = ($profile.localpath -split '\', -1, 'simplematch') | Select-Object -Last 1
    $Profile
}
write-host "Getting Installed applications." -foregroundColor green

$InstalledSoftware = (Get-ChildItem "HKLM:\Software\Microsoft\Windows\CurrentVersion\Uninstall" | Get-ItemProperty) + ($software += Get-ChildItem "HKLM:\Software\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall" | Get-ItemProperty) | Select-Object Displayname, Publisher, Displayversion, InstallLocation, InstallDate
$installedSoftware = foreach ($Application in $installedSoftware) {
    if ($null -eq $application.InstallLocation) { continue }
    if ($null -eq $Application.InstallDate) { $application.installdate = (get-item $application.InstallLocation -ErrorAction SilentlyContinue).CreationTime.ToString('yyyyMMdd')  }
    $Application.InstallDate = [datetime]::parseexact($Application.InstallDate, 'yyyyMMdd', $null).ToString('yyyy-MM-dd HH:mm') 
    $application
}


write-host "Checking WAN IP" -foregroundColor green
$events = @()
$previousIP = get-content "$($env:ProgramData)/LastIP.txt" -ErrorAction SilentlyContinue | Select-Object -first 1
if (!$previousIP) { Write-Host "No previous IP found. Compare will fail." }
$Currentip = (Invoke-RestMethod -Uri "https://ipinfo.io/ip") -replace "`n", ""
$Currentip | out-file "$($env:ProgramData)/LastIP.txt" -Force

if ($Currentip -ne $previousIP) {
    $Events += [pscustomobject]@{
        date  = $date.ToString('yyyy-MM-dd HH:mm') 
        Event = "WAN IP has changed from $PreviousIP to $CurrentIP"
        type  = "WAN Event"
    }
}
write-host "Getting Installed applications in last 24 hours for events list" -foregroundColor green
$InstalledInLast24Hours = $installedsoftware | where-object { $_.installDate -ge $date.addhours(-24).tostring('yyyy-MM-dd') }
foreach ($installation in $InstalledInLast24Hours) {
    $Events += [pscustomobject]@{
        date  = $installation.InstallDate
        Event = "New Software: $($Installation.displayname) has been installed or updated."
        type  = "Software Event"
    }
}
write-host "Getting KBs in last 24 hours for events list" -foregroundColor green
$hotfixesInstalled = get-hotfix | where-object { $_.InstalledOn -ge $date.adddays(-2) }
foreach ($InstalledHotfix in $hotfixesInstalled) {
    $Events += [pscustomobject]@{
        date  = $InstalledHotfix.installedOn.tostring('yyyy-MM-dd HH:mm') 
        Event = "Update $($InstalledHotfix.Hotfixid) has been installed."
        type  = "Update Event"
    }

}
write-host "Getting user logon/logoff events of last 24 hours." -foregroundColor green
$UserProfilesDir = get-childitem "C:\Users"
foreach ($Users in $UserProfilesDir) {
    if ($users.CreationTime -gt $date.AddDays(-1)) { 
        $Events += [pscustomobject]@{
            date  = $users.CreationTime.tostring('yyyy-MM-dd HH:mm') 
            Event = "First time logon: $($Users.name) has logged on for the first time."
            type  = "User event"
        }
    }
    $NTUser = get-item "$($users.FullName)\NTUser.dat" -force -ErrorAction SilentlyContinue
    if ($NTUser.LastWriteTime -gt $date.AddDays(-1)) {
        $Events += [pscustomobject]@{
            date  = $NTUser.LastWriteTime.tostring('yyyy-MM-dd HH:mm') 
            Event = "Logoff: $($Users.name) has logged off or restarted the computer."
            type  = "User event"
        }
    }
    if ($NTUser.LastAccessTime -gt $date.AddDays(-1)) {
        $Events += [pscustomobject]@{
            date  = $NTUser.LastAccessTime.tostring('yyyy-MM-dd HH:mm') 
            Event = "Logon: $($Users.name) has logged on."
            type  = "User event"
                
        }
    }
}
$head = @"
<script>
function myFunction() {
    const filter = document.querySelector('#myInput').value.toUpperCase();
    const trs = document.querySelectorAll('table tr:not(.header)');
    trs.forEach(tr => tr.style.display = [...tr.children].find(td => td.innerHTML.toUpperCase().includes(filter)) ? '' : 'none');
  }</script>
<Title>Audit Log Report</Title>
<style>
body { background-color:#E5E4E2;
      font-family:Monospace;
      font-size:10pt; }
td, th { border:0px solid black; 
        border-collapse:collapse;
        white-space:pre; }
th { color:white;
    background-color:black; }
table, tr, td, th {
     padding: 2px; 
     margin: 0px;
     white-space:pre; }
tr:nth-child(odd) {background-color: lightgray}
table { width:95%;margin-left:5px; margin-bottom:20px; }
h2 {
font-family:Tahoma;
color:#6D7B8D;
}
.footer 
{ color:green; 
 margin-left:10px; 
 font-family:Tahoma;
 font-size:8pt;
 font-style:italic;
}
#myInput {
  background-image: url('https://www.w3schools.com/css/searchicon.png'); /* Add a search icon to input */
  background-position: 10px 12px; /* Position the search icon */
  background-repeat: no-repeat; /* Do not repeat the icon image */
  width: 50%; /* Full-width */
  font-size: 16px; /* Increase font-size */
  padding: 12px 20px 12px 40px; /* Add some padding */
  border: 1px solid #ddd; /* Add a grey border */
  margin-bottom: 12px; /* Add some space below the input */
}
</style>
"@


$events = $events | Sort-Object -Property date -Descending

$eventshtml = ($Events | convertto-html -fragment | out-string) -replace $TableStyling
$ProfilesHTML = ($UsersProfiles | convertto-html -Fragment | out-string)  -replace $TableStyling
$updatesHTML = ($hotfixesInstalled | select-object InstalledOn, Hotfixid, caption, InstalledBy  | convertto-html -Fragment | out-string) -replace $TableStyling
$SoftwareHTML = ($installedSoftware | convertto-html -Fragment | out-string) -replace $TableStyling


$head,$eventshtml,$ProfilesHTML,$updatesHTML,$SoftwareHTML | out-file "C:\Temp\Auditoutput.html"

And that’s it! as always, Happy PowerShelling! ūüôā

Documenting with PowerShell: Syncing Unifi devices to IT-Glue

This blog should be used in together with my previous blog about Unifi documentation. This script syncs all devices to IT-Glue and makes sure the configurations are in sync with eachother.

It will overwrite any changes you’ve made to the configurations, so treat with care.

###############
$ITGkey = "ITGlueKey"
$ITGbaseURI = "https://api.eu.itglue.com"
$UnifiBaseUri = "https://Controller.yourdomain.com:8443/api"
$UnifiUser = "APIUSER"
$UnifiPassword = "APIPASSWORD"
##############

#Settings IT-Glue logon information
If (Get-Module -ListAvailable -Name "ITGlueAPI") { 
    Import-module ITGlueAPI 
}
Else { 
    Install-Module ITGlueAPI -Force
    Import-Module ITGlueAPI
}
Add-ITGlueBaseURI -base_uri $ITGbaseURI
Add-ITGlueAPIKey $ITGkey
write-host "Checking if products exist in IT-Glue, and if not creating them."
$unifiAllModels = @"
[{"c":"BZ2","t":"uap","n":"UniFi AP"},{"c":"BZ2LR","t":"uap","n":"UniFi AP-LR"},{"c":"U2HSR","t":"uap","n":"UniFi AP-Outdoor+"},
{"c":"U2IW","t":"uap","n":"UniFi AP-In Wall"},{"c":"U2L48","t":"uap","n":"UniFi AP-LR"},{"c":"U2Lv2","t":"uap","n":"UniFi AP-LR v2"},
{"c":"U2M","t":"uap","n":"UniFi AP-Mini"},{"c":"U2O","t":"uap","n":"UniFi AP-Outdoor"},{"c":"U2S48","t":"uap","n":"UniFi AP"},
{"c":"U2Sv2","t":"uap","n":"UniFi AP v2"},{"c":"U5O","t":"uap","n":"UniFi AP-Outdoor 5G"},{"c":"U7E","t":"uap","n":"UniFi AP-AC"},
{"c":"U7EDU","t":"uap","n":"UniFi AP-AC-EDU"},{"c":"U7Ev2","t":"uap","n":"UniFi AP-AC v2"},{"c":"U7HD","t":"uap","n":"UniFi AP-HD"},
{"c":"U7SHD","t":"uap","n":"UniFi AP-SHD"},{"c":"U7NHD","t":"uap","n":"UniFi AP-nanoHD"},{"c":"UCXG","t":"uap","n":"UniFi AP-XG"},
{"c":"UXSDM","t":"uap","n":"UniFi AP-BaseStationXG"},{"c":"UCMSH","t":"uap","n":"UniFi AP-MeshXG"},{"c":"U7IW","t":"uap","n":"UniFi AP-AC-In Wall"},
{"c":"U7IWP","t":"uap","n":"UniFi AP-AC-In Wall Pro"},{"c":"U7MP","t":"uap","n":"UniFi AP-AC-Mesh-Pro"},{"c":"U7LR","t":"uap","n":"UniFi AP-AC-LR"},
{"c":"U7LT","t":"uap","n":"UniFi AP-AC-Lite"},{"c":"U7O","t":"uap","n":"UniFi AP-AC Outdoor"},{"c":"U7P","t":"uap","n":"UniFi AP-Pro"},
{"c":"U7MSH","t":"uap","n":"UniFi AP-AC-Mesh"},{"c":"U7PG2","t":"uap","n":"UniFi AP-AC-Pro"},{"c":"p2N","t":"uap","n":"PicoStation M2"},
{"c":"US8","t":"usw","n":"UniFi Switch 8"},{"c":"US8P60","t":"usw","n":"UniFi Switch 8 POE-60W"},{"c":"US8P150","t":"usw","n":"UniFi Switch 8 POE-150W"},
{"c":"S28150","t":"usw","n":"UniFi Switch 8 AT-150W"},{"c":"USC8","t":"usw","n":"UniFi Switch 8"},{"c":"US16P150","t":"usw","n":"UniFi Switch 16 POE-150W"},
{"c":"S216150","t":"usw","n":"UniFi Switch 16 AT-150W"},{"c":"US24","t":"usw","n":"UniFi Switch 24"},{"c":"US24P250","t":"usw","n":"UniFi Switch 24 POE-250W"},
{"c":"US24PL2","t":"usw","n":"UniFi Switch 24 L2 POE"},{"c":"US24P500","t":"usw","n":"UniFi Switch 24 POE-500W"},{"c":"S224250","t":"usw","n":"UniFi Switch 24 AT-250W"},
{"c":"S224500","t":"usw","n":"UniFi Switch 24 AT-500W"},{"c":"US48","t":"usw","n":"UniFi Switch 48"},{"c":"US48P500","t":"usw","n":"UniFi Switch 48 POE-500W"},
{"c":"US48PL2","t":"usw","n":"UniFi Switch 48 L2 POE"},{"c":"US48P750","t":"usw","n":"UniFi Switch 48 POE-750W"},{"c":"S248500","t":"usw","n":"UniFi Switch 48 AT-500W"},
{"c":"S248750","t":"usw","n":"UniFi Switch 48 AT-750W"},{"c":"US6XG150","t":"usw","n":"UniFi Switch 6XG POE-150W"},{"c":"USXG","t":"usw","n":"UniFi Switch 16XG"},
{"c":"UGW3","t":"ugw","n":"UniFi Security Gateway 3P"},{"c":"UGW4","t":"ugw","n":"UniFi Security Gateway 4P"},{"c":"UGWHD4","t":"ugw","n":"UniFi Security Gateway HD"},
{"c":"UGWXG","t":"ugw","n":"UniFi Security Gateway XG-8"},{"c":"UP4","t":"uph","n":"UniFi Phone-X"},{"c":"UP5","t":"uph","n":"UniFi Phone"},
{"c":"UP5t","t":"uph","n":"UniFi Phone-Pro"},{"c":"UP7","t":"uph","n":"UniFi Phone-Executive"},{"c":"UP5c","t":"uph","n":"UniFi Phone"},
{"c":"UP5tc","t":"uph","n":"UniFi Phone-Pro"},{"c":"UP7c","t":"uph","n":"UniFi Phone-Executive"}]
"@
 
$configTypes = @"
[{"t":"uap","n":"Unifi AP"},{"t":"usw","n":"Unifi Switch"},{"t":"ugw","n":"Unifi Gateway"},{"t":"uph","n":"Unifi VOIP"}]
"@ | ConvertFrom-Json

$unifiAllModels = $unifiAllModels | ConvertFrom-Json
$unifiModels = $unifiAllModels | Sort-Object n -Unique
$ITGConfigTypes = (Get-ITGlueConfigurationTypes).data
write-host "Check Config Types and creating if required" -ForegroundColor Green
foreach ($ConfType in $configTypes) {
    if ($ConfType.n -notin $ITGConfigTypes.attributes.name) {
        write-host "Creating $($Model.n)" -ForegroundColor Green
        New-ITGlueConfigurationTypes -data @{
            type       = 'configuration-types'
            attributes = @{
                name = $ConfType.n
            }
        }

    }
}
$ExistingModels = (Get-ITGlueModels -page_size 1000).data
write-host "Checkings manufacture and creating if required" -ForegroundColor Green
$Manafacture = (Get-ITGlueManufacturers -filter_name "UniFi").data
if (!$Manafacture) {
    New-ITGlueManufacturers -data @{
        type       = 'manufacturers'
        attributes = @{
            name = 'uniFi-2'
        }
    }
    $Manafacture = (Get-ITGlueManufacturers -filter_name "UniFi").data
}
write-host "Grabbing active status"
$ConfigurationStatusId = (Get-ITGlueConfigurationStatuses -filter_name 'Active').data.ID | Select-Object -Last 1
write-host "Checkings models and creating if required" -ForegroundColor Green
foreach ($Model in $unifiAllModels) {
    if ($model.n -notin $ExistingModels.attributes.name) {
        write-host "Creating $($Model.n)" -ForegroundColor Green
        New-ITGlueModels -data @{
            type       = 'models'
            attributes = @{
                'manufacturer-id' = $Manafacture.id
                name              = $model.n
            }
        }

    }
}

write-host "Start configuration syncing process." -foregroundColor green


$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
}

foreach ($site in $sites) {
    $ITGlueOrgID = $site.desc.split('()')[1]
    if (!$ITGlueOrgID) {
        write-host "Could not get IT-Glue OrgID for site $($site.desc). Moving on to next site." -ForegroundColor Yellow
        continue
    }
    else {
        write-host "Documenting $($site.desc), using ITGlue ID: $ITGlueOrgID" -ForegroundColor Green
    }

    $unifiDevices = Invoke-RestMethod -Uri "$UnifiBaseUri/s/$($site.name)/stat/device" -WebSession $websession
    foreach ($device in $unifiDevices.data) {
 ($ExistingConfiguration = Get-ITGlueConfigurations -organization_id $ITGlueOrgID -filter_serial_number $device.serial).data.id | select-object -last 1
        $DeviceName = if (!$device.name) { "Unifi Device $($device.serial)" } else { $device.name }
        $ModelName = ($unifiAllModels | Where-Object { $_.c -eq $device.model }).n
        $modelid = $ExistingModels | Where-Object { $_.attributes.name -eq $ModelName } | Select-Object -last 1
        $ConfigName = ($configtypes | Where-Object { $_.t -eq $device.type }).n
        $Configurationtypeid = ($ITGConfigTypes | Where-Object { $_.attributes.name -eq $Configname }).id
        $ConfigurationBody = @{
            type       = "configurations"
            attributes = @{
                "organization-id"         = $ITGlueOrgID
                "name"                    = $DeviceName
                "configuration-type-id"   = $Configurationtypeid
                "configuration-status-id" = $ConfigurationStatusId
                "manufacturer-id"         = $Manafacture.id
                "model-id"                = $ModelID.id
                "primary-ip"              = $device.ip
                "serial-number"           = $device.serial
                "mac-address"             = $device.mac
            }
        }
        if (!$ExistingConfiguration) { 
            write-host "Creating new device" -ForegroundColor Green
            New-ITGlueConfigurations -organization_id $ITGlueOrgID -data $ConfigurationBody
        } 
        else {
            write-host "Editing previous existing device" -ForegroundColor Green
            Set-ITGlueConfigurations -id ($ExistingConfiguration.data.id | Select-Object -last 1) -data $ConfigurationBody

        }
    }


}

And that’s it! this blog will be followed up by another documentation blog pretty quick, so its shorter than normal. As always, Happy PowerShelling.

Documenting and monitoring blogs updates

No new blog today, its officially a bank holiday and I’m enjoying the sun ūüôā I did make sure not to leave my readers empty handed. A bunch of my blogs got a little bit outdated, so I decided to update them.

The following blogs have been updated:

O365 blogs

I’ve updated the Secure Application Model blog with a method to retrieve new tokens, I’ve also fixed small logic bugs ūüôā

Also I’ve solved some html encoding issues with the Faster Partner Portal blog. I added the read-only MFA portal so its easier to find out which user has enabled and which not

The easier to read and automatic downloading of the audit logs has also been updated.

Monitoring Blogs

Since Microsoft decided to change the names for Office, I’ve updated the blog to monitor and install office C2R updates. My friend Stan was kind of enough to share all the possible URL’s and names with me.

I’ve also updated the Dell DCU blog so it downloads the latest version from Dell itself, instead of having to create a ZIP file, for some extra ease of use.

The script about monitoring and deploying the client based VPNs has also been updated to allow more types of VPN to be deployed.

After some troubeshooting my friend Isaac found that somehow I copy and pasted some blogs into each other for the WOL enablement and monitoring scripts. We’ve fixed that and the current version also improved on the detection.

Documentation blogs

So a bunch of the documentation blogs have had minor updates. I think its easiest if you use the documentation scripts to grab the latest update by browsing the category. Even the most recent unifi infrastructure had a small logic mistake for the generic version.

Automation blogs

I also updated the warranty lookup script, the GPO Deployment alternative and the IT-Glue backup script. The last one now also generates an HTML file with the passwords per client, instead of just one general password file. I’d still advise you to be really careful with that one of course.

And last but not least; Github

I’ve finally created some public github repos as per popular request. The github repos can be found here. I’ll try to keep it up to date as much as possible and will also included some of my bigger projects in there; right now I am working on the new Autotask REST API that is going to be released in version 2020.2.

So that’s all updates, I hope you enjoy and as always, happy PowerShelling!

Documenting with PowerShell: Documenting Unifi infrastructure

This blog is based on an earlier blog by Eliot Munro; Syncing Unifi Sites with IT-Glue by Eliot Munro. I loved the script, but wanted a little bit of extra information, I also didn’t really like the syncing with a Sharepoint list, so I modified the script to use the site name instead.

So, to use this version you’ll have to make some modifications to your Unifi site names. Rename your sites to follow this template: Name (ITGlueID). An example world be “SuperClient (1234)”. I also added a bit more IPSEC VPN information.

This script also has an added feature of documenting the switches for the site, where it lists which ports are in use and which ports are using PoE. A small example screenshot:

As with all my documentation scripts; it creates the flexible asset in IT-Glue for you. You can run this from an Azure runbook, your RMM system, or your personal workstation.

IT-Glue version

###############
$ITGkey = "ITGAPIKey"
$ITGbaseURI = "https://api.eu.itglue.com"
$UnifiBaseUri = "https://YourController.com:8443/api"
$UnifiUser = "APIUSER"
$UnifiPassword = "APIPAssword"
$FlexAssetName = "Unifi Controller Autodoc"
$Description = "A network one-page document that displays the unifi status."
##############

$TableStyling = "<th>", "<th style=`"background-color:#4CAF50`">"
#Settings IT-Glue logon information
If (Get-Module -ListAvailable -Name "ITGlueAPI") { 
    Import-module ITGlueAPI 
}
Else { 
    Install-Module ITGlueAPI -Force
    Import-Module ITGlueAPI
}
Add-ITGlueBaseURI -base_uri $ITGbaseURI
Add-ITGlueAPIKey $ITGkey
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            = "Site Name"
                            kind            = "Text"
                            required        = $true
                            "show-in-list"  = $true
                            "use-for-title" = $true
                        }
                    },
                    @{
                        type       = "flexible_asset_fields"
                        attributes = @{
                            order          = 2
                            name           = "WAN"
                            kind           = "Textbox"
                            required       = $false
                            "show-in-list" = $false
                        }
                    },
                    @{
                        type       = "flexible_asset_fields"
                        attributes = @{
                            order          = 3
                            name           = "LAN"
                            kind           = "Textbox"
                            required       = $false
                            "show-in-list" = $false
                        }
                    },
                    @{
                        type       = "flexible_asset_fields"
                        attributes = @{
                            order          = 4
                            name           = "VPN"
                            kind           = "Textbox"
                            required       = $false
                            "show-in-list" = $false
                        }
                    },
                    @{
                        type       = "flexible_asset_fields"
                        attributes = @{
                            order          = 5
                            name           = "Wi-Fi"
                            kind           = "Textbox"
                            required       = $false
                            "show-in-list" = $false
                        }
                    },  
                    @{
                        type       = "flexible_asset_fields"
                        attributes = @{
                            order          = 6
                            name           = "Port Forwards"
                            kind           = "Textbox"
                            required       = $false
                            "show-in-list" = $false
                        }
                    }, @{
                        type       = "flexible_asset_fields"
                        attributes = @{
                            order          = 7
                            name           = "Switches"
                            kind           = "Textbox"
                            required       = $false
                            "show-in-list" = $false
                        }
                    }
                )
            }
        }
    }
    New-ITGlueFlexibleAssetTypes -Data $NewFlexAssetData 
    $FilterID = (Get-ITGlueFlexibleAssetTypes -filter_name $FlexAssetName).data
}

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


$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
}

foreach ($site in $sites) {
    $ITGlueOrgID = $site.desc.split('()')[1]
    if (!$ITGlueOrgID) {
        write-host "Could not get IT-Glue OrgID for site $($site.desc). Moving on to next site." -ForegroundColor Yellow
        continue
    }
    else {
        write-host "Documenting $($site.desc), using ITGlue ID: $ITGlueOrgID" -ForegroundColor Green
    }

    $unifiDevices = Invoke-RestMethod -Uri "$UnifiBaseUri/s/$($site.name)/stat/device" -WebSession $websession
    $UnifiSwitches = $unifiDevices.data | Where-Object { $_.type -contains "usw" }
    $SwitchPorts = foreach ($unifiswitch in $UnifiSwitches) {
        "<h2>$($unifiswitch.name) - $($unifiswitch.mac)</h2> <table><tr>"
        foreach ($Port in $unifiswitch.port_table) {
            "<th>$($port.port_idx)</th>"
        }
        "</tr><tr>"
        foreach ($Port in $unifiswitch.port_table) {
            $colour = if ($port.up -eq $true) { '02ab26' } else { 'ad2323' }
            $speed = switch ($port.speed) {
                10000 { "10Gb" }
                1000 { "1Gb" }
                100 { "100Mb" }
                10 { "10Mb" }
                0 { "Port off" }
            }
            "<td style='background-color:#$($colour)'>$speed</td>"
        }
        '</tr><tr>'
        foreach ($Port in $unifiswitch.port_table) {
            $poestate = if ($port.poe_enable -eq $true) { 'PoE on'; $colour = '02ab26' } elseif ($port.port_poe -eq $false) { 'No PoE'; $colour = '#696363' } else { "PoE Off"; $colour = 'ad2323' }
            "<td style='background-color:#$($colour)'>$Poestate</td >"
        }
        '</tr></table>'
    }

    $uaps = $unifiDevices.data | Where-Object { $_.type -contains "uap" }

    $Wifinetworks = $uaps.vap_table | Group-Object Essid
    $wifi = foreach ($Wifinetwork in $Wifinetworks) {
        $Wifinetwork | Select-object @{n = "SSID"; e = { $_.Name } }, @{n = "Access Points"; e = { $uaps.name -join "`n" } }, @{n = "Channel"; e = { $_.group.channel -join ", " } }, @{n = "Usage"; e = { $_.group.usage | Sort-Object -Unique } }, @{n = "Enabled"; e = { $_.group.up | sort-object -Unique } }
    } 
     
    $alarms = (Invoke-RestMethod -Uri "$UnifiBaseUri/s/$($site.name)/stat/alarm" -WebSession $websession).data
    $alarms = $alarms | Select-Object @{n = "Universal Time"; e = { [datetime]$_.datetime } }, @{n = "Device Name"; e = { $_.$(($_ | Get-Member | Where-Object { $_.Name -match "_name" }).name) } }, @{n = "Message"; e = { $_.msg } } -First 10

    $portforward = (Invoke-RestMethod -Uri "$UnifiBaseUri/s/$($site.name)/rest/portforward" -WebSession $websession).data
    $portForward = $portforward | Select-Object Name, @{n = "Source"; e = { "$($_.src):$($_.dst_port)" } }, @{n = "Destination"; e = { "$($_.fwd):$($_.fwd_port)" } }, @{n = "Protocol"; e = { $_.proto } }

 
    $networkConf = (Invoke-RestMethod -Uri "$UnifiBaseUri/s/$($site.name)/rest/networkconf" -WebSession $websession).data
 
    $NetworkInfo = foreach ($network in $networkConf) {
        [pscustomobject] @{
            'Purpose'                 = $network.purpose
            'Name'                    = $network.name
            'vlan'                    = "$($network.vlan_enabled) $($network.vlan)" 
            "LAN IP Subnet"           =	$network.ip_subnet                 
            "LAN DHCP Relay Enabled"  =	$network.dhcp_relay_enabled        
            "LAN DHCP Enabled"        =	$network.dhcpd_enabled
            "LAN Network Group"       =	$network.networkgroup              
            "LAN Domain Name"         =	$network.domain_name               
            "LAN DHCP Lease Time"     =	$network.dhcpd_leasetime           
            "LAN DNS 1"               =	$network.dhcpd_dns_1               
            "LAN DNS 2"               =	$network.dhcpd_dns_2               
            "LAN DNS 3"               =	$network.dhcpd_dns_3               
            "LAN DNS 4"               =	$network.dhcpd_dns_4                           
            'DHCP Range'              = "$($network.dhcpd_start) - $($network.dhcpd_stop)"
            "WAN IP Type"             = $network.wan_type 
            'WAN IP'                  = $network.wan_ip 
            "WAN Subnet"              = $network.wan_netmask
            'WAN Gateway'             = $network.wan_gateway 
            "WAN DNS 1"               = $network.wan_dns1 
            "WAN DNS 2"               = $network.wan_dns2 
            "WAN Failover Type"       = $network.wan_load_balance_type
            'VPN Ike Version'         = $network.ipsec_key_exchange
            'VPN Encryption protocol' = $network.ipsec_encryption
            'VPN Hashing protocol'    = $network.ipsec_hash
            'VPN DH Group'            = $network.ipsec_dh_group
            'VPN PFS Enabled'         = $network.ipsec_pfs
            'VPN Dynamic Routing'     = $network.ipsec_dynamic_routing
            'VPN Local IP'            = $network.ipsec_local_ip
            'VPN Peer IP'             = $network.ipsec_peer_ip
            'VPN IPSEC Key'           = $network.x_ipsec_pre_shared_key
        }

    }

    $WANs = ($networkinfo | where-object { $_.Purpose -eq "wan" } | select-object Name, *WAN* | convertto-html -frag | out-string) -replace $tablestyling
    $LANS = ($networkinfo | where-object { $_.Purpose -eq "corporate" } | select-object Name, *LAN* | convertto-html -frag | out-string) -replace $tablestyling
    $VPNs = ($networkinfo | where-object { $_.Purpose -eq "site-vpn" } | select-object Name, *VPN* | convertto-html -frag | out-string) -replace $tablestyling
    $Wifi = ($wifi | convertto-html -frag | out-string) -replace $tablestyling
    $PortForwards = ($Portforward | convertto-html -frag | out-string) -replace $tablestyling

    $FlexAssetBody = @{
        type       = 'flexible-assets'
        attributes = @{
            traits = @{
                'site-name'     = $site.name
                'wan'           = $WANs
                'lan'           = $LANS
                'vpn'           = $VPNs
                'wi-fi'          = $wifi
                'port-forwards' = $PortForwards
                'switches'      = ($SwitchPorts | out-string)
            }
        }
    }
    write-host "Documenting to IT-Glue"  -ForegroundColor Green
    $ExistingFlexAsset = (Get-ITGlueFlexibleAssets -filter_flexible_asset_type_id $($filterID.ID) -filter_organization_id $ITGlueOrgID).data | Where-Object { $_.attributes.traits.'site-name' -eq $site.name }
    #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', $ITGlueOrgID)
        $FlexAssetBody.attributes.add('flexible-asset-type-id', $($filterID.ID))
        write-host "  Creating Unifi into IT-Glue organisation $ITGlueOrgID" -ForegroundColor Green
        New-ITGlueFlexibleAssets -data $FlexAssetBody
    }
    else {
        write-host "  Editing Unifi into IT-Glue organisation $ITGlueOrgID"  -ForegroundColor Green
        $ExistingFlexAsset = $ExistingFlexAsset[-1]
        Set-ITGlueFlexibleAssets -id $ExistingFlexAsset.id -data $FlexAssetBody
    }

}


Generic HTML Version

So as always, I’ve also included a generic version. You can use this generic version with your own documentation system. Per popular request here’s a screenshot on how that looks.

###############
$UnifiBaseUri = "https://Controller.com:8443/api"
$UnifiUser = "APIUSER"
$UnifiPassword = "APIUSER"
##############

$TableStyling = "<th>", "<th style=`"background-color:#4CAF50`">"

$UniFiCredentials = @{
    username = $UnifiUser
    password = $UnifiPassword
    remember = $true
} | ConvertTo-Json
 
$UnifiCredentials
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
}

foreach ($site in $sites) {

        write-host "Documenting $($site.desc), using ITGlue ID: $ITGlueOrgID" -ForegroundColor Green


    $unifiDevices = Invoke-RestMethod -Uri "$UnifiBaseUri/s/$($site.name)/stat/device" -WebSession $websession
    $UnifiSwitches = $unifiDevices.data | Where-Object { $_.type -contains "usw" }
    $SwitchPorts = foreach ($unifiswitch in $UnifiSwitches) {
        "<h2>$($unifiswitch.name) - $($unifiswitch.mac)</h2> <table><tr>"
        foreach ($Port in $unifiswitch.port_table) {
            "<th>$($port.port_idx)</th>"
        }
        "</tr><tr>"
        foreach ($Port in $unifiswitch.port_table) {
            $colour = if ($port.up -eq $true) { '02ab26' } else { 'ad2323' }
            $speed = switch ($port.speed) {
                10000 { "10Gb" }
                1000 { "1Gb" }
                100 { "100Mb" }
                10 { "10Mb" }
                0 { "Port off" }
            }
            "<td style='background-color:#$($colour)'>$speed</td>"
        }
        '</tr><tr>'
        foreach ($Port in $unifiswitch.port_table) {
            $poestate = if ($port.poe_enable -eq $true) { 'PoE on'; $colour = '02ab26' } elseif ($port.port_poe -eq $false) { 'No PoE'; $colour = '#696363' } else { "PoE Off"; $colour = 'ad2323' }
            "<td style='background-color:#$($colour)'>$Poestate</td >"
        }
        '</tr></table>'
    }

    $uaps = $unifiDevices.data | Where-Object { $_.type -contains "uap" }

    $Wifinetworks = $uaps.vap_table | Group-Object Essid
    $wifi = foreach ($Wifinetwork in $Wifinetworks) {
        $Wifinetwork | Select-object @{n = "SSID"; e = { $_.Name } }, @{n = "Access Points"; e = { $uaps.name -join "`n" } }, @{n = "Channel"; e = { $_.group.channel -join ", " } }, @{n = "Usage"; e = { $_.group.usage | Sort-Object -Unique } }, @{n = "Enabled"; e = { $_.group.up | sort-object -Unique } }
    } 
     
    $alarms = (Invoke-RestMethod -Uri "$UnifiBaseUri/s/$($site.name)/stat/alarm" -WebSession $websession).data
    $alarms = $alarms | Select-Object @{n = "Universal Time"; e = { [datetime]$_.datetime } }, @{n = "Device Name"; e = { $_.$(($_ | Get-Member | Where-Object { $_.Name -match "_name" }).name) } }, @{n = "Message"; e = { $_.msg } } -First 10

    $portforward = (Invoke-RestMethod -Uri "$UnifiBaseUri/s/$($site.name)/rest/portforward" -WebSession $websession).data
    $portForward = $portforward | Select-Object Name, @{n = "Source"; e = { "$($_.src):$($_.dst_port)" } }, @{n = "Destination"; e = { "$($_.fwd):$($_.fwd_port)" } }, @{n = "Protocol"; e = { $_.proto } }

 
    $networkConf = (Invoke-RestMethod -Uri "$UnifiBaseUri/s/$($site.name)/rest/networkconf" -WebSession $websession).data
 
    $NetworkInfo = foreach ($network in $networkConf) {
        [pscustomobject] @{
            'Purpose'                 = $network.purpose
            'Name'                    = $network.name
            'vlan'                    = "$($network.vlan_enabled) $($network.vlan)" 
            "LAN IP Subnet"           =	$network.ip_subnet                 
            "LAN DHCP Relay Enabled"  =	$network.dhcp_relay_enabled        
            "LAN DHCP Enabled"        =	$network.dhcpd_enabled
            "LAN Network Group"       =	$network.networkgroup              
            "LAN Domain Name"         =	$network.domain_name               
            "LAN DHCP Lease Time"     =	$network.dhcpd_leasetime           
            "LAN DNS 1"               =	$network.dhcpd_dns_1               
            "LAN DNS 2"               =	$network.dhcpd_dns_2               
            "LAN DNS 3"               =	$network.dhcpd_dns_3               
            "LAN DNS 4"               =	$network.dhcpd_dns_4                           
            'DHCP Range'              = "$($network.dhcpd_start) - $($network.dhcpd_stop)"
            "WAN IP Type"             = $network.wan_type 
            'WAN IP'                  = $network.wan_ip 
            "WAN Subnet"              = $network.wan_netmask
            'WAN Gateway'             = $network.wan_gateway 
            "WAN DNS 1"               = $network.wan_dns1 
            "WAN DNS 2"               = $network.wan_dns2 
            "WAN Failover Type"       = $network.wan_load_balance_type
            'VPN Ike Version'         = $network.ipsec_key_exchange
            'VPN Encryption protocol' = $network.ipsec_encryption
            'VPN Hashing protocol'    = $network.ipsec_hash
            'VPN DH Group'            = $network.ipsec_dh_group
            'VPN PFS Enabled'         = $network.ipsec_pfs
            'VPN Dynamic Routing'     = $network.ipsec_dynamic_routing
            'VPN Local IP'            = $network.ipsec_local_ip
            'VPN Peer IP'             = $network.ipsec_peer_ip
            'VPN IPSEC Key'           = $network.x_ipsec_pre_shared_key
        }

    }
    $head = @"
    <script>
    function myFunction() {
        const filter = document.querySelector('#myInput').value.toUpperCase();
        const trs = document.querySelectorAll('table tr:not(.header)');
        trs.forEach(tr => tr.style.display = [...tr.children].find(td => td.innerHTML.toUpperCase().includes(filter)) ? '' : 'none');
      }</script>
    <Title>Audit Log Report</Title>
    <style>
    body { background-color:#E5E4E2;
          font-family:Monospace;
          font-size:10pt; }
    td, th { border:0px solid black; 
            border-collapse:collapse;
            white-space:pre; }
    th { color:white;
        background-color:black; }
    table, tr, td, th {
         padding: 2px; 
         margin: 0px;
         white-space:pre; }
    tr:nth-child(odd) {background-color: lightgray}
    table { width:95%;margin-left:5px; margin-bottom:20px; }
    h2 {
    font-family:Tahoma;
    color:#6D7B8D;
    }
    .footer 
    { color:green; 
     margin-left:10px; 
     font-family:Tahoma;
     font-size:8pt;
     font-style:italic;
    }
    #myInput {
      background-image: url('https://www.w3schools.com/css/searchicon.png'); /* Add a search icon to input */
      background-position: 10px 12px; /* Position the search icon */
      background-repeat: no-repeat; /* Do not repeat the icon image */
      width: 50%; /* Full-width */
      font-size: 16px; /* Increase font-size */
      padding: 12px 20px 12px 40px; /* Add some padding */
      border: 1px solid #ddd; /* Add a grey border */
      margin-bottom: 12px; /* Add some space below the input */
    }
    </style>
"@
    $WANs = ($networkinfo | where-object { $_.Purpose -eq "wan" } | select-object Name, *WAN* | convertto-html -frag -PreContent "<h1>WANS</h2>" | out-string) -replace $tablestyling
    $LANS = ($networkinfo | where-object { $_.Purpose -eq "corporate" } | select-object Name, *LAN* | convertto-html -frag -PreContent "<h1>LANs</h2>" | out-string) -replace $tablestyling
    $VPNs = ($networkinfo | where-object { $_.Purpose -eq "site-vpn" } | select-object Name, *VPN* | convertto-html -frag -PreContent "<h1>VPNs</h2>" | out-string) -replace $tablestyling
    $Wifi = ($wifi | convertto-html -frag -PreContent "<h1>Wi-Fi</h2>" | out-string) -replace $tablestyling
    $PortForwards = ($Portforward | convertto-html -frag -PreContent "<h1>Port Forwards</h2>" | out-string) -replace $tablestyling
    
$head,$WANs,$LANS,$VPNs,$wifi,$PortForwards,$SwitchPorts | out-file "C:\Temp\$($site.desc).html"
}


So thats it! as always, Happy PowerShelling!

Monitoring with PowerShell: Preventing PowerShell based attacks (LoLBas)

In the last Huntress Tradecraft Tuesdays there was some discussion about using “Living of the land” techniques. Living of the land means using the tools available on the operation system to achieve access. There’s a lot of ways that bad actors are now using these system tools to deploy ransomware for example.

PowerShell is one of these tools; during Tradecraft Tuesday I’ve suggested using a technique to block some of these attacks. Some of my friends came to me afterwards for some more information so I figured I’d make a small blog about it ūüôā

Most of the LoLBin and LoLBas techniques make use of PowerShell commands that execute a script directly in memory – This is a ‘file-less’ technique. These PowerShell commands can actually be blocked quite easily by changing their alias to something else. This does mean that scripts that utilize these commands might break too. I’ll be showing a way to work around that for your own scripts.

I like using the following steps to try and prevent these type of attacks. Just a small disclaimer: This is a tiny part of your security suite and you should not rely on this 100%. Using this you are locking one of the doors in your house. It won’t help if you keep all the others open.

Deployment script

So first we’ll start with a very small deployment script you can run on all your workstations. The script takes care of the following things:

  • Enables Script Block Logging.
    • Please note that with enabling script block logging, all executed scripts will be logged. If your password is in plain text it will also be shown in these logs. If you have scripts that use plain-text passwords or keys please exit them when script block logging is enabled.
  • Changes the alias of the most dangerous commands to “write-host” – nullifying the risk of these commands.
  • You can add or remove commands as you see fit. The ones in the script are just the most well known.
New-Item -Path "HKLM:\SOFTWARE\Wow6432Node\Policies\Microsoft\Windows\PowerShell\ScriptBlockLogging" -Force
Set-ItemProperty -Path "HKLM:\SOFTWARE\Wow6432Node\Policies\Microsoft\Windows\PowerShell\ScriptBlockLogging" -Name "EnableScriptBlockLogging" -Value 1 -Force
#Set aliasses to something harmless
$DangerousCommands = @("iwr", "irm", "curl", "saps", "iex", "Invoke-Expression", "Invoke-RestMethod", "Invoke-WebRequest", "Invoke-RestMethod")
foreach ($Command in $DangerousCommands) {
    Set-Alias -Name $Command -Value "write-host" -Option AllScope -Force
}

So you’ve probably noticed that we’re actually overwriting these commands with an alias. Aliases take precedence over actual cmdlets. This is great for us but will cause scripts that use these commands to fail.

We can prevent this failure by running this script at the top of our execution scripts:

$DangerousCommands = @("iwr", "irm", "curl", "saps", "iex", "Invoke-Expression", "Invoke-RestMethod", "Invoke-WebRequest", "Invoke-RestMethod")
foreach ($Command in $DangerousCommands) {
    get-item alias:$Command | remove-item -Force
}

This will make the commands available again, except their shortest version such as “iwr” and “iex”. We’re not done yet though – We also want to see when people are doing bad stuff right? That’s why we have a monitoring script to load into your RMM too!

Monitoring script

So this monitoring script picks up the following:

  • If script logging is enabled. The results of this are stored in $ScriptBlockEnable
  • If the most dangerous commands are alliased to write-host, $AliasProtection contains the result.
  • If the PowerShell event logs in the previous hour contain one of the dangerous aliases. You could also add stuff like “HTTPS” to the dangerous aliases.

I’d suggest to run this script as much as possible with your RMM, so you get warned early when something phishy is going on.

$ScriptBlockLogging = get-ItemProperty -Path "HKLM:\SOFTWARE\Wow6432Node\Policies\Microsoft\Windows\PowerShell\ScriptBlockLogging"
$ScriptBlockEnable = if ($ScriptBlockLogging.EnableScriptBLockLogging -ne 1) { "Error - Script Block Logging is not enabled" } else { "Healthy - Script Block Logging is enabled" }

$DangerousCommands = @("iwr", "irm", "curl", "saps", "iex", "Invoke-Expression", "Invoke-RestMethod", "Invoke-WebRequest", "Invoke-RestMethod")
$Aliasses = get-alias | Where-Object { $_.name -in $DangerousCommands -and $_.ResolvedCommandName -ne "Write-Host" } 
if (!$Aliasses) {
    $AliasProtection = "Healthy - Dangerous commands are protected."
}
else {
    $AliasProtection = "Unhealthy - Dangerous commands are not protected. Please investigate."
}
$logInfo = @{ 
    ProviderName = "Microsoft-Windows-PowerShell"
    StartTime    = (get-date).AddHours(-2)
}
$PowerShellEvents = Get-WinEvent -FilterHashtable $logInfo | Select-Object TimeCreated, message
$PowerShellLogs = foreach ($Event in $PowerShellEvents) {

    foreach ($command in $DangerousCommands) {
        if ($Event.Message -like "*$Command*") { 
            [pscustomobject] @{
                TimeCreated      = $event.TimeCreated
                EventMessage     = $Event.message
                TriggeredCommand = $command

            } 
        }
    }

}

if(!$PowerShellLogs){
    $PowerShellLogs = "Healthy"
}

And that’s it! This should offer you a bit more protection from Lolbin/Lolbas and fileless attacks. As always, Happy PowerShelling.

Monitoring with PowerShell: Monitoring the used MFA type for O365/Azure.

We all know it’s key to have your security hygiene in order, a large part of that is your multi factor authentication deployment. Having all users use MFA these days is a no-brainer, but not all types of MFA are made equal. For example; MFA via text-message is generally considered unsafe.

But even the entered OTP codes are somewhat unsafe when compared to physical tokens or push-messages. Spear-phishers are now using proxy solutions or simply social engineering to extract the OTP code from users.

I have the philosophy that everything should be secure by design. So I like to offer my clients the most secure solutions. This does mean we also keep an eye on how they configure multi-factor authentication. When we do this for them we enforce that they use the push message option, and don’t use unsafe alternatives. We disable these alternatives in the portal too so that they can’t be setup directly.

This doesn’t always work out. sometimes the client sets up their own MFA, we take over a client or sometimes people just make mistakes. This is why we monitor the actual MFA type they’ve set up and try to assist the client in choosing the more secure alternative. To do that, we use the following script I hope it helps you in securing your environment too.

All Tenants Script

So this script checks if the primary option is set to an unsafe one, and alerts if it is. You could also use it to detect if MFA is configured, and what methods are used.

As always we use the Secure Application Model so we can run this script headless.

$ApplicationId         = 'xxxx-xxxx-xxxx-xxxx-xxx'
$ApplicationSecret     = 'YOURSECRET' | Convertto-SecureString -AsPlainText -Force
$TenantID              = 'xxxxxx-xxxx-xxx-xxxx--xxx'
$RefreshToken          = 'LongResourcetoken'
$ExchangeRefreshToken  = 'LongExchangeToken'
$credential = New-Object System.Management.Automation.PSCredential($ApplicationId, $ApplicationSecret)
$aadGraphToken = New-PartnerAccessToken -ApplicationId $ApplicationId -Credential $credential -RefreshToken $refreshToken -Scopes 'https://graph.windows.net/.default' -ServicePrincipal -Tenant $tenantID 
$graphToken = New-PartnerAccessToken -ApplicationId $ApplicationId -Credential $credential -RefreshToken $refreshToken -Scopes 'https://graph.microsoft.com/.default' -ServicePrincipal -Tenant $tenantID 

Connect-MsolService -AdGraphAccessToken $aadGraphToken.AccessToken -MsGraphAccessToken $graphToken.AccessToken
$customers = Get-MsolPartnerContract -All
$MFAType = foreach ($customer in $customers) {
    $users = Get-MsolUser -TenantId $customer.tenantid -all

    foreach ($user in $users) {
        $primaryMFA = if ($null -ne $user.StrongAuthenticationUserDetails) { ($user.StrongAuthenticationMethods | Where-Object { $_.IsDefault -eq $true }).methodType } else { "MFA Disabled" } 
        $SecondaryMFA = if ($null -ne $user.StrongAuthenticationUserDetails) { ($user.StrongAuthenticationMethods | Where-Object { $_.IsDefault -eq $false }).methodType } else { "No Secondary Option enabled" } 
        [PSCustomObject]@{
            "DisplayName"   = $user.DisplayName
            "user"          = $user.UserPrincipalName
            "Primary MFA"   = $primaryMFA
            "Secondary MFA" = $SecondaryMFA
        }
    }
}

$UnSafeMFAUsers = $MFAType | Where-Object { $_.'Primary MFA' -like "*SMS*" -or $_.'Primary MFA' -like "*voice*" -or $_.'Primary MFA' -like "*OTP*" }

if (!$UnSafeMFAUsers) {
    $UnSafeMFAUsers = "Healthy"
} 

Single Tenant Script

This script pretty much does the same as above, but for a single tenant.

$ApplicationId         = 'xxxx-xxxx-xxxx-xxxx-xxx'
$ApplicationSecret     = 'YOURSECRET' | Convertto-SecureString -AsPlainText -Force
$TenantID              = 'xxxxxx-xxxx-xxx-xxxx--xxx'
$RefreshToken          = 'LongResourcetoken'
$ExchangeRefreshToken  = 'LongExchangeToken'
$TenantToCheck         = 'tenant.onmicrosoft.com'
$credential = New-Object System.Management.Automation.PSCredential($ApplicationId, $ApplicationSecret)
$aadGraphToken = New-PartnerAccessToken -ApplicationId $ApplicationId -Credential $credential -RefreshToken $refreshToken -Scopes 'https://graph.windows.net/.default' -ServicePrincipal -Tenant $tenantID 
$graphToken = New-PartnerAccessToken -ApplicationId $ApplicationId -Credential $credential -RefreshToken $refreshToken -Scopes 'https://graph.microsoft.com/.default' -ServicePrincipal -Tenant $tenantID 

Connect-MsolService -AdGraphAccessToken $aadGraphToken.AccessToken -MsGraphAccessToken $graphToken.AccessToken
$customers = Get-MsolPartnerContract -All | where-object {$_.DefaultDomainName -eq $TenantToCheck}
$MFAType = foreach ($customer in $customers) {
    $users = Get-MsolUser -TenantId $customer.tenantid -all

    foreach ($user in $users) {
        $primaryMFA = if ($null -ne $user.StrongAuthenticationUserDetails) { ($user.StrongAuthenticationMethods | Where-Object { $_.IsDefault -eq $true }).methodType } else { "MFA Disabled" } 
        $SecondaryMFA = if ($null -ne $user.StrongAuthenticationUserDetails) { ($user.StrongAuthenticationMethods | Where-Object { $_.IsDefault -eq $false }).methodType } else { "No Secondary Option enabled" } 
        [PSCustomObject]@{
            "DisplayName"   = $user.DisplayName
            "user"          = $user.UserPrincipalName
            "Primary MFA"   = $primaryMFA
            "Secondary MFA" = $SecondaryMFA
        }
    }
}

$UnSafeMFAUsers = $MFAType | Where-Object { $_.'Primary MFA' -like "*SMS*" -or $_.'Primary MFA' -like "*voice*" -or $_.'Primary MFA' -like "*OTP*" }

if (!$UnSafeMFAUsers) {
    $UnSafeMFAUsers = "Healthy"
} 

Non Secure App Model script

So if you aren’t a Microsoft Partner but still want to execute this script, just use the following script.

Connect-MsolService
$customers = Get-MsolPartnerContract -All | where-object {$_.DefaultDomainName -eq $TenantToCheck}
$MFAType = foreach ($customer in $customers) {
    $users = Get-MsolUser -TenantId $customer.tenantid -all

    foreach ($user in $users) {
        $primaryMFA = if ($null -ne $user.StrongAuthenticationUserDetails) { ($user.StrongAuthenticationMethods | Where-Object { $_.IsDefault -eq $true }).methodType } else { "MFA Disabled" } 
        $SecondaryMFA = if ($null -ne $user.StrongAuthenticationUserDetails) { ($user.StrongAuthenticationMethods | Where-Object { $_.IsDefault -eq $false }).methodType } else { "No Secondary Option enabled" } 
        [PSCustomObject]@{
            "DisplayName"   = $user.DisplayName
            "user"          = $user.UserPrincipalName
            "Primary MFA"   = $primaryMFA
            "Secondary MFA" = $SecondaryMFA
        }
    }
}

$UnSafeMFAUsers = $MFAType | Where-Object { $_.'Primary MFA' -like "*SMS*" -or $_.'Primary MFA' -like "*voice*" -or $_.'Primary MFA' -like "*OTP*" }

if (!$UnSafeMFAUsers) {
    $UnSafeMFAUsers = "Healthy"
} 

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

Monitoring using PowerShell: Getting mailbox rules from the audit log

Some time ago I spoke about monitoring mailbox rules with PowerShell and how we’ve always used the “Get-inboxrule” cmdlet as delegate administrator to retrieve the rules and alert on them. Its been brought to my attention that recently API-created rules are no longer showing up using get-inboxrule.

so to resolve this, I’ve decided to rewrite the monitoring script for this by using the audit log instead. This script grabs the last day of the unified audit log and alerts if a new rule has been found. It will also alert you if the unified audit log is not enabled.

All Tenants Script

$ApplicationId = 'YourApplicationID'
$ApplicationSecret = 'YourApplicationSecret' | Convertto-SecureString -AsPlainText -Force
$TenantID = 'YourTenantID'
$ExchangeRefreshToken = 'YourExchangeToken'
$RefreshToken = 'YourRefreshToken'
$UPN = "UPN-Used-To-Generate-Token"
##############################
$credential = New-Object System.Management.Automation.PSCredential($ApplicationId, $ApplicationSecret)

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

Connect-MsolService -AdGraphAccessToken $aadGraphToken.AccessToken -MsGraphAccessToken $graphToken.AccessToken
$customers = Get-MsolPartnerContract -All
$logs = foreach ($customer in $customers) {

    $startDate = (Get-Date).AddDays(-1)
    $endDate = (Get-Date)
    $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
    $s = import-PSSession $session -AllowClobber -CommandName "Search-unifiedAuditLog", "Get-AdminAuditLogConfig"
if((Get-AdminAuditLogConfig).UnifiedAuditLogIngestionEnabled -eq $false){
     write-host "AuditLog is disabled for client $($customer.name)"
}

    $LogsTenant = @()
    Write-Host "Retrieving logs for $($customer.name)" -ForegroundColor Blue
    do {
        $logsTenant += Search-unifiedAuditLog -SessionCommand ReturnLargeSet -SessionId $customer.name -ResultSize 5000 -StartDate $startDate -EndDate $endDate -Operations "New-InboxRule", "Set-InboxRule", "UpdateInboxRules"
        Write-Host "Retrieved $($logsTenant.count) logs" -ForegroundColor Yellow
    }while ($LogsTenant.count % 5000 -eq 0 -and $LogsTenant.count -ne 0)
    Write-Host "Finished Retrieving logs" -ForegroundColor Green
    $LogsTenant
}
foreach($log in $logs){
$AuditData = $log.AuditData | ConvertFrom-Json
Write-Host "A new or changed rule has been found for user $($log.UserIds). The rule has the following info: $($Auditdata.Parameters | out-string)`n"
}
if(!$Logs){
    write-host "Healthy."
}

Single tenant script

$ApplicationId = 'YourApplicationID'
$ApplicationSecret = 'YourApplicationSecret' | Convertto-SecureString -AsPlainText -Force
$TenantID = 'YourTenantID'
$ExchangeRefreshToken = 'YourExchangeToken'
$RefreshToken = 'YourRefreshToken'
$UPN = "UPN-Used-To-Generate-Token"
$ClientTenantName = "bla.onmicrosoft.com"
##############################
$credential = New-Object System.Management.Automation.PSCredential($ApplicationId, $ApplicationSecret)

$customers = $ClientTenantName
$logs = foreach ($customer in $customers) {

    $startDate = (Get-Date).AddDays(-1)
    $endDate = (Get-Date)
    $token = New-PartnerAccessToken -ApplicationId 'a0c73c16-a7e3-4564-9a95-2bdf47383716'-RefreshToken $ExchangeRefreshToken -Scopes 'https://outlook.office365.com/.default' -Tenant $ClientTenantName
    $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
    $s = import-PSSession $session -AllowClobber -CommandName "Search-unifiedAuditLog", "Get-AdminAuditLogConfig"
if((Get-AdminAuditLogConfig).UnifiedAuditLogIngestionEnabled -eq $false){
     write-host "AuditLog is disabled for client $ClientTenantName)"
}

    $LogsTenant = @()
    Write-Host "Retrieving logs for $ClientTenantName)" -ForegroundColor Blue
    do {
        $logsTenant += Search-unifiedAuditLog -SessionCommand ReturnLargeSet -SessionId $customer.name -ResultSize 5000 -StartDate $startDate -EndDate $endDate -Operations "New-InboxRule", "Set-InboxRule", "UpdateInboxRules"
        Write-Host "Retrieved $($logsTenant.count) logs" -ForegroundColor Yellow
    }while ($LogsTenant.count % 5000 -eq 0 -and $LogsTenant.count -ne 0)
    Write-Host "Finished Retrieving logs" -ForegroundColor Green
    $LogsTenant
}
foreach($log in $logs){
$AuditData = $log.AuditData | ConvertFrom-Json
Write-Host "A new or changed rule has been found for user $($log.UserIds). The rule has the following info: $($Auditdata.Parameters | out-string)`n"
}
if(!$Logs){
    write-host "Healthy."
}

So that’s it! this should get -all- rules that have been created, including the ones by the old EWS API. Happy PowerShelling!

Automating with PowerShell: Creating named accounts

So this blog is attached to the MSP Security Summit presentation I’ve given. These scripts are some examples on how you could deploy named accounts with your RMM system. How to deploy the scripts as securely as possible really depends on your RMM system – Some systems allow you to pass passwords as secure strings, others require plain text but keep everything in memory to prevent the credentials from leaking.

These scripts are examples, modify them to your own environments as much as you want.

Script One: Creating a named Account

You can schedule this script using your RMM at your clients to create your named account. It will create a domain account if the machine is a domain controller, and it will create a local account if the machine is a server or workstation. I’d strongly recommend to use a different named account for workstations, than servers and domain controllers in case you use this version.

The script will expect a secure string as input for the password field. If your RMM does not support this, you’ll need to account for this.

param(
    $Username,
    $Password
)
function Set-NamedAccount ($Username, $Password, $type) {
    switch ($type) {
        'Local' {
            $ExistingUser = get-localuser $Username -ErrorAction SilentlyContinue
            if (!$ExistingUser) { 
                write-host "Creating new user admin $username" -ForegroundColor green
                New-LocalUser -Name $Username -Password $Password -PasswordNeverExpires
                Add-LocalGroupMember -Member $Username -SID 'S-1-5-32-544'
            }
            else {
                write-host "Setting password for admin $username" -ForegroundColor Green
                Set-LocalUser -Name $Username -Password $Password
            }
        }
        'Domain' { 
            $ExistingUser = get-aduser -filter * | Where-Object { $_.SamAccountName -eq $Username } -ErrorAction SilentlyContinue
            if (!$ExistingUser) { 
                write-host "Creating new domain admin for $username" -ForegroundColor Green
                New-ADUser -Name $Username -SamAccountName $Username -AccountPassword $Password -Enabled $True
                $ExistingUser = get-aduser -filter * | Where-Object { $_.SamAccountName -eq $Username }
                $Groups = @("Domain Admins", "Administrators", "Schema Admins", "Enterprise Admins")
                $groups | Add-ADGroupMember -members $ExistingUser -erroraction SilentlyContinue
            }
            else {
                write-host "Setting password for admin $username" -ForegroundColor green
                $ExistingUser | Set-adaccountpassword -newPassword $Password
            }
        }
    }
}

$DomainCheck = Get-CimInstance -ClassName Win32_OperatingSystem
switch ($DomainCheck.ProductType) {
    1 { Set-NamedAccount -Username $Username -Password $Password -type "Local" }
    2 { Set-NamedAccount -Username $Username -Password $Password -type "Domain" }
    3 { Set-NamedAccount -Username $Username -Password $Password -type "Local" }
    Default { write-warning -message "Could not get Server Type. Quitting script." }
}

Script Two: Creating a named Account with extra info

This named account script allows you to create a named account with a pseudo random part directly added to the username. Our RMM system allows us to add the customer ID from an external system. This makes sure you don’t use the same username across your entire client base and are still able remember the username for each client yourself. For example “JohnDoe-2123”

This also prevents a hacker from getting a credentials pair (Username+Password) and spraying them across other services.

param(
    $Username,
    $Password,
    $NameSeed
)
function Set-NamedAccount ($Username, $Password, $type) {
    switch ($type) {
        'Local' {
            $ExistingUser = get-localuser $Username -ErrorAction SilentlyContinue
            if (!$ExistingUser) { 
                write-host "Creating new user admin $username" -ForegroundColor green
                New-LocalUser -Name $Username -Password $Password -PasswordNeverExpires
                Add-LocalGroupMember -Member $Username -SID 'S-1-5-32-544'
            }
            else {
                write-host "Setting password for admin $username" -ForegroundColor Green
                Set-LocalUser -Name $Username -Password $Password
            }
        }
        'Domain' { 
            $ExistingUser = get-aduser -filter * | Where-Object { $_.SamAccountName -eq $Username } -ErrorAction SilentlyContinue
            if (!$ExistingUser) { 
                write-host "Creating new domain admin for $username" -ForegroundColor Green
                New-ADUser -Name $Username -SamAccountName $Username -AccountPassword $Password -Enabled $True
                $ExistingUser = get-aduser -filter * | Where-Object { $_.SamAccountName -eq $Username }
                $Groups = @("Domain Admins", "Administrators", "Schema Admins", "Enterprise Admins")
                $groups | Add-ADGroupMember -members $ExistingUser -erroraction SilentlyContinue
            }
            else {
                write-host "Setting password for admin $username" -ForegroundColor green
                $ExistingUser | Set-adaccountpassword -newPassword $Password
            }
        }
    }
}

$DomainCheck = Get-CimInstance -ClassName Win32_OperatingSystem
switch ($DomainCheck.ProductType) {
    1 { Set-NamedAccount -Username $Username+$NameSeed -Password $Password -type "Local" }
    2 { Set-NamedAccount -Username $Username+$NameSeed -Password $Password -type "Domain" }
    3 { Set-NamedAccount -Username $Username+$NameSeed -Password $Password -type "Local" }
    Default { write-warning -message "Could not get Server Type. Quitting script." }
}

You could also change this script to randomize the password, so you get pseudo-random passwords at your clients. Just heed my warning that I also gave during the presentation; do not think of this as a replacement for MFA.

Script Three: Removing a named account

So after you start using named accounts you’ll probably have someone leave the company, removing the account can be done by executing the following script.

param(
    $Username,
)
function Remove-NamedAccount ($Username, $type) {
    switch ($type) {
        'Local' {
            $ExistingUser = get-localuser $Username -ErrorAction SilentlyContinue
            if (!$ExistingUser) { 
                write-host "No such user found: $username" -ForegroundColor green
            }
            else {
                write-host "Deleting $username" -ForegroundColor Green
                Remove-LocalUser -Name $Username -Confirm:$false
            }
        }
        'Domain' { 
            $ExistingUser = get-aduser -filter * | Where-Object { $_.SamAccountName -eq $Username } -ErrorAction SilentlyContinue
            if (!$ExistingUser) { 
                write-host "No such user found: $username" -ForegroundColor Green
            }
            else {
                write-host "Deleting $username" -ForegroundColor green
                $ExistingUser | remove-aduser 
            }
        }
    }
}

$DomainCheck = Get-CimInstance -ClassName Win32_OperatingSystem
switch ($DomainCheck.ProductType) {
    1 { Set-NamedAccount -Username $Username+$NameSeed -Password $Password -type "Local" }
    2 { Set-NamedAccount -Username $Username+$NameSeed -Password $Password -type "Domain" }
    3 { Set-NamedAccount -Username $Username+$NameSeed -Password $Password -type "Local" }
    Default { write-warning -message "Could not get Server Type. Quitting script." }
}

This deletes the entire user from both the local store, and the domain. You can choose to replace the removal with a disable policy using the same script.

And more?

Well, that’s it for named accounts. Just remember to keep good security hygiene and start monitoring for security based threats too; unknown admin logons, forbidden users such as service accounts, new users, shodan results, port scans, Privileged Group Changes and all that kinda stuff ūüôā

And that’s it! I hope you enjoyed the presentation and if you have any questions, let me know. As always, Happy PowerShelling.

Automating with PowerShell: Automating Warranty information reporting.

One of the reddits I frequent has been seeing a lot of complaints lately about warranty information being incomplete or there’s complaints about the pricing of warranty information products. Most of these complaints are aimed at a specific product which is showing very shady sales tactics and general bad business practices lately.

I figured I would try to take up these complaints and solve them with a PowerShell script. This script grabs the warranty information for most major manufactures. It will generate a warranty report based on the input data. The input data can either be a CSV file or the Autotask PSA.

You can also upload the warranty date back to Autotask to keep your warranty information in sync too. Currently I’ve only made support for Autotask but I’m willing to create one for CW too if there is enough interest ūüôā

So lets get started.

Prerequisites

Before we can dive into the script we’ll have to collect some API keys. These keys will be used to get the warranty information at the vendors, or there are some “gotcha’s” you must know beforehand.

When you have Dell as a vendor:

  • Go to the Dell TechDirect website and register if you do not yet have an account. Complete the enrollment.
  • After registration, browse to the Dell TechDirect API enrollment page and wait for approval. This is a manual procedure so can take a day or two.
  • When the approval has been given, request a new API key and save this in a secure location.

When you have HP as a vendor:

Officially the HP API has been disabled because it was getting hammered by requests. The unofficial API is still available though so this script is based on that API.

When you have Microsoft as a vendor:

So Microsoft officially does not have a warranty lookup tool, but I found the Surface Diagnostic App does have a way to have programmatic access to the warranty environment. I reverse engineered this method. This is an unofficial and unsupported API so it might not function in the future. I’m quite proud of this one because as far as I can see, people have only achieved MS warranty checks by evading the CAPTCHA.

When you have Lenovo as a vendor:

You don’t have to do anything ūüôā Lenovo has an open API.

The Script

So now that we have our API keys and data, we can start feeding the script information. This can be via a CSV file or via Autotask. The CSV file should have the following information:

serialnumber,vendor,client
1234,HP,contoso
1234,Dell,toiletpaperco
1234,MS,contoso
1234,Lenovo,johndoe inc

If you are using Autotask, fill in the API integration key from your API user, and log in when your username/password is requested.

$source = "CSV" #AT, CSV, ITG, CW
##### Sync Settings
$SyncWithSource = $true  #Sync status warranty dates/status back to PSA/Management system. Only works with dynamic sources like ITG and AT.
$OverwriteWarranty = $true #Overwrites the date already found in AT with the one based on this API, unless the API could not find information.
$CreateHTMLReport = $true #Creates an HTML report.
###### File locations
$ReportsLocation = "C:\temp\reports" #Only required if Reporting is enabled.
$sourcefile = "C:\temp\temp.csv" #only required if source is not autotask.
$ATLogPath = "C:\temp\AT.txt" #Only used to log which objects have been synced with AT as AT does not have a audit log.
##### AT API Settings
$ATAPIKey = "Your-API-Key-For-Autotask" #only required if source is Autotask.
##### ITG API Settings
$ITGAPIKey = "Your-API-Key-For-ITG"  #only required if source is ITG
$ITGAPIURL = "https://api.eu.itglue.com" #only required if source is ITG
##### CW API Settings
$CWAPIURL = "https://api-staging.connectwisedev.com/v4_6_release/apis/3.0" #https://developer.connectwise.com/Best_Practices/Manage_Cloud_URL_Formatting?mt-learningpath=manage
$CWApiKeyPublic = "CWPublicKey" #Only required if source is CW
$CWApiKeyPrivate = "CwPrivateKey" #Only required if source is CW
$CWcompanyid = "CompanyID_1" #Only required if source is CW
##### Warranty Vendor API Keys
$DellClientID = "Dell-Client-ID"
$DellClientSecret = "Dell-Client-Secret"



function get-HPWarranty([Parameter(Mandatory = $true)]$SourceDevice, $Client) {
    $MWSID = (invoke-restmethod -uri 'https://support.hp.com/us-en/checkwarranty/multipleproducts/' -SessionVariable 'session' -Method get) -match '.*mwsid":"(?<wssid>.*)".*'
    $HPBody = " { `"gRecaptchaResponse`":`"`", `"obligationServiceRequests`":[ { `"serialNumber`":`"$SourceDevice`", `"isoCountryCde`":`"US`", `"lc`":`"EN`", `"cc`":`"US`", `"modelNumber`":null }] }"
 
    $HPReq = Invoke-RestMethod -Uri "https://support.hp.com/hp-pps-services/os/multiWarranty?ssid=$($matches.wssid)" -WebSession $session -Method "POST" -ContentType "application/json" -Body $HPbody
    if ($HPreq.productWarrantyDetailsVO.warrantyResultList.obligationStartDate) {
        $WarObj = [PSCustomObject]@{
            'Serial'                = $SourceDevice
            'Warranty Product name' = $hpreq.productWarrantyDetailsVO.warrantyResultList.warrantyType | Out-String
            'StartDate'             = $hpreq.productWarrantyDetailsVO.warrantyResultList.obligationStartDate | sort-object | select-object -last 1
            'EndDate'               = $hpreq.productWarrantyDetailsVO.warrantyResultList.obligationEndDate | sort-object | select-object -last 1
            'Warranty Status'       = $hpreq.productWarrantyDetailsVO.obligationStatus
            'Client'                = $Client
        }
    }
    else {
        $WarObj = [PSCustomObject]@{
            'Serial'                = $SourceDevice
            'Warranty Product name' = 'Could not get warranty information'
            'StartDate'             = $null
            'EndDate'               = $null
            'Warranty Status'       = 'Could not get warranty information'
            'Client'                = $Client
        }
    }
    return $WarObj
}
function get-DellWarranty([Parameter(Mandatory = $true)]$SourceDevice, $client) {
    $today = Get-Date -Format yyyy-MM-dd
    $AuthURI = "https://apigtwb2c.us.dell.com/auth/oauth/v2/token"
    if ($Global:TokenAge -lt (get-date).AddMinutes(-55)) { $global:Token = $null }
    If ($null -eq $global:Token) {
        $OAuth = "$global:DellClientID`:$global:DellClientSecret"
        $Bytes = [System.Text.Encoding]::ASCII.GetBytes($OAuth)
        $EncodedOAuth = [Convert]::ToBase64String($Bytes)
        $headersAuth = @{ "authorization" = "Basic $EncodedOAuth" }
        $Authbody = 'grant_type=client_credentials'
        $AuthResult = Invoke-RESTMethod -Method Post -Uri $AuthURI -Body $AuthBody -Headers $HeadersAuth
        $global:token = $AuthResult.access_token
        $Global:TokenAge = (get-date)
    }

    $headersReq = @{ "Authorization" = "Bearer $global:Token" }
    $ReqBody = @{ servicetags = $SourceDevice }
    $WarReq = Invoke-RestMethod -Uri "https://apigtwb2c.us.dell.com/PROD/sbil/eapi/v5/asset-entitlements" -Headers $headersReq -Body $ReqBody -Method Get -ContentType "application/json"
    $warlatest = $warreq.entitlements.enddate | sort-object | select-object -last 1 
    $WarrantyState = if ($warlatest -le $today) { "Expired" } else { "OK" }
    if ($warreq.entitlements.serviceleveldescription) {
        $WarObj = [PSCustomObject]@{
            'Serial'                = $SourceDevice
            'Warranty Product name' = $warreq.entitlements.serviceleveldescription -join "`n"
            'StartDate'             = (($warreq.entitlements.startdate | sort-object -Descending | select-object -last 1) -split 'T')[0]
            'EndDate'               = (($warreq.entitlements.enddate | sort-object | select-object -last 1) -split 'T')[0]
            'Warranty Status'       = $WarrantyState
            'Client'                = $Client
        }
    }
    else {
        $WarObj = [PSCustomObject]@{
            'Serial'                = $SourceDevice
            'Warranty Product name' = 'Could not get warranty information'
            'StartDate'             = $null
            'EndDate'               = $null
            'Warranty Status'       = 'Could not get warranty information'
            'Client'                = $Client
        }
    }
    return $WarObj
}
function get-LenovoWarranty([Parameter(Mandatory = $true)]$SourceDevice, $client) {
    $today = Get-Date -Format yyyy-MM-dd
    $APIURL = "https://ibase.lenovo.com/POIRequest.aspx"
    $SourceXML = "xml=<wiInputForm source='ibase'><id>LSC3</id><pw>IBA4LSC3</pw><product></product><serial>$SourceDevice</serial><wiOptions><machine/><parts/><service/><upma/><entitle/></wiOptions></wiInputForm>"
    $Req = Invoke-RestMethod -Uri $APIURL -Method POST -Body $SourceXML -ContentType 'application/x-www-form-urlencoded'
    if ($req.wiOutputForm) {
        $warlatest = $Req.wiOutputForm.warrantyInfo.serviceInfo.wed | sort-object | select-object -last 1 
        $WarrantyState = if ($warlatest -le $today) { "Expired" } else { "OK" }
         
        $WarObj = [PSCustomObject]@{
            'Serial'                = $Req.wiOutputForm.warrantyInfo.machineinfo.serial
            'Warranty Product name' = $Req.wiOutputForm.warrantyInfo.machineinfo.productname -join "`n"
            'StartDate'             = $Req.wiOutputForm.warrantyInfo.serviceInfo.warstart | sort-object -Descending | select-object -last 1
            'EndDate'               = $Req.wiOutputForm.warrantyInfo.serviceInfo.wed | sort-object | select-object -last 1
            'Warranty Status'       = $WarrantyState
            'Client'                = $Client
        }
    }
    else {
        $WarObj = [PSCustomObject]@{
            'Serial'                = $SourceDevice
            'Warranty Product name' = 'Could not get warranty information'
            'StartDate'             = $null
            'EndDate'               = $null
            'Warranty Status'       = 'Could not get warranty information'
            'Client'                = $Client
        }
    }
    return $WarObj
 
 
}
function Get-MSWarranty([Parameter(Mandatory = $true)]$SourceDevice, $client) {
    $body = ConvertTo-Json @{
        sku          = "Surface_"
        SerialNumber = $SourceDevice
        ForceRefresh = $false
    }
    $today = Get-Date -Format yyyy-MM-dd
    $PublicKey = Invoke-RestMethod -Uri 'https://surfacewarrantyservice.azurewebsites.net/api/key' -Method Get
    $AesCSP = New-Object System.Security.Cryptography.AesCryptoServiceProvider 
    $AesCSP.GenerateIV()
    $AesCSP.GenerateKey()
    $AESIVString = [System.Convert]::ToBase64String($AesCSP.IV)
    $AESKeyString = [System.Convert]::ToBase64String($AesCSP.Key)
    $AesKeyPair = [System.Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes("$AESIVString,$AESKeyString"))
    $bodybytes = [System.Text.Encoding]::UTF8.GetBytes($body)
    $bodyenc = [System.Convert]::ToBase64String($AesCSP.CreateEncryptor().TransformFinalBlock($bodybytes, 0, $bodybytes.Length))
    $RSA = New-Object System.Security.Cryptography.RSACryptoServiceProvider
    $RSA.ImportCspBlob([System.Convert]::FromBase64String($PublicKey))
    $EncKey = [System.Convert]::ToBase64String($rsa.Encrypt([System.Text.Encoding]::UTF8.GetBytes($AesKeyPair), $false))
     
    $FullBody = @{
        Data = $bodyenc
        Key  = $EncKey
    } | ConvertTo-Json
     
    $WarReq = Invoke-RestMethod -uri "https://surfacewarrantyservice.azurewebsites.net/api/v2/warranty" -Method POST -body $FullBody -ContentType "application/json"
    if ($WarReq.warranties) {
        $WarrantyState = foreach ($War in ($WarReq.warranties.effectiveenddate -split 'T')[0]) {
            if ($War -le $today) { "Expired" } else { "OK" }
        }
        $WarObj = [PSCustomObject]@{
            'Serial'                = $SourceDevice
            'Warranty Product name' = $WarReq.warranties.name -join "`n"
            'StartDate'             = (($WarReq.warranties.effectivestartdate | sort-object -Descending | select-object -last 1) -split 'T')[0]
            'EndDate'               = (($WarReq.warranties.effectiveenddate | sort-object | select-object -last 1) -split 'T')[0]
            'Warranty Status'       = $WarrantyState
            'Client'                = $Client
        }
    }
    else {
        $WarObj = [PSCustomObject]@{
            'Serial'                = $SourceDevice
            'Warranty Product name' = 'Could not get warranty information'
            'StartDate'             = $null
            'EndDate'               = $null
            'Warranty Status'       = 'Could not get warranty information'
            'Client'                = $Client
        }
    }
    return $WarObj
}
 
function  Get-WarrantyCSV($sourcefile) {
    write-host "Source is CSV file. Grabbing all devices." -ForegroundColor Green
    $CSVLines = import-csv -path $sourcefile -Delimiter ","
    $warrantyObject = foreach ($Line in $CSVLines) {
        switch ($line.vendor) {
            HP { get-HPWarranty -SourceDevice $line.SerialNumber -Client $line.client }
            Dell { get-DellWarranty -SourceDevice $line.SerialNumber -Client $line.client }
            Lenovo { get-LenovoWarranty -SourceDevice $line.SerialNumber -Client $line.client }
            MS { Get-MSWarranty -SourceDevice $line.SerialNumber -Client $line.client }
        }
    }
    return $warrantyObject
}
 
function Get-WarrantyAutotask($APIKey) {
    write-host "Source is Autotask." -ForegroundColor Green
    If (Get-Module -ListAvailable -Name "Autotask") { Import-module "Autotask" } Else { install-module "Autotask" -Force }
    $Credential = Get-Credential -Message "Enter your Autotask Credentials"
    remove-module autotask
    Import-Module Autotask -ArgumentList $Credential, $global:ATAPIKey
    write-host "Logging into Autotask. Grabbing all client information." -ForegroundColor "Green"
    $AllClients = $AllAccounts = Get-AtwsAccount -All | Where-Object { $_.Active -eq $true }
    write-host "Client information found. Grabbing all devices" -ForegroundColor "Green"
    $AllDevices = Get-AtwsInstalledProduct -All | Where-Object { $_.Active -eq $true -and $null -ne $_.SerialNumber }
    write-host "Collecting information. This can take a long time." -ForegroundColor "Green"
    $i = 0
    $warrantyObject = foreach ($Device in $AllDevices) {
        $i++
        Write-Progress -Activity "Grabbing Warranty information" -status "Processing $($device.serialnumber). Device $i of $($Alldevices.Count)" -percentComplete ($i / $Alldevices.Count * 100)
        $Client = ($AllClients | Where-Object { $_.id -eq $device.AccountID }).AccountName
        #We use a guess-smart method for serialnumbers. 
        #Dell is always 7, Lenovo is always 8, 10 is HP, 12 is Surface. 
        #This is because we cannot safely find the manafacture in the AT info.
        switch ($device.SerialNumber.Length) {
            7 { $WarState = get-DellWarranty -SourceDevice $device.SerialNumber -client $Client }
            8 { $WarState = get-LenovoWarranty -SourceDevice $device.SerialNumber -client $Client }
            10 { $WarState = get-HPWarranty  -SourceDevice $device.SerialNumber -client $Client }
            12 { $WarState = Get-MSWarranty  -SourceDevice $device.SerialNumber -client $Client }
        }
        if ($script:SyncWithSource -eq $true) {
            switch ($script:OverwriteWarranty) {
                $true {
                    if ($null -ne $warstate.EndDate) {
                        $device | Set-AtwsInstalledProduct -WarrantyExpirationDate $warstate.EndDate
                        "$Client / $($device.SerialNumber) with AT ID $($device.id) warranty has been overwritten to $($warstate.EndDate)" | out-file $script:ATLogPath -Append -Force
                    }
                     
                }
                $false { 
                    if ($null -eq $device.WarrantyExpirationDate -and $null -ne $warstate.EndDate) { 
                        $device | Set-AtwsInstalledProduct -WarrantyExpirationDate $warstate.EndDate 
                        "$Client / $($device.SerialNumber) with AT ID $($device.id) warranty has been set to $($warstate.EndDate)" | out-file $script:ATLogPath -Append -Force
                    } 
                }
            }
        }
        $WarState
    }
 
    return $warrantyObject
}
function  Get-WarrantyITG() {
    write-host "Source is IT-Glue. Grabbing all devices." -ForegroundColor Green
    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 $Global:ITGAPIURL
    Add-ITGlueAPIKey  $Global:ITGAPIKey
    write-host "Getting IT-Glue configuration list" -foregroundColor green
    $i = 0
    $AllITGlueConfigs = @()
    do {
        $AllITGlueConfigs += (Get-ITglueconfigurations -page_size 1000 -page_number $i).data
        $i++
        Write-Host "Retrieved $($AllITGlueConfigs.count) configurations" -ForegroundColor Yellow
    }while ($AllITGlueConfigs.count % 1000 -eq 0 -and $AllITGlueConfigs.count -ne 0) 
     
    $warrantyObject = foreach ($device in $AllITGlueConfigs) {
        $i++
        Write-Progress -Activity "Grabbing Warranty information" -status "Processing $($device.attributes.'serial-number'). Device $i of $($AllITGlueConfigs.Count)" -percentComplete ($i / $AllITGlueConfigs.Count * 100)
        $Client = ($AllClients | Where-Object { $_.id -eq $device.AccountID }).AccountName
        $client = $device.attributes.'organization-name'
        switch ($device.attributes.'serial-number'.Length) {
            7 { $WarState = get-DellWarranty -SourceDevice $device.attributes.'serial-number' -client $Client }
            8 { $WarState = get-LenovoWarranty -SourceDevice $device.attributes.'serial-number' -client $Client }
            10 { $WarState = get-HPWarranty  -SourceDevice $device.attributes.'serial-number' -client $Client }
            12 { $WarState = Get-MSWarranty  -SourceDevice $device.attributes.'serial-number' -client $Client }
        }
        if ($script:SyncWithSource -eq $true) {
            $FlexAssetBody = @{
                "type"       = "configurations"
                "attributes" = @{
                    'warranty-expires-at' = $warstate.EndDate
                } 
            }
            switch ($script:OverwriteWarranty) {
                $true {
                    if ($null -ne $warstate.EndDate) {
                        Set-ITGlueConfigurations -id $device.id -data $FlexAssetBody
                    }
                     
                }
                $false { 
                    if ($null -eq $device.WarrantyExpirationDate -and $null -ne $warstate.EndDate) { 
                        Set-ITGlueConfigurations -id $device.id -data $FlexAssetBody
                    } 
                }
            }
        }
        $WarState
    }
    return $warrantyObject
}
 
function  Get-WarrantyCW() {
    write-host "Source is Connectwise Manage. Grabbing all devices." -ForegroundColor Green
    $Base64Key = [Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes("$($Global:CWcompanyid)+$($Global:CWApiKeyPublic):$($global:CWApiKeyPrivate)"))
 
    $Header = @{
        'clientId'      = '3613dda6-fa25-49b9-85fb-7aa2b628befa' #This is the warranty script client id. Do not change. 
        'Authorization' = "Basic $Base64Key"
        'Content-Type'  = 'application/json'
    }
    $i = 0
    $Devices = @()
    do {
        $Devices += invoke-restmethod -headers $header -method GET -uri "$($Global:CWAPIURL)/company/configurations?pageSize=1000&page=$i"
        $i++
        Write-Host "Retrieved $($devices.count) configurations" -ForegroundColor Yellow
    }while ($devices.count % 1000 -eq 0 -and $devices.count -ne 0) 
 
    $warrantyObject = foreach ($device in $Devices) {
        $i++
        Write-Progress -Activity "Grabbing Warranty information" -status "Processing $($device.serialnumber). Device $i of $($devices.Count)" -percentComplete ($i / $Devices.Count * 100)
        $client = $device.company.name
        switch ($device.serialnumber.Length) {
            7 { $WarState = get-DellWarranty -SourceDevice $device.serialnumber -client $Client }
            8 { $WarState = get-LenovoWarranty -SourceDevice $device.serialnumber -client $Client }
            10 { $WarState = get-HPWarranty  -SourceDevice $device.serialnumber -client $Client }
            12 { $WarState = Get-MSWarranty  -SourceDevice $device.serialnumber -client $Client }
        }
        if ($script:SyncWithSource -eq $true) {
            if (!$device.warrantyExpirationDate) {
                $device | Add-Member -NotePropertyName "warrantyExpirationDate" -NotePropertyValue "$($WarState.enddate)T00:00:00Z"
            }
            else { 
                $device.warrantyExpirationDate = "$($WarState.enddate)T00:00:00Z"
            }
            $CWBody = $device | ConvertTo-Json
            switch ($script:OverwriteWarranty) {
                $true {
                    if ($null -ne $warstate.EndDate) {
                        invoke-restmethod -headers $header -method put -uri "$($Global:CWAPIURL)/company/configurations/$($device.id)" -body $CWBody
                    }
                     
                }
                $false { 
                    if ($null -eq $device.WarrantyExpirationDate -and $null -ne $warstate.EndDate) { 
                        invoke-restmethod -headers $header -method put -uri "$($Global:CWAPIURL)/company/configurations/$($device.id)" -body $CWBody
                    } 
                }
            }
        }
        $WarState
    }
    return $warrantyObject
}
 
 
switch ($source) {
    AT { $warrantyObject = Get-WarrantyAutotask -APIKey $ATAPIKey | Sort-Object -Property Client }
    CSV { $warrantyObject = Get-WarrantyCSV -Sourcefile $sourcefile | Sort-Object -Property Client }
    ITG { $warrantyObject = Get-WarrantyITG | Sort-Object -Property Client }
    CW { $warrantyObject = Get-WarrantyCW | Sort-Object -Property Client }
}
write-host "Done updating warrenties. Generating reports if required." -ForegroundColor Green
$head = @"
<script>
function myFunction() {
    const filter = document.querySelector('#myInput').value.toUpperCase();
    const trs = document.querySelectorAll('table tr:not(.header)');
    trs.forEach(tr => tr.style.display = [...tr.children].find(td => td.innerHTML.toUpperCase().includes(filter)) ? '' : 'none');
  }</script>
<Title>Warranty Report</Title>
<style>
body { background-color:#E5E4E2;
      font-family:Monospace;
      font-size:10pt; }
td, th { border:0px solid black; 
        border-collapse:collapse;
        white-space:pre; }
th { color:white;
    background-color:black; }
table, tr, td, th {
     padding: 2px; 
     margin: 0px;
     white-space:pre; }
tr:nth-child(odd) {background-color: lightgray}
table { width:95%;margin-left:5px; margin-bottom:20px; }
h2 {
font-family:Tahoma;
color:#6D7B8D;
}
.footer 
{ color:green; 
 margin-left:10px; 
 font-family:Tahoma;
 font-size:8pt;
 font-style:italic;
}
#myInput {
  background-image: url('https://www.w3schools.com/css/searchicon.png'); /* Add a search icon to input */
  background-position: 10px 12px; /* Position the search icon */
  background-repeat: no-repeat; /* Do not repeat the icon image */
  width: 50%; /* Full-width */
  font-size: 16px; /* Increase font-size */
  padding: 12px 20px 12px 40px; /* Add some padding */
  border: 1px solid #ddd; /* Add a grey border */
  margin-bottom: 12px; /* Add some space below the input */
}
</style>
"@
   
$PreContent = @"
<H1> Warranty Report </H1> <br>
   
Please consult the report for more information. you can use the search window to find a specific device, date, or warranty state.
<br/>
<br/>
    
<input type="text" id="myInput" onkeyup="myFunction()" placeholder="Search...">
"@
   
 
if ($CreateHTMLReport -eq $true) {
    $CheckReportFolder = Test-Path($ReportsLocation)
    if (!$CheckReportFolder) { new-item -ItemType Directory -Path $ReportsLocation -Force | Out-Null }
    foreach ($client in $warrantyObject.client | Select-Object -Unique) {
        write-host "Generating report for $Client at $($ReportsLocation)\$client.html" -ForegroundColor Green
        $warrantyObject | Where-Object { $_.Client -eq $client } | convertto-html -Head $head -precontent $precontent | out-file "$($ReportsLocation)\$client.html"
    }
 
}

After executing the script, the HTML reports will look like this:

And that’s it! the script isn’t perfect yet and could use some more error handling which I’ll work on in the coming weeks but I really think this is a fantastic solution to get rid of warranty information providers that are asking big bucks. ūüôā

So as always, Happy PowerShelling!

Update 1: Added ITGlue as source.

Update 2: Added CW as resource

Update 3: Added some better logic for the script, also the script is on Github now! https://github.com/KelvinTegelaar/PowerShellWarrantyReports