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!

Documenting with PowerShell: Documenting intune applications

So I’ve been writing about intune for a couple of times now, and figured I’d make another documentation blog. We’re going to document the applications. As always I will show you both IT-Glue, and a generic HTML version.

so the use case for this is so you can easily show your clients which applications you deploy. Its also easy to log into the documentation system and check exactly who has access to each application so you won’t have to directly grab the intune portal.

To get started you’ll need the secure application model for this script. If you use the IT-Glue version you will also need the API key.

IT-Glue version

########################## IT-Glue ############################
$APIKEy = "YourITGlueAPIKey"
$APIEndpoint = "https://api.eu.itglue.com"
$FlexAssetName = "intune - Application documentation v1"
$Description = "Documentation for all registered intune applications"
########################## IT-Glue ############################

########################## Secure App Model Settings ############################
$ApplicationId = 'YourApplicationID'
$ApplicationSecret = 'yourApplicationsecret' | Convertto-SecureString -AsPlainText -Force
$TenantID = 'YourtenantID'
$RefreshToken = 'verylongrefreshtoken'
$upn = 'UPN-Used-To-Generate-Tokens'
########################## Secure App Model Settings ############################

write-host "Grabbing IT-Glue module" -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 $APIEndpoint
Add-ITGlueAPIKey $APIKEy
 
write-host "Checking if Flexible Asset exists in IT-Glue." -foregroundColor green
$FilterID = (Get-ITGlueFlexibleAssetTypes -filter_name $FlexAssetName).data
if (!$FilterID) { 
    write-host "Does not exist, creating new." -foregroundColor green
    $NewFlexAssetData = 
    @{
        type          = 'flexible-asset-types'
        attributes    = @{
            name        = $FlexAssetName
            icon        = 'sitemap'
            description = $description
        }
        relationships = @{
            "flexible-asset-fields" = @{
                data = @(
                    @{
                        type       = "flexible_asset_fields"
                        attributes = @{
                            order           = 1
                            name            = "Tenant name"
                            kind            = "Text"
                            required        = $true
                            "show-in-list"  = $true
                            "use-for-title" = $true
                        }
                    },
                    @{
                        type       = "flexible_asset_fields"
                        attributes = @{
                            order          = 2
                            name           = "Tenant ID"
                            kind           = "Text"
                            required       = $false
                            "show-in-list" = $false
                        }
                    },
                    @{
                        type       = "flexible_asset_fields"
                        attributes = @{
                            order          = 3
                            name           = "Application info"
                            kind           = "Textbox"
                            required       = $false
                            "show-in-list" = $false
                        }
                    }
                    


                )
            }
        }
    }
    New-ITGlueFlexibleAssetTypes -Data $NewFlexAssetData 
    $FilterID = (Get-ITGlueFlexibleAssetTypes -filter_name $FlexAssetName).data
}

#Grab all IT-Glue contacts to match the domain name.
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 token to log into Azure AD. Grabbing all tenants" -ForegroundColor Green

