Category Archives: Series: PowerShell Automation

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!

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 = "CW" #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"
    $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
    $token = $AuthResult.access_token
    $headersReq = @{ "Authorization" = "Bearer $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 (!$ReportsLocation) { 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

Documenting with PowerShell: Using PowerShell to create faster partner portal

I love having the ability to manage all clients from a single portal. My only issue is that the partner portal is quite error prone and sluggish, and it seems to get worse with each added client.

There are some more problems like only being able to find clients based on their name. So I’ve decided to make a quicker and a mostly simpler partner portal page. The page is a single HTML file with a search option. It allows you to access each of your clients portals using your credentials. The HTML page also allows search on all properties – I’ve added all domains in a hidden column so you can find clients based on the domain name too. Here’s how it looks:

There are two scripts; one that can run headless and uses the Secure App Model. Another that just uses your credentials.

Secure App Model Version

The reason I’ve made two versions is because we’re running a function page on azure that runs this script on a schedule.

Every day this page is updated for us and all my employees can access the page easily by browsing to the hosted page. I’ve of course made this page SSO with the Office365 credentials as described here. This way the page is always secured. Remember to change the last line to your own output folder.

#Related blog: https://www.cyberdrain.com/documenting-with-powershell-using-powershell-to-create-faster-partner-portal/
########################## Secure App Model Settings ############################
$ApplicationId = 'YourApplicationID'
$ApplicationSecret = 'YourApplicationSecret' | Convertto-SecureString -AsPlainText -Force
$TenantID = 'YourTenantID'
$RefreshToken = 'YourRefreshToken'
$UPN = "YourUPN"
########################## Secure App Model Settings ############################
   
$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
$CustomerLinks = foreach ($customer in $customers) {
    $domains = (Get-MsolDomain -TenantId $customer.tenantid).Name -join ',' | Out-String
    [pscustomobject]@{
        'Client Name'            = $customer.Name
        'Client tenant domain'   = $customer.DefaultDomainName
        'O365 Admin Portal'      = "<a target=`"_blank`" href=`"https://portal.office.com/Partner/BeginClientSession.aspx?CTID=$($customer.TenantId)&CSDEST=o365admincenter`">O365 Portal</a>"
        'Exchange Admin Portal'  = "<a target=`"_blank`" href=`"https://outlook.office365.com/ecp/?rfr=Admin_o365&exsvurl=1&amp;delegatedOrg=$($Customer.DefaultDomainName)`">Exchange Portal</a>"
        'Azure Active Directory' = "<a target=`"_blank`" href=`"https://aad.portal.azure.com/$($Customer.DefaultDomainName)`" >AAD Portal</a>"
        'MFA Portal (Read Only)' = "<a target=`"_blank`" href=`"https://account.activedirectory.windowsazure.com/usermanagement/multifactorverification.aspx?tenantId=$($Customer.tenantid)&culture=en-us&amp;requestInitiatedContext=users`" >MFA Portal</a>"
        'Sfb Portal'             = "<a target=`"_blank`" href=`"https://portal.office.com/Partner/BeginClientSession.aspx?CTID=$($Customer.TenantId)&CSDEST=MicrosoftCommunicationsOnline`">SfB Portal</a>"
        'Teams Portal'           = "<a target=`"_blank`" href=`"https://admin.teams.microsoft.com/?delegatedOrg=$($Customer.DefaultDomainName)`">Teams Portal</a>"
        'Azure Portal'           = "<a target=`"_blank`" href=`"https://portal.azure.com/$($customer.DefaultDomainName)`">Azure Portal</a>"
        'Intune portal'          = "<a target=`"_blank`" href=`"https://portal.azure.com/$($customer.DefaultDomainName)/#blade/Microsoft_Intune_DeviceSettings/ExtensionLandingBlade/overview`">Intune Portal</a>"
        'Domains'                = "Domains: $domains"
    }
}
  
$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>LNPP - Lime Networks Partner Portal</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> CyberDrain.com faster partner portal</H1> <br>
  
For more information, check <a href="https://www.cyberdrain.com/documenting-with-powershell-using-powershell-to-create-faster-partner-portal/"/>CyberDrain.com</a>
<br/>
<br/>
   
<input type="text" id="myInput" onkeyup="myFunction()" placeholder="Search...">
"@
  
  
$CustomerHTML = $CustomerLinks | ConvertTo-Html -head $head -PreContent $PreContent | Out-String
  
[System.Web.HttpUtility]::HtmlDecode($CustomerHTML) -replace "<th>Domains", "<th style=display:none;`">" -replace "<td>Domains", "<td style=display:none;`">Domains"  | out-file "C:\temp\index.html"

Non-Secure App Model version

This is a version that you can run on-demand, simply to create the HTML file and host it internally or if you don’t have rapid client expansion just run it whenever a client is added.

Connect-MsolService
$customers = Get-MsolPartnerContract -All
$CustomerLinks = foreach ($customer in $customers) {
    $domains = (Get-MsolDomain -TenantId $customer.tenantid).Name -join ',' | Out-String
    [pscustomobject]@{
        'Client Name'            = $customer.Name
        'Client tenant domain'   = $customer.DefaultDomainName
        'O365 Admin Portal'      = "<a target=`"_blank`" href=`"https://portal.office.com/Partner/BeginClientSession.aspx?CTID=$($customer.TenantId)&CSDEST=o365admincenter`">O365 Portal</a>"
        'Exchange Admin Portal'  = "<a target=`"_blank`" href=`"https://outlook.office365.com/ecp/?rfr=Admin_o365&exsvurl=1&amp;delegatedOrg=$($Customer.DefaultDomainName)`">Exchange Portal</a>"
        'Azure Active Directory' = "<a target=`"_blank`" href=`"https://aad.portal.azure.com/$($Customer.DefaultDomainName)`" >AAD Portal</a>"
        'MFA Portal (Read Only)' = "<a target=`"_blank`" href=`"https://account.activedirectory.windowsazure.com/usermanagement/multifactorverification.aspx?tenantId=$($Customer.tenantid)&culture=en-us&amp;requestInitiatedContext=users`" >MFA Portal</a>"
        'Sfb Portal'             = "<a target=`"_blank`" href=`"https://portal.office.com/Partner/BeginClientSession.aspx?CTID=$($Customer.TenantId)&CSDEST=MicrosoftCommunicationsOnline`">SfB Portal</a>"
        'Teams Portal'           = "<a target=`"_blank`" href=`"https://admin.teams.microsoft.com/?delegatedOrg=$($Customer.DefaultDomainName)`">Teams Portal</a>"
        'Azure Portal'           = "<a target=`"_blank`" href=`"https://portal.azure.com/$($customer.DefaultDomainName)`">Azure Portal</a>"
        'Intune portal'          = "<a target=`"_blank`" href=`"https://portal.azure.com/$($customer.DefaultDomainName)/#blade/Microsoft_Intune_DeviceSettings/ExtensionLandingBlade/overview`">Intune Portal</a>"
        'Domains'                = "Domains: $domains"
    }
}
  