$credential = New-Object System.Management.Automation.PSCredential($ApplicationId, $ApplicationSecret)
$Baseuri = "https://graph.microsoft.com/beta"
$aadGraphToken = New-PartnerAccessToken -ApplicationId $ApplicationId -Credential $credential -RefreshToken $refreshToken -Scopes 'https://graph.windows.net/.default' -ServicePrincipal -Tenant $tenantID 
$graphToken = New-PartnerAccessToken -ApplicationId $ApplicationId -Credential $credential -RefreshToken $refreshToken -Scopes 'https://graph.microsoft.com/.default' -ServicePrincipal -Tenant $tenantID 
Connect-AzureAD -AadAccessToken $aadGraphToken.AccessToken -AccountId $upn -MsAccessToken $graphToken.AccessToken -TenantId $tenantID | Out-Null
$tenants = Get-AzureAdContract -All:$true
Disconnect-AzureAD
foreach ($Tenant in $Tenants) {
    $CustAadGraphToken = New-PartnerAccessToken -ApplicationId $ApplicationId -Credential $credential -RefreshToken $refreshToken -Scopes "https://graph.windows.net/.default" -ServicePrincipal -Tenant $tenant.CustomerContextId
    $CustGraphToken = New-PartnerAccessToken -ApplicationId $ApplicationId -Credential $credential -RefreshToken $refreshToken -Scopes "https://graph.microsoft.com/.default" -ServicePrincipal -Tenant $tenant.CustomerContextId
    Connect-AzureAD -AadAccessToken $CustAadGraphToken.AccessToken -AccountId $upn -MsAccessToken $CustGraphToken.AccessToken -TenantId $tenant.CustomerContextId | out-null
    write-host "Starting documentation process for $($Tenant.Displayname)" -ForegroundColor Green
    $Header = @{
        Authorization = "Bearer $($CustGraphToken.AccessToken)"
    }

    write-host "Grabbing all applications for $($Tenant.Displayname)." -ForegroundColor Green
    try {
        $ApplicationList = (Invoke-RestMethod -Uri "$baseuri/deviceAppManagement/mobileApps/?`$expand=categories,assignments" -Headers $Header -Method get -ContentType "application/json").value | Where-Object { $_.'@odata.type' -eq "#microsoft.graph.win32LobApp" }
    }
    catch {
        write-host "     Could not grab application list for $($Tenant.Displayname). Is intune configured? Error was: $($_.Exception.Message)" -ForegroundColor Yellow
        continue
    }
    $Applications = foreach ($Application in $ApplicationList) {
        write-host "              grabbing Application Assignment for $($Application.displayname)" -ForegroundColor Green
        $GroupsRequired = foreach ($ApplicationAssign in $Application.assignments | where-object { $_.intent -eq "Required" }) {
            (Invoke-RestMethod -Uri "$baseuri/groups/$($Applicationassign.target.groupId)" -Headers $Header -Method get -ContentType "application/json").value.displayName
        }
        $GroupsAvailable = foreach ($ApplicationAssign in $Application.assignments | where-object { $_.intent -eq "Available" }) {
            (Invoke-RestMethod -Uri "$baseuri/groups/$($Applicationassign.target.groupId)" -Headers $Header -Method get -ContentType "application/json").value.displayName
        }
        [pscustomobject]@{
            Displayname               = $Application.Displayname
            description               = $Application.description
            Publisher                 = $application.Publisher
            "Featured Application"    = $application.IsFeatured
            Notes                     = $Application.notes
            "Application is assigned" = $application.isassigned
            "Install Command"         = $Application.InstallCommandLine
            "Uninstall Command"       = $Application.Uninstallcommandline
            "Architectures"           = $Application.applicableArchitectures
            "Created on"              = $Application.createdDateTime
            "Last Modified"           = $Application.LastModifieddatetime
            "Privacy Information URL" = $Application.PrivacyInformationURL
            "Information URL"         = $Application.PrivacyInformationURL
            "Required for group"      = $GroupsRequired -join "`n'"
            "Available to group"      = $GroupsAvailable -join "`n"
        } 

    }
    $TableStyling = "<th>", "<th> <style=`"background-color:#4CAF50`">"
    $AppHTML = ($applications | convertto-html -Fragment | out-string) -replace $TableStyling

    $FlexAssetBody =
    @{
        type       = 'flexible-assets'
        attributes = @{
            traits = @{
                'tenant-name'      = $tenant.DisplayName
                'tenant-id'        = $tenant.CustomerContextId
                'application-info' = $AppHTML
            }
        }
    }
    $customerdomains = get-azureaddomain
    $PrimaryDomain = ($customerdomains | Where-Object { $_.IsDefault -eq $true }).name
    Write-Host "          Finding $($customer.name) in IT-Glue" -ForegroundColor Green
    $orgID = @()
    foreach ($customerDomain in $customerdomains) {
        $orgID += ($AllITGlueContacts | Where-Object { $_.'contact-emails'.value -match $customerDomain.name }).'organization-id' | Select-Object -Unique
    }
    write-host "             Uploading Application list $($customer.name) into IT-Glue"  -ForegroundColor Green
    foreach ($org in $orgID) {
        $ExistingFlexAsset = (Get-ITGlueFlexibleAssets -filter_flexible_asset_type_id $($filterID.ID) -filter_organization_id $org).data | Where-Object { $_.attributes.traits.'tenant-id' -eq $tenant.CustomerContextId }
        #If the Asset does not exist, we edit the body to be in the form of a new asset, if not, we just upload.
        if (!$ExistingFlexAsset) {
            $FlexAssetBody.attributes.add('organization-id', $org)
            $FlexAssetBody.attributes.add('flexible-asset-type-id', $($filterID.ID))
            write-host "                      Creating new Application list $($customer.name) into IT-Glue organisation $org" -ForegroundColor Green
            New-ITGlueFlexibleAssets -data $FlexAssetBody
        }
        else {
            write-host "                      Updating Application list$($customer.name) into IT-Glue organisation $org"  -ForegroundColor Green
            $ExistingFlexAsset = $ExistingFlexAsset | select-object -last 1
            Set-ITGlueFlexibleAssets -id $ExistingFlexAsset.id -data $FlexAssetBody
        }
        Disconnect-AzureAD
    }


}

So, the script runs for all your tenants, this creates the flexible asset for you, grabs all applications and uploads them to IT-Glue.

Generic version

########################## Secure App Model Settings ############################
$ApplicationId = 'YourApplicationID'
$ApplicationSecret = 'yourApplicationsecret' | Convertto-SecureString -AsPlainText -Force
$TenantID = 'YourtenantID'
$RefreshToken = 'verylongrefreshtoken'
$upn = 'yourupn'
########################## Secure App Model Settings ############################
write-host "Generating token to log into Azure AD. Grabbing all tenants" -ForegroundColor Green

$credential = New-Object System.Management.Automation.PSCredential($ApplicationId, $ApplicationSecret)
$Baseuri = "https://graph.microsoft.com/beta"
$aadGraphToken = New-PartnerAccessToken -ApplicationId $ApplicationId -Credential $credential -RefreshToken $refreshToken -Scopes 'https://graph.windows.net/.default' -ServicePrincipal -Tenant $tenantID 
$graphToken = New-PartnerAccessToken -ApplicationId $ApplicationId -Credential $credential -RefreshToken $refreshToken -Scopes 'https://graph.microsoft.com/.default' -ServicePrincipal -Tenant $tenantID 
Connect-AzureAD -AadAccessToken $aadGraphToken.AccessToken -AccountId $upn -MsAccessToken $graphToken.AccessToken -TenantId $tenantID
$PreContent = @"
<H1> Graph Application Documentation</H1><br>

<br>Please note that this documentation only includes windows line-of-business applications and excludes the default applications such as ios and android applications.
<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>
"@
$tenants = Get-AzureAdContract -All:$true



foreach ($Tenant in $Tenants) {
    write-host "Starting documentation process for $($Tenant.Displayname)" -ForegroundColor Green
    $CustomergraphToken = New-PartnerAccessToken -ApplicationId $ApplicationId -Credential $credential -RefreshToken $refreshToken -Scopes 'https://graph.microsoft.com/.default' -ServicePrincipal -Tenant $Tenant.CustomerContextId
    $Header = @{
        Authorization = "Bearer $($CustomergraphToken.AccessToken)"
    }

    write-host "Grabbing all applications for $($Tenant.Displayname)." -ForegroundColor Green
    try {
        $ApplicationList = (Invoke-RestMethod -Uri "$baseuri/deviceAppManagement/mobileApps/?`$expand=categories,assignments" -Headers $Header -Method get -ContentType "application/json").value | Where-Object {$_.'@odata.type' -eq "#microsoft.graph.win32LobApp"}
    }
    catch {
        write-host "     Could not grab application list for $($Tenant.Displayname). Is intune configured? Error was: $($_.Exception.Message)" -ForegroundColor Yellow
        continue
    }
    $Applications = foreach ($Application in $ApplicationList) {
        write-host "              grabbing Application Assignment for $($Application.displayname)" -ForegroundColor Green
        $GroupsRequired = foreach ($ApplicationAssign in $Application.assignments | where-object { $_.intent -eq "Required" }) {
            (Invoke-RestMethod -Uri "$baseuri/groups/$($Applicationassign.target.groupId)" -Headers $Header -Method get -ContentType "application/json").value.displayName
        }
        $GroupsAvailable = foreach ($ApplicationAssign in $Application.assignments | where-object { $_.intent -eq "Available" }) {
            (Invoke-RestMethod -Uri "$baseuri/groups/$($Applicationassign.target.groupId)" -Headers $Header -Method get -ContentType "application/json").value.displayName
        }
        [pscustomobject]@{
            Displayname               = $Application.Displayname
            description               = $Application.description
            Publisher                 = $application.Publisher
            "Featured Application"    = $application.IsFeatured
            Notes                     = $Application.notes
            "Application is assigned" = $application.isassigned
            "Install Command"         = $Application.InstallCommandLine
            "Uninstall Command"       = $Application.Uninstallcommandline
            "Architectures"           = $Application.applicableArchitectures
            "Created on"              = $Application.createdDateTime
            "Last Modified"           = $Application.LastModifieddatetime
            "Privacy Information URL" = $Application.PrivacyInformationURL
            "Information URL"         = $Application.PrivacyInformationURL
            "Required for group"      = $GroupsRequired -join "`n'"
            "Available to group"      = $GroupsAvailable -join "`n"
        } 

    }
    $applications | ConvertTo-Html -head $head -PreContent $PreContent | out-file "C:\temp\$($Tenant.Displayname).html"

}