$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>LNPP - Lime Networks Partner Portal</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> CyberDrain.com faster partner portal</H1> <br>
  
For more information, check <a href="https://www.cyberdrain.com/documenting-with-powershell-using-powershell-to-create-faster-partner-portal/"/>CyberDrain.com</a>
<br/>
<br/>
   
<input type="text" id="myInput" onkeyup="myFunction()" placeholder="Search...">
"@
  
  
$CustomerHTML = $CustomerLinks | ConvertTo-Html -head $head -PreContent $PreContent | Out-String
  
[System.Web.HttpUtility]::HtmlDecode($CustomerHTML) -replace "<th>Domains", "<th style=display:none;`">" -replace "<td>Domains", "<td style=display:none;`">Domains"  | out-file "C:\temp\index.html"

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

Automating with PowerShell: Automatically uploading applications to intune tenants

So I’ve been doubting if I should make this blog. I found that others had already done this and maybe my method would just be redundant. After some slight convincing I figured my method does have its merits. One of them being that it uses the secure application model, and thus its easy to apply to all partner tenants for a CSP, the other benefit is that this could run headless as a completely automated solution.

Most MSPs create a baseline of applications for their clients that’s the same across the entire stack, 7zip, Chrome, things that we believe should be installed by default. This script allows you to apply that baseline across all your tenants.

So to get start we’ll have to do a couple of things, just to make sure you have everything:

  • Setup the secure app model, and collect the information you’ll need
  • Grab all the installers you want to use and put them all in a folder, I use C:\intune\Applications, so 7zip would be C:\intune\aplications\7-zip
  • for each application you’ll need a new app.json. To create the app.json, you can use the example below.
  • You’ll need Azcopy, and IntuneWinAppUtil. The script also download it for you, but please host the files yourselves. 🙂

Filling out the JSON actually is not that hard; for most applications you’ll only need to replace the Displayname, InstallCommandLine, UninstallCommandLine, and detection rules. If you need help on all the options I’d suggest the Graph API manual.

If you are using a path, or illegal character you can escape these by adding “\” infront of it.

Example JSON

{

  "displayName": "CyberDrain.com 7Zip",
  "installCommandLine": "ninite.exe /Select \"7-zip\" /silent /disableshortcuts",
  "uninstallCommandLine": "ninite.exe /Select \"7-zip\" /silent /uninstall",
  "description": "Ninite Pro to Install 7zip.",
  "developer": "CyberDrain.com",
  "owner": "Cyberdrain.com",
  "informationUrl": "https://cyberdrain.com",
  "privacyInformationUrl": "https://cyberdrain.com",
  "fileName": "IntunePackage.intunewin",
  "@odata.type": "#microsoft.graph.win32LobApp",
  "applicableArchitectures": "x86, x64",

  "installExperience": {
    "runAsAccount": "user",
    "deviceRestartBehavior": "allow",
    "@odata.type": "microsoft.graph.win32LobAppInstallExperience"
  },
  "detectionRules": [
    {
  "@odata.type": "#microsoft.graph.win32LobAppFileSystemDetection",
  "path": "%programfiles%\\7-zip",
  "fileOrFolderName": "7z.exe",
  "check32BitOn64System": false,
  "detectionType": "exists" }
  ],
      "returncode":  [
                       {
                           "returnCode":  0,
                           "type":  "success",
                           "@odata.type":  "#microsoft.graph.win32LobAppReturnCode"
                       },
                       {
                           "returnCode":  1707,
                           "type":  "Success",
                           "@odata.type":  "#microsoft.graph.win32LobAppReturnCode"
                       },
                       {
                           "returnCode":  1641,
                           "type":  "hardReboot",
                           "@odata.type":  "#microsoft.graph.win32LobAppReturnCode"
                       },
                       {
                           "returnCode":  1618,
                           "type":  "retry",
                           "@odata.type":  "#microsoft.graph.win32LobAppReturnCode"
                       },
                       {
                           "returnCode":  3010,
                           "type":  "softReboot",
                           "@odata.type":  "#microsoft.graph.win32LobAppReturnCode"
                       }
					   ],
  "minimumNumberOfProcessors": "1",
  "minimumFreeDiskSpaceInMB": "8",
  "minimumCpuSpeedInMHz": "4",
  "minimumSupportedOperatingSystem": {
    "@odata.type": "microsoft.graph.windowsMinimumOperatingSystem",
    "v10_1607": true
  },
  "notes": "Loaded via cyberdrain.com application script",
  "minimumMemoryInMB": "1"

  
}

The script: Deploy Intune Applications.

So this script took some figuring out, I’ve been using the examples found here, and Ben Reader’s version right here. There’s some tricks we apply but the one you should be aware of is the padding – We pad the file with a 10mb file to make sure that we can upload using Azcopy. The script is completely headless, so just run it and it will upload all the apps.

The options are straight forward – just fill in all the information and run the script. You can even re-upload apps by changing $ContinueonExistingApp to true. The script currently runs for just the tenant you specify. That way, you can schedulde multiple scripts with different options for different clients. If you’d like a version for all tenants at the same time, let me know!

########################## Secure App Model Settings ############################
$ApplicationId = 'YourAppID'
$ApplicationSecret = 'YourAppSecret' | Convertto-SecureString -AsPlainText -Force
$TenantID = 'YourCSPTenantID'
$RefreshToken = 'yourverylongrefeshtoken'
$upn = 'UPN-Used-To-Generate-Tokens'
$CustomerTenantID = "YourCustomerTenant.onmicrosoft.com"
########################## Script Settings  ############################
$ApplicationFolder = "C:\intune\Applications"
$Baseuri = "https://graph.microsoft.com/beta/deviceAppManagement/mobileApps"
$AzCopyUri = "https://cyberdrain.com/wp-content/uploads/2020/04/azcopy.exe"
$IntuneWinAppUri = "https://cyberdrain.com/wp-content/uploads/2020/04/IntuneWinAppUtil.exe"
$ContinueOnExistingApp = $false
###################################################################
write-host "Checking AZCopy prerequisites and downloading these if required" -ForegroundColor Green
try {
    $AzCopyDownloadLocation = Test-Path "$ApplicationFolder\AzCopy.exe"
    if (!$AzCopyDownloadLocation) { 
        Invoke-WebRequest -UseBasicParsing -Uri $AzCopyUri -OutFile "$($ApplicationFolder)\AzCopy.exe" 
    }
}
catch {
    write-host "The download and extraction of AzCopy failed. The script will stop. Error: $($_.Exception.Message)"
    exit 1
}
write-host "Checking IntuneWinAppUtil prerequisites and downloading these if required" -ForegroundColor Green

try {
    $AzCopyDownloadLocation = Test-Path "$ApplicationFolder\IntuneWinAppUtil.exe"
    if (!$AzCopyDownloadLocation) { Invoke-WebRequest -UseBasicParsing -Uri $IntuneWinAppUri -OutFile "$($ApplicationFolder)\IntuneWinAppUtil.exe" }
}
catch {
    write-host "The download and extraction of IntuneWinApp failed. The script will stop. Error: $($_.Exception.Message)"
    exit 1
}

$credential = New-Object System.Management.Automation.PSCredential($ApplicationId, $ApplicationSecret)
write-host "Generating token to log into Intune" -ForegroundColor Green
$graphToken = New-PartnerAccessToken -ApplicationId $ApplicationId -Credential $credential -RefreshToken $refreshToken -Scopes 'https://graph.microsoft.com/.default' -ServicePrincipal -Tenant $CustomerTenantID
$Header = @{
    Authorization = "Bearer $($graphToken.AccessToken)"
}
$AppFolders = Get-ChildItem $ApplicationFolder -Directory 
foreach ($App in $AppFolders) {
    $intuneBody = get-content "$($app.fullname)\app.json"
    $Settings = $intuneBody | ConvertFrom-Json 
    write-host "Creating if intune package for $($app.name) does not exists." -ForegroundColor Green
    $ApplicationList = (Invoke-RestMethod -Uri $baseuri -Headers $Header -Method get -ContentType "application/json").value | where-object { $_.DisplayName -eq $settings.displayName }
    if ($ApplicationList.count -gt 1 -and $ContinueOnExistingApp -eq $false) { 
        write-host "$($app.name) exists. Skipping this application." -ForegroundColor yellow
        continue
    }
    write-host "Creating intune package for $($App.Name)" -ForegroundColor Green
    $bytes = 10MB
    [System.Security.Cryptography.RNGCryptoServiceProvider] $rng = New-Object System.Security.Cryptography.RNGCryptoServiceProvider
    $rndbytes = New-Object byte[] $bytes
    $rng.GetBytes($rndbytes)
    [System.IO.File]::WriteAllBytes("$($App.fullname)\dummy.dat", $rndbytes)
    $FileToExecute = $Settings.installCommandLine.split(" ")[0]
    start-process "$applicationfolder\IntuneWinAppUtil.exe" -argumentlist "-c $($App.FullName) -s $FileToExecute -o $($App.FullName)" -wait
    write-host "Creating Application on intune platform for $($App.Name)" -ForegroundColor Green
    $InTuneProfileURI = "$($BaseURI)"
    $NewApp = Invoke-RestMethod -Uri $InTuneProfileURI -Headers $Header -body $intuneBody -Method POST -ContentType "application/json"
    write-host "Getting encryption information for intune file for $($App.Name)" -ForegroundColor Green

    $intuneWin = get-childitem $App.fullname -Filter *.intunewin
    #unzip the detection.xml file to get manifest info and encryptioninfo.
    $Directory = [System.IO.Path]::GetDirectoryName("$($intuneWin.fullname)")
    Add-Type -Assembly System.IO.Compression.FileSystem
    $zip = [IO.Compression.ZipFile]::OpenRead("$($intuneWin.fullname)")
    $zip.Entries | Where-Object { $_.Name -like "Detection.xml" } | ForEach-Object {
        [System.IO.Compression.ZipFileExtensions]::ExtractToFile($_, "$Directory\Detection.xml", $true)
    }
    $zip.Dispose()
    $intunexml = get-content "$Directory\Detection.xml"
    remove-item  "$Directory\Detection.xml" -Force
    #Unzip the encrypted file to prepare for upload.
    $Directory = [System.IO.Path]::GetDirectoryName("$($intuneWin.fullname)")
    Add-Type -Assembly System.IO.Compression.FileSystem
    $zip = [IO.Compression.ZipFile]::OpenRead("$($intuneWin.fullname)")
    $zip.Entries | Where-Object { $_.Name -like "IntunePackage.intunewin" } | ForEach-Object {
        [System.IO.Compression.ZipFileExtensions]::ExtractToFile($_, "$Directory\IntunePackage.intunewin", $true)
    }
    $zip.Dispose()
    $ExtactedEncFile = (Get-Item "$Directory\IntunePackage.intunewin")
    $intunewinFileSize = (Get-Item "$Directory\IntunePackage.intunewin").Length
  
    $ContentBody = ConvertTo-Json @{
        name          = $intunexml.ApplicationInfo.FileName
        size          = [int64]$intunexml.ApplicationInfo.UnencryptedContentSize
        sizeEncrypted = [int64]$intunewinFileSize
    } 
    write-host "Uploading content information for $($App.Name)." -ForegroundColor Green

    $ContentURI = "$($BaseURI)/$($NewApp.id)/microsoft.graph.win32lobapp/contentVersions/1/files/"
    $ContentReq = Invoke-RestMethod -Uri $ContentURI -Headers $Header -body $ContentBody -Method POST -ContentType "application/json"
    write-host "Trying to get file uri for $($App.Name)." -ForegroundColor Green
    do {
        write-host "Still trying to get file uri for $($App.Name) Please wait." -ForegroundColor Green
        $AzFileUriCheck = "$($BaseURI)/$($NewApp.id)/microsoft.graph.win32lobapp/contentVersions/1/files/$($ContentReq.id)"
        $AzFileUri = Invoke-RestMethod -Uri $AzFileUriCheck -Headers $Header -Method get -ContentType "application/json"
        if ($AZfileuri.uploadState -like "*fail*") { break }
        start-sleep 5
    } while ($AzFileUri.AzureStorageUri -eq $null) 
    write-host "Retrieved upload URL. Uploading package $($App.Name) via AzCopy." -ForegroundColor Green

    $UploadResults = & "$($ApplicationFolder)\azCopy.exe" cp "$($ExtactedEncFile.fullname)" "$($Azfileuri.AzureStorageUri)"  --block-size-mb 4 --output-type 'json'    
    remove-item @($intunewin.fullname, $ExtactedEncFile) -Force
    start-sleep 2

    write-host "File uploaded. Commiting $($App.Name) with Encryption Info" -ForegroundColor Green

    $EncBody = @{
        fileEncryptionInfo = @{
            encryptionKey        = $intunexml.ApplicationInfo.EncryptionInfo.EncryptionKey
            macKey               = $intunexml.ApplicationInfo.EncryptionInfo.MacKey
            initializationVector = $intunexml.ApplicationInfo.EncryptionInfo.InitializationVector
            mac                  = $intunexml.ApplicationInfo.EncryptionInfo.Mac
            profileIdentifier    = $intunexml.ApplicationInfo.EncryptionInfo.ProfileIdentifier
            fileDigest           = $intunexml.ApplicationInfo.EncryptionInfo.FileDigest
            fileDigestAlgorithm  = $intunexml.ApplicationInfo.EncryptionInfo.FileDigestAlgorithm
        }
    } | ConvertTo-Json
    $CommitURI = "$($BaseURI)/$($NewApp.id)/microsoft.graph.win32lobapp/contentVersions/1/files/$($ContentReq.id)/commit"
    $CommitReq = Invoke-RestMethod -Uri $CommitURI -Headers $Header -body $EncBody -Method POST -ContentType "application/json"

    write-host "Waiting for file commit results for $($App.Name)." -ForegroundColor Green

    do {
        write-host "Still trying to get commit state. Please wait." -ForegroundColor Green

        $CommitStateURL = "$($BaseURI)/$($NewApp.id)/microsoft.graph.win32lobapp/contentVersions/1/files/$($ContentReq.id)"
        $CommitStateReq = Invoke-RestMethod -Uri $CommitStateURL -Headers $Header -Method get -ContentType "application/json"
        if ($CommitStateReq.uploadState -like "*fail*") { write-host "Commit Failed for $($App.Name). Moving on to Next application. Manual intervention will be required" -ForegroundColor red; break }
        start-sleep 10
    } while ($CommitStateReq.uploadState -eq "commitFilePending") 
    if ($CommitStateReq.uploadState -like "*fail*") { continue }
    write-host "Commiting application version" -ForegroundColor Green
    $ConfirmBody = @{
        "@odata.type"             = "#microsoft.graph.win32lobapp"
        "committedContentVersion" = "1"
    } | Convertto-Json
    $CommitFinalizeURI = "$($BaseURI)/$($NewApp.id)"
    $CommitFinalizeReq = Invoke-RestMethod -Uri $CommitFinalizeURI -Headers $Header -body $Confirmbody -Method PATCH -ContentType "application/json"
    write-host "Deployment completed for app $($app.name). You can assign this app to users now." -ForegroundColor Green
}

So if you combine this with my earlier autopilot automation blog, you could easily setup the entire autopilot experience, with very little effort. And that’s it! as always, Happy PowerShelling.

Automating with PowerShell: Automating intune Autopilot configuration

So yesterday I was watching a webinar about intune and Autopilot. Autopilot is pretty cool for MSPs because it becomes fairly simple to give users a nice OOBE. It also makes setup for devices a lot less of a hassle, the only issue that was spoken about during the webinar is that there is still a lot of manual clicking.

This is why I took it upon myself to create some automation scripts for AutoPilot. We’re using the Graph API so you’ll need to setup your Secure Application Model before you can use these scripts. You’ll also need to perform the following steps to add a permission to the application:

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

Importing Device ID’s for autopilot

So one of the first things I noticed is that engineers are still entering device ID’s manually, or uploading CSV files by logging into the portal, etc. That seems like a bit of an hassle to me, so for that you can use the following script.

First you’ll have to create your import CSV file, Most of the time this can be downloaded from your supplier, or requested. When you have this file, execute the script.

########################## Office 365 ############################
$ApplicationId = 'YourApplicationID'
$ApplicationSecret = 'SecretApplicationSecret' | Convertto-SecureString -AsPlainText -Force
$TenantID = 'YourTenantID'
$RefreshToken = 'SuperSecretRefreshToken'
$upn = 'UPN-Used-To-Generate-Tokens'
$CustomerTenantID = "YOURCLIENTSTENANT.onmicrosoft.com"
$CSVFilePath = "C:\Temp\Import-CSV-YourClientsTenant.txt"
########################## Office 365 ############################
$credential = New-Object System.Management.Automation.PSCredential($ApplicationId, $ApplicationSecret)
write-host "Generating token to log into Intune" -ForegroundColor Green
$graphToken = New-PartnerAccessToken -ApplicationId $ApplicationId -Credential $credential -RefreshToken $refreshToken -Scopes 'https://graph.microsoft.com/.default' -ServicePrincipal -Tenant $CustomerTenantID
$Header = @{
    Authorization = "Bearer $($graphToken.AccessToken)"
}
write-host "Importing CSV File." -ForegroundColor Green
$CsvFile = Import-Csv $CSVFilePath -Delimiter ","
#$RandGuid = [guid]::NewGuid()
foreach ($Line in $CsvFile) {
    $intuneBody = @{
        orderIdentifier    = "CyberDrain.com Import Script"
        serialNumber       = $line.'Device Serial Number'
        productKey         = $line.'Windows Product ID'
        hardwareIdentifier = $Line.'Hardware Hash'
        state              = @{
            deviceImportStatus   = 'Complete'
            deviceRegistrationId = '1'
            deviceErrorCode      = '15'
            deviceErrorName      = "No Errors Detected"
        }
    } | ConvertTo-Json

    $InTuneDevicesURI = "https://graph.microsoft.com/beta/deviceManagement/importedWindowsAutopilotDeviceIdentities"
    Invoke-RestMethod -Uri $InTuneDevicesURI -Headers $Header -body $intuneBody -Method POST -ContentType "application/json"

}

After executing this script, it’ll appear in your client’s intune portal like this. This can take some time sometimes because of the Sync on the Microsoft side. You can force this by hitting the “Sync” button.

You could easily automate this script to run on a schedule, and just replace the CSV file whenever you want. That way you import your devices with zero-touch.

Adding a Windows Deployment Autopilot Profile

One of the annoying repetitive tasks when you start setting up Autopilot is the Autopilot profile. This script will automate that for you and set the settings to your wishes. I don’t directly apply the profile, because sometimes you just want to make some tiny edits per client before applying.

########################## Office 365 ############################
$ApplicationId = 'YourApplicationID'
$ApplicationSecret = 'SecretApplicationSecret' | Convertto-SecureString -AsPlainText -Force
$TenantID = 'YourTenantID'
$RefreshToken = 'SuperSecretRefreshToken'
$upn = 'UPN-Used-To-Generate-Tokens'
$CustomerTenantID = "YOURCLIENTSTENANT.onmicrosoft.com"
########################## Office 365 ############################
$credential = New-Object System.Management.Automation.PSCredential($ApplicationId, $ApplicationSecret)
write-host "Generating token to log into Intune" -ForegroundColor Green
$graphToken = New-PartnerAccessToken -ApplicationId $ApplicationId -Credential $credential -RefreshToken $refreshToken -Scopes 'https://graph.microsoft.com/.default' -ServicePrincipal -Tenant $CustomerTenantID
$Header = @{
    Authorization = "Bearer $($graphToken.AccessToken)"
}
write-host "Creating Deployment Profile" -ForegroundColor Green

$intuneBody = @{
    "@odata.type"                          = "#microsoft.graph.activeDirectoryWindowsAutopilotDeploymentProfile"
    displayName                            = "CyberDrain.com Default Profile"
    description                            = "This deployment profile has been created by the CyberDrain.com Profile Deployment script"
    language                               = 'EN'
    hybridAzureADJoinSkipConnectivityCheck = $true
    extractHardwareHash                    = $true
    enableWhiteGlove                       = $true
    outOfBoxExperienceSettings             = @{
        "@odata.type"             = "microsoft.graph.outOfBoxExperienceSettings"
        hidePrivacySettings       = $true
        hideEULA                  = $true
        userType                  = 'Standard'
        deviceUsageType           = 'Shared'
        skipKeyboardSelectionPage = $true
        hideEscapeLink            = $true
    }
    enrollmentStatusScreenSettings         = @{
        '@odata.type'                                    = "microsoft.graph.windowsEnrollmentStatusScreenSettings"
        hideInstallationProgress                         = $true
        allowDeviceUseBeforeProfileAndAppInstallComplete = $true
        blockDeviceSetupRetryByUser                      = $false
        allowLogCollectionOnInstallFailure               = $true
        customErrorMessage                               = "An error has occured. Please contact your IT Administrator"
        installProgressTimeoutInMinutes                  = "15"
        allowDeviceUseOnInstallFailure                   = $true
    }
} | ConvertTo-Json

$InTuneProfileURI = "https://graph.microsoft.com/beta/deviceManagement/windowsAutopilotDeploymentProfiles"
Invoke-RestMethod -Uri $InTuneProfileURI -Headers $Header -body $intuneBody -Method POST -ContentType "application/json"

And that’s it! Maybe I’ll focus on creating applications via PowerShell and assigning them next time. If you have any requests, let me know!

As always, Happy PowerShelling