You could use this generic HTML version for your own documentation system. I’ve included a screenshot of how it looks because this was asked for in the past 🙂

And 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.

Monitoring with PowerShell: Monitoring DFSR status

There were a couple of projects I was working on, one of them being a method to upload intune applications to all tenants in a CSP. Turns out that someone else already made that so I won’t be blogging about it directly. If your interested in that you can check out this link. This did swallow most of my time, so instead I figured I’d publish a smaller blog today.

So instead we’re going to focus on monitoring something else entirely; DFS Replication. DFS is somewhat dated when compared to cool new tech like Azure File Sync, and others like that but still used in a lot of environments. Even in our move to cloud only environments I still think we have a handful of DFS servers running.

This small script will assist you in finding DFS machines that are having an increased backlog, connection issues, or folder issues.

$MaxBackLog = "100"
$DFSFiles = Get-DfsrState
$DFSBackLogHealth = if ($DFSFiles.count -gt $Maxbacklog) { "There are more than $Maxbacklog in the backlog" } else { "Healthy" }

$connections = Get-DfsrConnection | Where-Object {$_.state -ne  'normal'}
$DFSConnectionHealth = if($Connections) { "Fault connections found. Please investigate" } else { "Healthy" }


$Folders = Get-DfsReplicatedFolder | Where-Object {$_.state -ne  'normal'}
$DFSFolderHealth = if($Folders) { "Faulty folder found. Please investigate" } else { "Healthy" }

I know it’s a little shorter than my normal blogs. I just couldn’t find a new cool subject to talk about. If you have any suggestions please let me know. I’m still doing requests 🙂

Monitoring with PowerShell: Monitor SSL certificates

A friend of mine recently asked if I had a solution for monitoring ADFS and exchange certificates. Funnily this was actually a challenge I’ve struggled with in the past. The problem is that each of these systems has their own way of getting the currently installed certificates.

So when I was playing around creating a list of all commands that we could use to collect the certificates, I remembered an earlier trick I used to check the bindings for SSTP certificates. Using netsh is an easy way to grab all the current bindings, independent of all cmdlets supplied by products directly.

The only problem with the netsh commands that we need to use is that these are not converted to a Powershell native command yet. That means it’s pretty much up to us to play with the strings to get the results we want. Using the script below, you can monitor all the current certificate bindings on a server. Be it Exchange, ADFS, SSTP, or whatever else.

Change the $days variable to how many days before expiry you want to get alerted on.

$days = (Get-Date).AddDays(14)
$TxtBindings = (& netsh http show sslcert) | select-object -skip 3 | out-string
$nl = [System.Environment]::NewLine
$Txtbindings = $TxtBindings -split "$nl$nl"
$BindingsList = foreach ($Binding in $Txtbindings) {
    if ($Binding -ne "") {
        $Binding = $Binding -replace "  ", "" -split ": "
        [pscustomobject]@{
            IPPort          = ($Binding[1] -split "`n")[0] 
            CertificateHash = ($Binding[2] -split "`n" -replace '[^a-zA-Z0-9]', '')[0] 
            AppID           = ($Binding[3] -split "`n")[0]
            CertStore       = ($Binding[4] -split "`n")[0] 
        }
    }
}

if ($BindingsList.Count -eq 0) { 
    $CertState = "Healthy - No certificate bindings found."
    exit 0
}

$CertState = foreach ($bind in $bindingslist) {
    $CertFile = Get-ChildItem -path "CERT:LocalMachine\MY" | Where-Object -Property ThumbPrint -eq $bind.CertificateHash
    if ($certfile.NotAfter -lt $Days) { "$($certfile.FriendlyName) / $($certfile.thumbprint) will expire on $($certfile.NotAfter)" }    
}

You might want to alert on other things, so feel free to customize the script of course. And that’s it. As always, Happy PowerShelling!

Monitoring with PowerShell: Monitoring Rogue DHCP Servers

So recently we’ve had an issue with a co-managed client where a network projector suddenly started running a DHCP server on the network. Normally speaking this would get picked up by DHCP guarding options we set up on our unifi stack. In this case the DHCP guarding options did not work. So this was the result of the client not wanting to have their network managed by us.

The issue was solved and the client is letting us do all of the network management now, but this did turn into a thought exercise for me; how could I detect a rogue DHCP server using PowerShell, and alert on it?

The answer was not as simple as I expected; a lot of people tried to simulate DHCP discover requests and capture them, or use DHCPLOC. DHCPLOC is no longer supported and tends to break DHCP servers. After a couple hours of looking around I found this blog by CyberShadow. CyberShadow’s blog is from 2013, but he still updates the utility that you can find on his Github. Using his utility we can perform DHCP Discovers and find out if a different server is serving clients.

The Script

The script will download CyberShadow’s DHCP test client for you, run 3 discoveries and compare the results with the server you’ve given as “Allowed”.

$AllowedDHCPServer = "192.168.15.1"

#Replace the Download URL to where you've uploaded the DHCPTest file yourself. We will only download this file once. 
$DownloadURL = "https://cyberdrain.com/wp-content/uploads/2020/04/dhcptest-0.7-win64.exe"
$DownloadLocation = "$($Env:ProgramData)\DHCPTest"
try {
    $TestDownloadLocation = Test-Path $DownloadLocation
    if (!$TestDownloadLocation) { new-item $DownloadLocation -ItemType Directory -force }
    $TestDownloadLocationZip = Test-Path "$DownloadLocation\DHCPTest.exe"
    if (!$TestDownloadLocationZip) { Invoke-WebRequest -UseBasicParsing -Uri $DownloadURL -OutFile "$($DownloadLocation)\DHCPTest.exe" }
}
catch {
    write-host "The download and extraction of DHCPTest failed. Error: $($_.Exception.Message)"
    exit 1
}
$Tests = 0
$ListedDHCPServers = do {
    & "$DownloadLocation\DHCPTest.exe" --quiet --query --print-only 54 --wait --timeout 3
    $Tests ++
} while ($Tests -lt 2)

$DHCPHealth = foreach ($ListedServer in $ListedDHCPServers) {
    if ($ListedServer -ne $AllowedDHCPServer) { "Rogue DHCP Server found. IP of rogue server is $ListedServer" }
}

if (!$DHCPHealth) { $DHCPHealth = "Healthy. No Rogue DHCP servers found." }

So that’s it! Monitoring rogue DHCP servers becomes easy this way. As always, Happy PowerShelling!

Monitoring with PowerShell: Monitoring DNS record changes

Short one today, as I’m super busy with regular work. 🙂

At my company we use a dynamic DNS service for load balancing or failover. This is great because clients don’t notice downtime when a failover or load balancing action occurs. It makes everything smooth and fluent for clients and users of our systems.

The only problem with this is that sometimes a failover occurs and you don’t notice. Great for users, not so great for administrators; if a server crashes in the woods, did it really crash at all?

To make sure we always get notified about these situations we use the following script via our RMM system.

$DomainsToTest = @("remote.clientname.com", "clientswebsite.com")
New-item "C:\ProgramData\DNSTestLog" -ItemType Directory -erroraction SilentlyContinue -Force | out-null

$DNSHealth = foreach ($DomainToTest in $DomainsToTest) {

    Clear-DnsClientCache

    $PreviousDNS = get-content "C:\ProgramData\DNSTestLog\$($DomainToTest).txt" -ErrorAction SilentlyContinue
    if (!$PreviousDNS) { 
        write-host "No previous file found. Creating file. Compare will fail."
        "" | Out-File "C:\ProgramData\DNSTestLog\$($DomainToTest).txt"
    }
    $DNSResults = (Resolve-dnsname -name $DomainToTest -Type A -NoHostsFile).IP4Address
    $DNSResults | Out-File "C:\ProgramData\DNSTestLog\$($DomainToTest).txt"
    if ($PreviousDNS -ne $DNSResults) {
        "$DomainToTest does not equal the previous result."
    }

}

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

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

Monitoring with PowerShell: Monitoring client VPN settings

So with all that’s going on a lot of people are having trouble keeping up with setting up VPNs correctly. I’ve also struggled with clients that do not have a cloud only solution but are still on a hybrid method of working.

In the past I’ve talked about Always On VPN which we tend to deploy at clients. This, and even just SSTP connections are our most used VPN method. I tend to like Microsoft solutions for everything. 😉 In any case – We’ve been having trouble with this too. Some people suggest using CMAK to assist in deploying VPN. Of course like using my RMM system instead. 😉

As with most of the blogs I’ve created two scripts; one for monitoring and one for remediation.

The monitoring script

In our RMM we can give each monitoring script a set of input variables. Using these input variables we check if the VPN is set the way we want it. If you can’t setup input variables on your RMM, just change them in the script.

$Settings = @{
    name                  = "Client based VPN"
    alluserconnection     = $true
    ServerAddress         = "remote.clientname.com"
    TunnelType            = "SSTP" #Can be: Automatic, Ikev2m L2TP, PPTP,SSTP.
    SplitTunneling        = $True 
    UseWinLogonCredential = $true
    #There's a lot more options to set/monitor. Investigate for your own settings.
}
$VPN = Get-VPNconnection -name $($Settings.name) -AllUserConnection -ErrorAction SilentlyContinue
if (!$VPN) {
    $VPNHealth = "Unhealthy - Could not find VPN Connection."    
} 
else {
    $ExpectedVPNSettings = New-Object PSCustomObject -property $Settings
    $Selection = $propsToCompare = $ExpectedVPNSettings.psobject.properties.name
    $CurrentVPNSettings = $VPN | Select-object $Selection
    $CompareVPNSettings = compare-object $CurrentVPNSettings  $ExpectedVPNSettings -Property $Selection
    if (!$CompareVPNSettings) { $VPNHealth = "Healthy" } else { $VPNHealth = "Unhealthy - Settings do not match." }
}

So now that you are monitoring the VPN connection and if the settings are correct, we’re moving on to the remediation or setup side of the house.

Remediation script

the remediation works by looking up the current VPN connections based on the name property, if the VPN does not yet exists we will add one. If it does exists, we will reset the settings to the way we would like them to be.

$Settings = @{
    name                  = "Client based VPN"
    alluserconnection     = $true
    ServerAddress         = "remote.clientname.com"
    TunnelType            = "SSTP" #Can be: Automatic, Ikev2m L2TP, PPTP,SSTP.
    SplitTunneling        = $True 
    UseWinLogonCredential = $true
    #There's a lot more options to set/monitor. Investigate for your own settings.
}
$VPN = Get-VPNconnection -name $($Settings.name) -AllUserConnection -ErrorAction SilentlyContinue
if (!$VPN) {
    Add-VPNconnection @Settings -verbose
}
else {
    Set-VpnConnection @settings -Verbose
}

What’s cool is that these scripts work for any VPN that uses the Windows VPN client. This makes it super simple to deploy and monitor your clients VPN connections, and always have the same settings across your entire customer base.

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

Monitoring with PowerShell: monitor and enabling WOL for HP, Lenovo, Dell

Some of my friends (Hi Joe! Hi Tyler!) recently requested a script to both monitor and enable WOL for workstations at their clients. WOL stands for Wake-On-Lan and is used to boot machines without user intervention. There are two forms of WOL: OS based, to let a machine start up from standby or hibernation, and BIOS based to boot computers which are completely turned off.

With the current stress of everyone wanting to work from home via all sorts of tools, my friends found some devices that aren’t replying to WOL packets or machines that simply were not configured for WOL yet.

I figured this could be a pretty cool exercise, I’ve seen a bunch of people making very device specific scripts to enable WOL, but not a lot of manufacture specific scripts that would enable it for an entire line of devices. During my discoveries I’ve found that the three biggest manufactures all have a method to enable WOL directly with PowerShell. Two of them use a module, the other uses a WMI class.

The script will work for Dell, HP, and Lenovo. Disclaimer: I tested the script on a handful of devices, so no guarantees of course. 🙂

The monitoring script

The monitoring script only works with PowerShell 5.0 or higher. It will also update PowerShell Get and also download the correct module for the device. We match based on the device manufacture in WMI.

$PPNuGet = Get-PackageProvider -ListAvailable | Where-Object { $_.Name -eq "Nuget" }
if (!$PPNuget) {
    Write-Host "Installing Nuget provider" -foregroundcolor Green
    Install-PackageProvider -Name NuGet -MinimumVersion 2.8.5.201 -Force
}
$PSGallery = Get-PSRepository -Name PsGallery
if (!$PSGallery) {
    Write-Host "Installing PSGallery" -foregroundcolor Green
    Set-PSRepository -InstallationPolicy Trusted -Name PSGallery
}
$PsGetVersion = (get-module PowerShellGet).version
if ($PsGetVersion -lt [version]'2.0') {
    Write-Host "Installing latest version of PowerShellGet provider" -foregroundcolor Green
    install-module PowerShellGet -MinimumVersion 2.2 -Force
    Write-Host "Reloading Modules" -foregroundcolor Green
    Remove-Module PowerShellGet -Force
    Remove-module PackageManagement -Force
    Import-Module PowerShellGet -MinimumVersion 2.2 -Force
    Write-Host "Updating PowerShellGet" -foregroundcolor Green
    Install-Module -Name PowerShellGet -MinimumVersion 2.2.3 -force
    Write-Host "You must rerun the script to succesfully get the WOL status. PowerShellGet was found out of date." -ForegroundColor red
    exit 1
}
Write-Host "Checking Manufacturer" -foregroundcolor Green
$Manufacturer = (Get-WmiObject -Class:Win32_ComputerSystem).Manufacturer
if ($Manufacturer -like "*Dell*") {
    Write-Host "Manufacturer is Dell. Installing Module and trying to get WOL state" -foregroundcolor Green
    Write-Host "Installing Dell Bios Provider if needed" -foregroundcolor Green
    $Mod = Get-Module DellBIOSProvider
    if (!$mod) {
        Install-Module -Name DellBIOSProvider -Force
    }
    import-module DellBIOSProvider
    try { 
        $WOLMonitor = get-item -Path "DellSmBios:\PowerManagement\WakeOnLan" -ErrorAction SilentlyContinue
        if ($WOLMonitor.currentvalue -eq "LanOnly") { $WOLState = "Healthy" }
    }
    catch {
        write-host "an error occured. Could not get WOL setting."
    }
}
if ($Manufacturer -like "*HP*" -or $Manufacturer -like "*Hewlett*") {
    Write-Host "Manufacturer is HP. Installing module and trying to get WOL State." -foregroundcolor Green
    Write-Host "Installing HP Provider if needed." -foregroundcolor Green
    $Mod = Get-Module HPCMSL
    if (!$mod) {
        Install-Module -Name HPCMSL -Force -AcceptLicense
    }
    import-module HPCMSL
    try { 
        $WolTypes = get-hpbiossettingslist | Where-Object { $_.Name -like "*Wake On Lan*" }
        $WOLState = ForEach ($WolType in $WolTypes) {
            write-host "Setting WOL Type: $($WOLType.Name)"
            get-HPBIOSSettingValue -name $($WolType.name) -ErrorAction Stop 
        }
    }
    catch {
        write-host "an error occured. Could not find WOL state"
    }
}
if ($Manufacturer -like "*Lenovo*") {
    Write-Host "Manufacturer is Lenovo. Trying to get via WMI" -foregroundcolor Green
    try { 
        Write-Host "Getting BIOS." -foregroundcolor Green
        $currentSetting = (Get-WmiObject -ErrorAction Stop -class "Lenovo_BiosSetting" -namespace "root\wmi") | Where-Object { $_.CurrentSetting -ne "" }
        $WOLStatus = $currentSetting.currentsetting | ConvertFrom-Csv -Delimiter "," -Header "Setting", "Status" | Where-Object { $_.setting -eq "Wake on lan" }
        $WOLStatus = $WOLStatus.status -split ";"
        if ($WOLStatus[0] -eq "Primary") { $WOLState = "Healthy" }
    }
    catch {
        write-host "an error occured. Could not find WOL state" 
    }
}
$NicsWithWake = Get-CimInstance -ClassName "MSPower_DeviceWakeEnable" -Namespace "root/wmi" | Where-Object { $_.Enable -eq $False }
if (!$NicsWithWake) {
    $NICWOL = "Healthy - All NICs enabled for WOL within the OS." 
} 
else {
    $NICWOL = "Unhealthy - NIC does not have WOL enabled inside of the OS." 
}
if (!$WOLState) { 
    $NICWOL = "Unhealthy - Could not find WOL state" 
}

After running the script we can check the contents of $WOLState for the current state of the WOL setting in the BIOS. You can also check $NICWOL for the Operating System’s WOL state.

The remediation script

So on the remediation side we tackle enabling WOL in both the BIOS and inside of the Operating System. We always install the module in this case, as often module updates are released when newer systems are too.

$PPNuGet = Get-PackageProvider -ListAvailable | Where-Object { $_.Name -eq "Nuget" }
if (!$PPNuget) {
    Write-Host "Installing Nuget provider" -foregroundcolor Green
    Install-PackageProvider -Name NuGet -MinimumVersion 2.8.5.201 -Force
}
$PSGallery = Get-PSRepository -Name PsGallery
if (!$PSGallery) {
    Write-Host "Installing PSGallery" -foregroundcolor Green
    Set-PSRepository -InstallationPolicy Trusted -Name PSGallery
}
$PsGetVersion = (get-module PowerShellGet).version
if ($PsGetVersion -lt [version]'2.0') {
    Write-Host "Installing latest version of PowerShellGet provider" -foregroundcolor Green
    install-module PowerShellGet -MinimumVersion 2.2 -Force
    Write-Host "Reloading Modules" -foregroundcolor Green
    Remove-Module PowerShellGet -Force
    Remove-module PackageManagement -Force
    Import-Module PowerShellGet -MinimumVersion 2.2 -Force
    Write-Host "Updating PowerShellGet" -foregroundcolor Green
    Install-Module -Name PowerShellGet -MinimumVersion 2.2.3 -force
    write-host "You must rerun the script to succesfully set the WOL status. PowerShellGet was found out of date." -ForegroundColor red
}
Write-Host "Checking Manufacturer" -foregroundcolor Green
$Manufacturer = (Get-WmiObject -Class:Win32_ComputerSystem).Manufacturer
if ($Manufacturer -like "*Dell*") {
    Write-Host "Manufacturer is Dell. Installing Module and trying to enable Wake on LAN." -foregroundcolor Green
    Write-Host "Installing Dell Bios Provider" -foregroundcolor Green
    Install-Module -Name DellBIOSProvider -Force
    import-module DellBIOSProvider
    try { 
        set-item -Path "DellSmBios:\PowerManagement\WakeOnLan" -value "LANOnly" -ErrorAction Stop
    }
    catch {
        write-host "an error occured. Could not set BIOS to WakeOnLan. Please try setting WOL manually"
    }
}
if ($Manufacturer -like "*HP*" -or $Manufacturer -like "*Hewlett*") {
    Write-Host "Manufacturer is HP. Installing module and trying to enable WakeOnLan. All HP Drivers are required for this operation to succeed." -foregroundcolor Green
    Write-Host "Installing HP Provider" -foregroundcolor Green
    Install-Module -Name HPCMSL -Force -AcceptLicense
    import-module HPCMSL
    try { 
        $WolTypes = get-hpbiossettingslist | Where-Object { $_.Name -like "*Wake On Lan*" }
        ForEach ($WolType in $WolTypes) {
            write-host "Setting WOL Type: $($WOLType.Name)"
            Set-HPBIOSSettingValue -name $($WolType.name) -Value "Boot to Hard Drive" -ErrorAction Stop 
        }
    }
    catch {
        write-host "an error occured. Could not set BIOS to WakeOnLan. Please try manually"
    }
}
if ($Manufacturer -like "*Lenovo*") {
    Write-Host "Manufacturer is Lenovo. Trying to set via WMI. All Lenovo Drivers are required for this operation to succeed." -foregroundcolor Green
    try { 
        Write-Host "Setting BIOS." -foregroundcolor Green
        (Get-WmiObject -ErrorAction Stop -class "Lenovo_SetBiosSetting" -namespace "root\wmi").SetBiosSetting('WakeOnLAN,Primary') | Out-Null
        Write-Host "Saving BIOS." -foregroundcolor Green
        (Get-WmiObject -ErrorAction Stop -class "Lenovo_SaveBiosSettings" -namespace "root\wmi").SaveBiosSettings() | Out-Null
    }
    catch {
        write-host "an error occured. Could not set BIOS to WakeOnLan. Please try manually"
    }
}
write-host "Setting NIC to enable WOL" -ForegroundColor Green
$NicsWithWake = Get-CimInstance -ClassName "MSPower_DeviceWakeEnable" -Namespace "root/wmi"
foreach ($Nic in $NicsWithWake) {
    write-host "Enabling for NIC" -ForegroundColor green
    Set-CimInstance $NIC -Property @{Enable = $true }
}

Like I said in the start – This still is slightly experimental for me. I did not have a large stack of devices to test on other than Dell devices, so if you find any issues with Lenovo or HP devices, let me know and send me a transcript, maybe I can help you figure it out!

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

update: N-Central remediation AMP can be found here. The monitoring AMP can be found here.

Update 2: Fixed a small encoding issue that crashed the script in some cases.

Update 3: Added better detection logic for nuget which causes a script hang, fixed an import and install module issue. This has not been updated inside of the monitoring AMPs. Please copy and paste the latest script in there. 🙂