Monitoring with PowerShell: VSS Snapshot size

We’ve been doing some work for another IT company of a friend of mine. I’ve been helping him with automation inside of his RMM system. He is like me and really likes to have VSS as a third or fourth backup solution, just for those small emergencies when a file is deleted.

The issue with this is that VSS snapshots can sometimes consume a lot of space. The default a VSS snapshot can consume is 10 percent of the entire disk. On larger disks such as file servers this can be hundreds of gigabytes. Instead of just lowering the quota I’ve decided to create a small monitor that can alert when a specific threshold has been reached and we can react on that. This way we can just let the VSS snapshot size be the 10% for all our clients, and only change the ones where we see it will consume a lot of disk space.

The script uses the cim/wmi instance “Win32_ShadowStorage to retrieve the correct sizes and compares it to the threshold.

$threshold = "600" #threshold in GB
$DiskSpaceUsed = Get-CimInstance -ClassName Win32_ShadowStorage | Select-Object @{n = "Used (GB)"; e = { [math]::Round([double]$_.UsedSpace / 1GB, 3) } }, @{n = "Max (GB)"; e = { [math]::Round([double]$_.MAxSpace / 1GB, 3) } }, *
$HealthState = foreach ($Disks in $DiskSpaceUsed) {

    $Volume = get-volume -UniqueId $DiskSpaceUsed.Volume.DeviceID
    $DiskSize = [math]::Round([double]$volume.Size / 1GB, 3)
    $diskremaining = [math]::Round([double]$volume.SizeRemaining / 1GB, 3)
    if ($Disks.'Used (GB)' -gt $threshold) { "Disk $($Volume.DriveLetter) snapshot size is higher than $Threshold. The disk size is $($diskSize) and it has $($diskremaining) remaining space. The max snapshot size is $($Disks.'Max (GB)')" }
}

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

And that’s it! a short but sweet one. As always, Happy PowerShelling!

Monitoring with PowerShell: Monitoring Onedrive and Sharepoint file limits

I love our cloud deployments. I’m amazed at how well people are adapting to working with an online online environment and using the tools cross platform such as the Onedrive and Teams client. As both a tech, and on the management side of the house it’s great to see such flexibility.

The only downside we’ve found so far is that you do have pay a lot of attention to the current limitations of the application your running. So in our case we have a bunch of OneDrive clients. These clients have to be taught you can’t just drop everything in a single location like we did with a file server.

So we are now monitoring some of the limitations that exist in the OneDrive sync client. One of the limitations is that you should not sync libraries with more than 100,000 files, so whenever we reach 90000 files, we create a new library or move data for our clients.

All Tenants Script

The script uses the Graph API. Get all the keys from the Secure App Model script. It will run for all tenants and alert on each that has more than 90000 files.

$ApplicationId = 'YOURAPPLICATIONID'
$ApplicationSecret = 'YOURAPPLICATIONSECRET' | Convertto-SecureString -AsPlainText -Force
$TenantID = 'YOURTENANTID'
$RefreshToken = 'YOURUNBELIEVEBALLYLONGREFRESHTOKEN'
$upn = 'UPN-Used-To-Generate-Tokens'
##############################
$credential = New-Object System.Management.Automation.PSCredential($ApplicationId, $ApplicationSecret)
write-host "Generating access tokens" -ForegroundColor Green
$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
write-host "Connecting to MSOLService" -ForegroundColor Green
Connect-MsolService -AdGraphAccessToken $aadGraphToken.AccessToken -MsGraphAccessToken $graphToken.AccessToken
write-host "Grabbing client list" -ForegroundColor Green
$customers = Get-MsolPartnerContract -All
write-host "Connecting to clients" -ForegroundColor Green

$LimitsReached = foreach ($customer in $customers) {
    write-host "Generating token for $($Customer.name)" -ForegroundColor Green
    $graphToken = New-PartnerAccessToken -ApplicationId $ApplicationId -Credential $credential -RefreshToken $refreshToken -Scopes 'https://graph.microsoft.com/.default' -ServicePrincipal -Tenant $customer.TenantID
    $Header = @{
        Authorization = "Bearer $($graphToken.AccessToken)"
    }
    Write-Host   "Grabbing data for $($customer.name)" -ForegroundColor green
    $OneDriveUsageURI = "https://graph.microsoft.com/v1.0/reports/getOneDriveUsageAccountDetail(period='D7')"
    $OneDriveUsageReports = (Invoke-RestMethod -Uri $OneDriveUsageURI -Headers $Header -Method Get -ContentType "application/json") | ConvertFrom-Csv

    $SharepointUsageReportsURI = "https://graph.microsoft.com/v1.0/reports/getSharePointSiteUsageDetail(period='D7')"
    $SharepointUsageReports = (Invoke-RestMethod -Uri $SharepointUsageReportsURI -Headers $Header -Method Get -ContentType "application/json") | ConvertFrom-Csv
    
    
    foreach ($SharepointReport in $SharepointUsageReports) {
        if ([int]$SharepointReport.'File count' -ge [int]"90000") {
            $SharepointReport 
        }
    }

    foreach ($OneDriveReport in $OneDriveUsageReports) {
        if ([int]$OneDriveReport.'File count' -ge [int]"90000") {
        $OneDriveReport
        }
    }

     

}

if (!$LimitsReached) {
    Write-Host   "Healthy" -ForegroundColor green
}
else {
    Write-Host   "Unhealthy" -ForegroundColor Red
    $LimitsReached
}

Single Tenant Script

This version runs only for the tenant you’ve entered, so you can specify which tenants you want to monitor.

$ApplicationId = 'YOURAPPLICATIONID'
$ApplicationSecret = 'YOURAPPLICATIONSECRET' | Convertto-SecureString -AsPlainText -Force
$TenantID = 'YOURTENANTID'
$RefreshToken = 'YOURUNBELIEVEBALLYLONGREFRESHTOKEN'
$upn = 'UPN-Used-To-Generate-Tokens'
$TenantToMonitor = "Blabla.onmicrosoft.com"
##############################

$LimitsReached = 

    write-host "Generating token for $($TenantToMonitor)" -ForegroundColor Green
    $graphToken = New-PartnerAccessToken -ApplicationId $ApplicationId -Credential $credential -RefreshToken $refreshToken -Scopes 'https://graph.microsoft.com/.default' -ServicePrincipal -Tenant $TenantToMonitor
    $Header = @{
        Authorization = "Bearer $($graphToken.AccessToken)"
    }
    Write-Host   "Grabbing data for $($TenantToMonitor)" -ForegroundColor green
    $OneDriveUsageURI = "https://graph.microsoft.com/v1.0/reports/getOneDriveUsageAccountDetail(period='D7')"
    $OneDriveUsageReports = (Invoke-RestMethod -Uri $OneDriveUsageURI -Headers $Header -Method Get -ContentType "application/json") | ConvertFrom-Csv

    $SharepointUsageReportsURI = "https://graph.microsoft.com/v1.0/reports/getSharePointSiteUsageDetail(period='D7')"
    $SharepointUsageReports = (Invoke-RestMethod -Uri $SharepointUsageReportsURI -Headers $Header -Method Get -ContentType "application/json") | ConvertFrom-Csv
    
    
    foreach ($SharepointReport in $SharepointUsageReports) {
        if ([int]$SharepointReport.'File count' -ge [int]"90000") {
            $SharepointReport 
        }
    }

    foreach ($OneDriveReport in $OneDriveUsageReports) {
        if ([int]$OneDriveReport.'File count' -ge [int]"90000") {
        $OneDriveReport
        }
    }

     


if (!$LimitsReached) {
    Write-Host   "Healthy" -ForegroundColor green
}
else {
    Write-Host   "Unhealthy" -ForegroundColor Red
    $LimitsReached
}

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

Documenting with PowerShell: Documenting Office 365 usage reports

I like knowing what specific parts of Office365 my clients use most, so I can customize their experience to the way they work. This means I can send them manuals for mobile usage when they are only using mobile phones, or I can help them in using Teams, Onedrive, or other stuff like Planner or ToDo to the fullest.

I also like using the usage report as an early alerting measure – but that’ll be a different blog this week. To get the reports, we’ll be using the Secure Application Model. We will need to add a single permission first. Do the following to create this permission:

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

After giving these permissions, you can start running either of the scripts below. These gather all the usage reports that are available via the Azure AD Graph API for all your clients. These reports contain information like how many files are stored in Onedrive, what applications the client uses, and how many office activations the client has. I’ve listed all the ones that I like – Feel free to strip out the ones you do not use.

Generic Version

I’ve been asked a couple of times to include a screenshot of how the report could look. This is a template of one of my testing tenants, please note that with production tenants this will most likely be a much longer list. 🙂

Example Report – Generic HTML version

$ApplicationId = 'YourApplicationID'
$ApplicationSecret = 'SecretApplicationSecret' | Convertto-SecureString -AsPlainText -Force
$TenantID = 'YourTenantID'
$RefreshToken = 'SuperSecretRefreshToken'
$upn = 'UPN-Used-To-Generate-Tokens'
##############################
$credential = New-Object System.Management.Automation.PSCredential($ApplicationId, $ApplicationSecret)
write-host "Generating access tokens" -ForegroundColor Green
$graphToken = New-PartnerAccessToken -ApplicationId $ApplicationId -Credential $credential -RefreshToken $refreshToken -Scopes 'https://graph.microsoft.com/.default' -ServicePrincipal -Tenant $tenantID 
$aadGraphToken = New-PartnerAccessToken -ApplicationId $ApplicationId -Credential $credential -RefreshToken $refreshToken -Scopes 'https://graph.windows.net/.default' -ServicePrincipal -Tenant $tenantID 

write-host "Connecting to MSOLService" -ForegroundColor Green
Connect-MsolService -AdGraphAccessToken $aadGraphToken.AccessToken -MsGraphAccessToken $graphToken.AccessToken
write-host "Grabbing client list" -ForegroundColor Green
$customers = Get-MsolPartnerContract -All
write-host "Connecting to clients" -ForegroundColor Green

foreach ($customer in $customers) {
    write-host "Generating token for $($Customer.name)" -ForegroundColor Green
    $graphToken = New-PartnerAccessToken -ApplicationId $ApplicationId -Credential $credential -RefreshToken $refreshToken -Scopes 'https://graph.microsoft.com/.default' -ServicePrincipal -Tenant $customer.TenantID
    $Header = @{
        Authorization = "Bearer $($graphToken.AccessToken)"
    }
    write-host "Gathering Reports for $($Customer.name)" -ForegroundColor Green
    #Gathers which devices currently use Teams, and the details for these devices.
    $TeamsDeviceReportsURI = "https://graph.microsoft.com/v1.0/reports/getTeamsDeviceUsageUserDetail(period='D7')"
    $TeamsDeviceReports = (Invoke-RestMethod -Uri $TeamsDeviceReportsURI -Headers $Header -Method Get -ContentType "application/json") -replace "", "" | ConvertFrom-Csv | ConvertTo-Html -fragment -PreContent "<h1>Teams device report</h1>" | Out-String
    #Gathers which Users currently use Teams, and the details for these Users.
    $TeamsUserReportsURI = "https://graph.microsoft.com/v1.0/reports/getTeamsUserActivityUserDetail(period='D7')"
    $TeamsUserReports = (Invoke-RestMethod -Uri $TeamsUserReportsURI -Headers $Header -Method Get -ContentType "application/json") -replace "", "" | ConvertFrom-Csv | ConvertTo-Html -fragment -PreContent "<h1>Teams user report</h1>"| Out-String
    #Gathers which users currently use email and the details for these Users
    $EmailReportsURI = "https://graph.microsoft.com/v1.0/reports/getEmailActivityUserDetail(period='D7')"
    $EmailReports = (Invoke-RestMethod -Uri $EmailReportsURI -Headers $Header -Method Get -ContentType "application/json") -replace "", "" | ConvertFrom-Csv | ConvertTo-Html -fragment -PreContent "<h1>Email users Report</h1>"| Out-String
    #Gathers the storage used for each e-mail user.
    $MailboxUsageReportsURI = "https://graph.microsoft.com/v1.0/reports/getMailboxUsageDetail(period='D7')"
    $MailboxUsage = (Invoke-RestMethod -Uri $MailboxUsageReportsURI -Headers $Header -Method Get -ContentType "application/json") -replace "", "" | ConvertFrom-Csv | ConvertTo-Html -fragment -PreContent "<h1>Email storage report</h1>"| Out-String
    #Gathers the activations for each user of office.
    $O365ActivationsReportsURI = "https://graph.microsoft.com/v1.0/reports/getOffice365ActivationsUserDetail"
    $O365ActivationsReports = (Invoke-RestMethod -Uri $O365ActivationsReportsURI -Headers $Header -Method Get -ContentType "application/json") -replace "", "" | ConvertFrom-Csv | ConvertTo-Html -fragment -PreContent "<h1>O365 Activation report</h1>"| Out-String
    #Gathers the Onedrive activity for each user.
    $OneDriveActivityURI = "https://graph.microsoft.com/v1.0/reports/getOneDriveActivityUserDetail(period='D7')"
    $OneDriveActivityReports = (Invoke-RestMethod -Uri $OneDriveActivityURI -Headers $Header -Method Get -ContentType "application/json") -replace "", "" | ConvertFrom-Csv | ConvertTo-Html -fragment -PreContent "<h1>Onedrive Activity report</h1>"| Out-String
    #Gathers the Onedrive usage for each user.
    $OneDriveUsageURI = "https://graph.microsoft.com/v1.0/reports/getOneDriveUsageAccountDetail(period='D7')"
    $OneDriveUsageReports = (Invoke-RestMethod -Uri $OneDriveUsageURI -Headers $Header -Method Get -ContentType "application/json") -replace "", "" | ConvertFrom-Csv | ConvertTo-Html -fragment -PreContent "<h1>OneDrive usage report</h1>"| Out-String
    #Gathers the Sharepoint usage for each user.
    $SharepointUsageReportsURI = "https://graph.microsoft.com/v1.0/reports/getSharePointSiteUsageDetail(period='D7')"
    $SharepointUsageReports = (Invoke-RestMethod -Uri $SharepointUsageReportsURI -Headers $Header -Method Get -ContentType "application/json") -replace "", "" | ConvertFrom-Csv | ConvertTo-Html -fragment -PreContent "<h1>Sharepoint usage report</h1>"| Out-String

$head = 
@"
      <Title>O365 Reports</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;
    }
    </style>
"@

$head,$TeamsDeviceReports,$TeamsUserReports,$EmailReports,$MailboxUsage,$O365ActivationsReports,$OneDriveActivityReports,$OneDriveUsageReports,$SharepointUsageReports | out-file "C:\Temp\$($Customer.name).html"


}

IT-Glue version

########################## Office 365 ############################
$ApplicationId = 'YourApplicationID'
$ApplicationSecret = 'SecretApplicationSecret' | Convertto-SecureString -AsPlainText -Force
$TenantID = 'YourTenantID'
$RefreshToken = 'SuperSecretRefreshToken'
$upn = 'UPN-Used-To-Generate-Tokens'
########################## IT-Glue ############################
$APIKEy = "ITGLUEAPIEY"
$APIEndpoint = "https://api.eu.itglue.com"
$FlexAssetName = "Office365 Reports - AutoDoc v1"
$Description = "Office365 Reporting."
#some layout options, change if you want colours to be different or do not like the whitespace.
$TableHeader = "<table class=`"table table-bordered table-hover`" style=`"width:80%`">"
$TableStyling = "<th>", "<th style=`"background-color:#4CAF50`">"
###########################
#Grabbing ITGlue Module and installing.
If (Get-Module -ListAvailable -Name "ITGlueAPI") { 
    Import-module ITGlueAPI 
}
Else { 
    Install-Module ITGlueAPI -Force
    Import-Module ITGlueAPI
}

#Settings IT-Glue logon information
Add-ITGlueBaseURI -base_uri $APIEndpoint
Add-ITGlueAPIKey $APIKEy
write-host "Checking if Flexible Asset exists in IT-Glue." -foregroundColor green
$FilterID = (Get-ITGlueFlexibleAssetTypes -filter_name $FlexAssetName).data
if (!$FilterID) { 
    write-host "Does not exist, creating new." -foregroundColor green
    $NewFlexAssetData = 
    @{
        type          = 'flexible-asset-types'
        attributes    = @{
            name        = $FlexAssetName
            icon        = 'sitemap'
            description = $description
        }
        relationships = @{
            "flexible-asset-fields" = @{
                data = @(
                    @{
                        type       = "flexible_asset_fields"
                        attributes = @{
                            order           = 1
                            name            = "Teams Device Reports"
                            kind            = "Textbox"
                            required        = $true
                            "show-in-list"  = $true
                            "use-for-title" = $true
                        }
                    },
                    @{
                        type       = "flexible_asset_fields"
                        attributes = @{
                            order          = 2
                            name           = "Teams User Reports"
                            kind           = "Textbox"
                            required       = $false
                            "show-in-list" = $false
                        }
                    },
                    @{
                        type       = "flexible_asset_fields"
                        attributes = @{
                            order          = 3
                            name           = "Email Reports"
                            kind           = "Textbox"
                            required       = $false
                            "show-in-list" = $false
                        }
                    },
                    @{
                        type       = "flexible_asset_fields"
                        attributes = @{
                            order          = 4
                            name           = "Mailbox Usage Reports"
                            kind           = "Textbox"
                            required       = $false
                            "show-in-list" = $false
                        }
                    },
                    @{
                        type       = "flexible_asset_fields"
                        attributes = @{
                            order          = 5
                            name           = "O365 Activations Reports"
                            kind           = "Textbox"
                            required       = $false
                            "show-in-list" = $false
                        }
                    },
                    @{
                        type       = "flexible_asset_fields"
                        attributes = @{
                            order          = 6
                            name           = "OneDrive Activity Reports"
                            kind           = "Textbox"
                            required       = $false
                            "show-in-list" = $false
                        }
                    },
                    @{
                        type       = "flexible_asset_fields"
                        attributes = @{
                            order          = 7
                            name           = "OneDrive Usage Reports"
                            kind           = "Textbox"
                            required       = $false
                            "show-in-list" = $false
                        }
                    },
                    @{
                        type       = "flexible_asset_fields"
                        attributes = @{
                            order          = 8
                            name           = "Sharepoint Usage Reports"
                            kind           = "Textbox"
                            required       = $false
                            "show-in-list" = $false
                        }
                    },
                    @{
                        type       = "flexible_asset_fields"
                        attributes = @{
                            order          = 9
                            name           = "TenantID"
                            kind           = "Text"
                            required       = $false
                            "show-in-list" = $false
                        }
                    }
                )
            }
        }
    }
    New-ITGlueFlexibleAssetTypes -Data $NewFlexAssetData
    $FilterID = (Get-ITGlueFlexibleAssetTypes -filter_name $FlexAssetName).data
}
$AllITGlueContacts = @()
#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 "Start documentation process." -foregroundColor green
$credential = New-Object System.Management.Automation.PSCredential($ApplicationId, $ApplicationSecret)
write-host "Generating access tokens" -ForegroundColor Green
$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 
write-host "Connecting to MSOLService" -ForegroundColor Green
Connect-MsolService -AdGraphAccessToken $aadGraphToken.AccessToken -MsGraphAccessToken $graphToken.AccessToken
write-host "Grabbing client list" -ForegroundColor Green
$customers = Get-MsolPartnerContract -All
write-host "Connecting to clients" -ForegroundColor Green

foreach ($customer in $customers) {
    write-host "Generating token for $($Customer.name)" -ForegroundColor Green
    $graphToken = New-PartnerAccessToken -ApplicationId $ApplicationId -Credential $credential -RefreshToken $refreshToken -Scopes 'https://graph.microsoft.com/.default' -ServicePrincipal -Tenant $customer.TenantID
    $Header = @{
        Authorization = "Bearer $($graphToken.AccessToken)"
    }
    write-host "Gathering Reports for $($Customer.name)" -ForegroundColor Green
    #Gathers which devices currently use Teams, and the details for these devices.
    $TeamsDeviceReportsURI = "https://graph.microsoft.com/v1.0/reports/getTeamsDeviceUsageUserDetail(period='D7')"
    $TeamsDeviceReports = (Invoke-RestMethod -Uri $TeamsDeviceReportsURI -Headers $Header -Method Get -ContentType "application/json") -replace "", "" | ConvertFrom-Csv | ConvertTo-Html -fragment -PreContent "<h1>Teams device report</h1>" | Out-String
    #Gathers which Users currently use Teams, and the details for these Users.
    $TeamsUserReportsURI = "https://graph.microsoft.com/v1.0/reports/getTeamsUserActivityUserDetail(period='D7')"
    $TeamsUserReports = (Invoke-RestMethod -Uri $TeamsUserReportsURI -Headers $Header -Method Get -ContentType "application/json") -replace "", "" | ConvertFrom-Csv | ConvertTo-Html -fragment -PreContent "<h1>Teams user report</h1>" | Out-String
    #Gathers which users currently use email and the details for these Users
    $EmailReportsURI = "https://graph.microsoft.com/v1.0/reports/getEmailActivityUserDetail(period='D7')"
    $EmailReports = (Invoke-RestMethod -Uri $EmailReportsURI -Headers $Header -Method Get -ContentType "application/json") -replace "", "" | ConvertFrom-Csv | ConvertTo-Html -fragment -PreContent "<h1>Email users Report</h1>" | Out-String
    #Gathers the storage used for each e-mail user.
    $MailboxUsageReportsURI = "https://graph.microsoft.com/v1.0/reports/getMailboxUsageDetail(period='D7')"
    $MailboxUsage = (Invoke-RestMethod -Uri $MailboxUsageReportsURI -Headers $Header -Method Get -ContentType "application/json") -replace "", "" | ConvertFrom-Csv | ConvertTo-Html -fragment -PreContent "<h1>Email storage report</h1>" | Out-String
    #Gathers the activations for each user of office.
    $O365ActivationsReportsURI = "https://graph.microsoft.com/v1.0/reports/getOffice365ActivationsUserDetail"
    $O365ActivationsReports = (Invoke-RestMethod -Uri $O365ActivationsReportsURI -Headers $Header -Method Get -ContentType "application/json") -replace "", "" | ConvertFrom-Csv | ConvertTo-Html -fragment -PreContent "<h1>O365 Activation report</h1>" | Out-String
    #Gathers the Onedrive activity for each user.
    $OneDriveActivityURI = "https://graph.microsoft.com/v1.0/reports/getOneDriveActivityUserDetail(period='D7')"
    $OneDriveActivityReports = (Invoke-RestMethod -Uri $OneDriveActivityURI -Headers $Header -Method Get -ContentType "application/json") -replace "", "" | ConvertFrom-Csv | ConvertTo-Html -fragment -PreContent "<h1>Onedrive Activity report</h1>" | Out-String
    #Gathers the Onedrive usage for each user.
    $OneDriveUsageURI = "https://graph.microsoft.com/v1.0/reports/getOneDriveUsageAccountDetail(period='D7')"
    $OneDriveUsageReports = (Invoke-RestMethod -Uri $OneDriveUsageURI -Headers $Header -Method Get -ContentType "application/json") -replace "", "" | ConvertFrom-Csv | ConvertTo-Html -fragment -PreContent "<h1>OneDrive usage report</h1>" | Out-String
    #Gathers the Sharepoint usage for each user.
    $SharepointUsageReportsURI = "https://graph.microsoft.com/v1.0/reports/getSharePointSiteUsageDetail(period='D7')"
    $SharepointUsageReports = (Invoke-RestMethod -Uri $SharepointUsageReportsURI -Headers $Header -Method Get -ContentType "application/json") -replace "", "" | ConvertFrom-Csv | ConvertTo-Html -fragment -PreContent "<h1>Sharepoint usage report</h1>" | Out-String
    
    $FlexAssetBody =
    @{
        type       = 'flexible-assets'
        attributes = @{
            traits = @{
                'teams-device-reports'      = ($TableHeader + $TeamsDeviceReports) -replace $TableStyling
                'teams-user-reports'        = ($TableHeader + $TeamsUserReports ) -replace $TableStyling
                'email-reports'             = ($TableHeader + $EmailReports) -replace $TableStyling
                'mailbox-usage-reports'     = ($TableHeader + $MailboxUsage) -replace $TableStyling
                'o365-activations-reports'  = ($TableHeader + $O365ActivationsReports) -replace $TableStyling
                'onedrive-activity-reports' = ($TableHeader + $OneDriveActivityReports) -replace $TableStyling
                'onedrive-usage-reports'    = ($TableHeader + $OneDriveUsageReports) -replace $TableStyling
                'sharepoint-usage-reports'  = ($TableHeader + $SharepointUsageReports) -replace $TableStyling
                'tenantid'                  = $customer.TenantId
            }
        }
    }
     
    Write-Host "          Finding $($customer.name) in IT-Glue" -ForegroundColor Green
    $orgID = @()
    $customerdomains = Get-MsolDomain -TenantId $customer.tenantid
    foreach ($customerDomain in $customerdomains) {
        $orgID += ($AllITGlueContacts | Where-Object { $_.'contact-emails'.value -match $customerDomain.name }).'organization-id' | Select-Object -Unique
    }
    write-host "             Uploading Reports $($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.'tenantid' -eq $customer.TenantId }
        #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 Reports $($customer.name) into IT-Glue organisation $org" -ForegroundColor Green
            New-ITGlueFlexibleAssets -data $FlexAssetBody
            start-sleep 2
        }
        else {
            write-host "                      Updating Reports $($customer.name) into IT-Glue organisation $org"  -ForegroundColor Green
            $ExistingFlexAsset = $ExistingFlexAsset | select-object -last 1
            Set-ITGlueFlexibleAssets -id $ExistingFlexAsset.id -data $FlexAssetBody
            start-sleep 2
        }

    }
    


}

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

Monitoring with PowerShell: Monitoring Active SMB sessions.

So with the new SMBv3 Remote Code Execution issues codenamed “SMBGhost”. SMBGhost is an issue where an attack could gain remote code execution by exploiting a bug in SMB compression. A temporary fix is disabling SMB compression on the server side using this registry key:

Set-ItemProperty -Path "HKLM:\SYSTEM\CurrentControlSet\Services\LanmanServer\Parameters" DisableCompression -Type DWORD -Value 1 -Force

Microsoft has since released a patch (see this link for more info). We’ve decided to start monitoring SMB sessions on clients in any case. Normally speaking, no SMB sessions to a client should be open unless you are performing a remote installation using the ADMIN$ share. So it’s good practice to check if there are SMB sessions open and if so, where they are coming from. This is also a pretty cool trick to find who is hosting their own shares inside of your networks.

The Script

So its a fairly short script – it alerts on both currently opened sessions, and active SMB connections. There’s a difference between the both as you can connect to the IPC$ share, without having an active open session. In any case – I’d run this script every minute or less on all your workstations. Its quite lightweight and a great help to find bad actors in your environment.

$Sessions = Get-smbsession
$Connections = get-smbconnection


if ($sessions) {
    foreach ($Session in $Sessions) {
        write-host "a session has been found coming from $($Session.ClientComputerName). The logged on user is $($Session.ClientUserName) with $($Session.NumOpens) opened sessions" 
    }
}
else {
    write-host "No sessions found"
}

if ($Connections) {
    foreach ($Connection in $Connections) {
        write-host "a Connection has been found on $($Connection.ServerName). The logged on user is $($Connection.Username) with $($Connection.NumOpens) opened sessions" 
    }
}
else {
    write-host "No sessions found"
}

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

Documenting with PowerShell: Passportal API Examples

UPDATE: The blog below is based on a private alpha/beta, and as such complete documentation is not yet available. Solarwinds is working on making the API available to everyone. 🙂

So recently I’ve gotten access to the alpha Solarwinds Passportal API, Passportal is a relatively young documentation platform that has the ability to store documents as plain html files but make a relational database out of it. The API is brand new so it’s a cool chance to make a small post about how people can approach the API and start using it for automatic documentation.

Currently the API is still in alpha/beta, so all of this blog can change. One thing to note is that currently the API endpoints cannot store passwords. That means that password-based documentation such as my Bitlocker blog is not yet available. 

I’ll consider making an unofficial Solarwinds Passportal PowerShell Module when the final version arrives, even if I’m not a regular PassPortal user I like having the same tools available. Anyway, let’s get started!

First, we’ll have to get our API key. You can get the API key by following these instructions:

Now that we have our key, we can get started with actual code. The passportal API uses access tokens to make sure that you are allowed to do anything on the API so our first job is generating an access token. Enter the required information for your environment.

$URL = "https://de-clover.passportalmsp.com/api"
$XAPIKey = "YOURAPIKEY"
$XAPISecret = "APISECRET1000"
#Next we will hash our secret to HMAC 256 using the secret "aUa&&amp;XUQBJXz2x&". This is a preset hashing secret.
$secrethash = "aUa&&XUQBJXz2x&amp;" #Do not change this.
$hmacsha = New-Object System.Security.Cryptography.HMACSHA256
$hmacsha.key = [Text.Encoding]::ASCII.GetBytes($XAPISecret)
$signature = $hmacsha.ComputeHash([Text.Encoding]::ASCII.GetBytes($secrethash))
$XHash = [System.BitConverter]::ToString($signature).Replace('-', '').ToLower()
#After we have an encrypted method of sending the key we’re going to create the correct headers. 
$headers = @{
    'X-KEY'  = $XAPIKey
    'X-HASH' = $XHash
}
$Content = @{
    'content' = $secrethash
    'scope'   = 'docs_api'
}
#And now we can make a request to get our access key. This access key will be our actual login for the API this session.
$Tokens = Invoke-RestMethod -Headers $headers -Uri "$($URL)/v2/auth/client_token" -Method POST -Body $Content -ContentType "application/x-www-form-urlencoded" 

Now that we have an access token, we’ll remove our hashed API key from the headers, add our access token and try to get a list of all our clients.

#we'll remove our x-key and x-hash from the headers, and add the API access token instead. 
$headers.Remove('x-key')
$headers.Remove('x-hash')
$headers.Add('x-access-token', $Tokens.access_token)
$Clients = (Invoke-RestMethod -Headers $headers -Uri "$($URL)/v2/documents/clients?resultsPerPage=1000" -Method Get -Verbose).results

So with this list of information, we’re able to grab all documents for all client by adding this part:

foreach($Client in $Clients){
    (Invoke-RestMethod -Headers $headers -Uri "$($URL)/v2/documents?clientId=$($Client.id)" -Method Get -Verbose).results
}

To create a document for a specific client, we modify our script just a little bit and add the following code, in this example we’re filling in the default template supplied by Solarwinds within Passportal, for an Application called “Autodoc” – This application will be added to all clients.

$Clients = (Invoke-RestMethod -Headers $headers -Uri "$($URL)/v2/documents/clients?resultsPerPage=1000" -Method Get -Verbose).results
$TemplateID = (Invoke-RestMethod -Headers $headers -Uri "$($URL)/v2/documents/templates?resultsPerPage=1000" -Method Get -Verbose).results | Where-Object { $_.type -eq "application" }

foreach ($Client in $Clients) {
    $body = ConvertTo-Json @(@{
        templateUid      = $TemplateID.id
        clientId         = $client.id
        title            = "Autodoc CyberDrain.com API Test"
        application_name = "AutoDoc CyberDrain.com"
        version          = "1.0"
        notes            = "This was created with an API test."
    })

    Invoke-RestMethod -Headers $headers -Uri "$($URL)/v2/documents" -Method POST -Body $body -Verbose
}

Full script

$URL = "https://de-clover.passportalmsp.com/api"
$XAPIKey = "YOURAPIKEY"
$XAPISecret = "APISECRET1000"
#Next we will hash our secret to HMAC 256 using the secret "aUa&&amp;amp;XUQBJXz2x&". This is a preset hashing secret.
$secrethash = "aUa&&amp;XUQBJXz2x&"
$hmacsha = New-Object System.Security.Cryptography.HMACSHA256
$hmacsha.key = [Text.Encoding]::ASCII.GetBytes($XAPISecret)
$signature = $hmacsha.ComputeHash([Text.Encoding]::ASCII.GetBytes($secrethash))
$XHash = [System.BitConverter]::ToString($signature).Replace('-', '').ToLower()
#After we have an encrypted method of sending the key we’re going to create the correct headers. 
$headers = @{
    'X-KEY'  = $XAPIKey
    'X-HASH' = $XHash
}
$Content = @{
    'content' = $secrethash
    'scope'   = 'docs_api'
}
#And now we can make a request to get our access key. This access key will be our actual login for the API this session.
$Tokens = Invoke-RestMethod -Headers $headers -Uri "$($URL)/v2/auth/client_token" -Method POST -Body $Content -ContentType "application/x-www-form-urlencoded" 

#we'll remove our x-key and x-hash from the headers, and add the API access token instead. 
$headers.Remove('x-key')
$headers.Remove('x-hash')
$headers.Add('x-access-token', $Tokens.access_token)
#With the access key, we can make actual API requests. We'll try creating a document!

$TemplateID = (Invoke-RestMethod -Headers $headers -Uri "$($URL)/v2/documents/templates?resultsPerPage=1000" -Method Get -Verbose).results | Where-Object { $_.type -eq "application" }
$Clients = (Invoke-RestMethod -Headers $headers -Uri "$($URL)/v2/documents/clients?resultsPerPage=1000" -Method Get -Verbose).results

foreach ($Client in $Clients) {
    $body = ConvertTo-Json @(@{
        templateUid      = $TemplateID.id
        clientId         = $client.id
        title            = "Autodoc CyberDrain.com API Test"
        application_name = "AutoDoc CyberDrain.com"
        version          = "1.0"
        notes            = "This was created with an API test."
    })

    Invoke-RestMethod -Headers $headers -Uri "$($URL)/v2/documents" -Method POST -Body $body -Verbose
}

And that’s it! This API will be opening entire new avenues to documentation. I am loving the method of using plain HTML as a source for documents as most of my “Documenting with PowerShell” series has an HTML based solution too, it’s still going to take some time to evolve but with this tutorial it should be easy enough to add Passportal to my “Documenting with…” series.

Monitoring with PowerShell: Monitoring Unifi site configuration

So I’ve done a couple of blogs about Unifi before. You can find those here, here, and here. I really like the entire Ubiquiti Unifi stack thanks to the ease of configuration. This ease of configuration does make it so that everyone can install it, even though mistakes can be made.

These mistakes or small configuration errors are the reason I’ve made a monitoring set to check if each site is configured the way we prefer it at my company.

So lets get started; first we connect to the API using the following script:

param(
    [string]$URL = 'yourcontroller.controller.tld',
    [string]$port = '8443',
    [string]$User = 'APIUSER',
    [string]$Pass = 'SomeReallyLongPassword',
    [string]$SiteCode = 'default' #you can enter each site here. This way when you assign the monitoring to a client you edit this to match the correct siteID.
)
[string]$controller = "https://$($URL):$($port)"
[string]$credential = "`{`"username`":`"$User`",`"password`":`"$Pass`"`}"
try {
    $null = Invoke-Restmethod -Uri "$controller/api/login" -method post -body $credential -ContentType "application/json; charset=utf-8"  -SessionVariable myWebSession
}
catch {
    $APIerror = "Api Connection Error: $($_.Exception.Message)"
}

Now that we’re connected, we can start making queries. Check out the older unifi blogs if you just want to focus on device monitoring. in this case we’re going to be checking our configuration and if it matches the following, this is not our exact configuration but with these settings you’d be able to edit it to anything you want. 🙂

  • We want at least 3 networks to be available: LAN, Guest, VOIP.
  • We want to make sure the ALG settings are disabled.
  • Speedtest must be enabled and running every 20 minutes.
  • Also, we want “Advanced Feature Mode” to be enabled.

We’re going to be downloading 2 arrays from the Unifi API. One for the Network Configuration, the other for the Site Configuration. I’ve placed it all in an object, which most RMM systems can’t really alert on, which is why I’ve also included the if/else statements all the way at the bottom. You can change these to your own wishes easily.

param(
    [string]$URL = 'yourcontroller.controller.tld',
    [string]$port = '8443',
    [string]$User = 'APIUSER',
    [string]$Pass = 'SomeReallyLongPassword',
    [string]$SiteCode = 'default' #you can enter each site here. This way when you assign the monitoring to a client you edit this to match the correct siteID.
)
[string]$controller = "https://$($URL):$($port)"
[string]$credential = "`{`"username`":`"$User`",`"password`":`"$Pass`"`}"


$errorlist = New-Object -TypeName PSCustomObject
try {
    $null = Invoke-Restmethod -Uri "$controller/api/login" -method post -body $credential -ContentType "application/json; charset=utf-8"  -SessionVariable myWebSession
}
catch {
    Add-Member -InputObject $ErrorList -MemberType NoteProperty -Name APISessionError -Value $_.Exception.Message
}

try {
    $NetWorkConf = (Invoke-Restmethod -Uri "$controller/api/s/$SiteCode/list/networkconf" -WebSession $myWebSession).data | Where-Object { $_.Purpose -ne "WAN" }
}
catch {
    Add-Member -InputObject $ErrorList -MemberType NoteProperty -Name APINetworkError -Value $_.Exception.Message
}

try {
    $SysInfo = (Invoke-Restmethod -Uri "$controller/api/s/$SiteCode/get/setting" -WebSession $myWebSession).data
}
catch {
    Add-Member -InputObject $ErrorList -MemberType NoteProperty -Name APISysInfoError -Value $_.Exception.Message
}

$UnifiOutput = [PSCustomObject]@{
    NetworkNames      = $Networkconf.name
    NetworkCount      = $NetWorkConf.Count
    AdvancedFeatures  = ($Sysinfo.advanced_feature_enabled)
    SpeedTestEnabled  = ($sysinfo | Where-Object { $_.key -eq "Auto_Speedtest" }).enabled
    SpeedTestInterval = ($sysinfo | Where-Object { $_.key -eq "Auto_Speedtest" }).interval
    VoipNetwork       = ($NetWorkConf.name | Where-Object { $_ -like "*VOIP*" }).Count
    GuestNetwork      = ($NetWorkConf.purpose | Where-Object { $_ -like "*guest*" }).Count
    LANNetworks       = ($NetWorkConf.name | Where-Object { $_ -like "*-LAN*" }).Count
    Modules           = [PSCustomObject]@{
        ftp_module           =	$sysinfo.ftp_module
        gre_module           =	$sysinfo.gre_module
        h323_module          =	$sysinfo.h323_module
        pptp_module          =	$sysinfo.pptp_module
        sip_module           =	$sysinfo.sip_module
        tftp_module          =	$sysinfo.tftp_module
        broadcast_ping       =	$sysinfo.broadcast_ping
        receive_redirects    =	$sysinfo.receive_redirects
        send_redirects       =	$sysinfo.send_redirects
        syn_cookies          =	$sysinfo.syn_cookies
        offload_accounting   =	$sysinfo.offload_accounting
        offload_sch          =	$sysinfo.offload_sch
        offload_l2_blocking  =	$sysinfo.offload_l2_blocking
        mdns_enabled         =	$sysinfo.mdns_enabled
        upnp_enabled         =	$sysinfo.upnp_enabled
        upnp_nat_pmp_enabled =	$sysinfo.upnp_nat_pmp_enabled
        upnp_secure_mode     =	$sysinfo.upnp_secure_mode
        mss_clamp            =	$sysinfo.mss_clamp
    }
}

if ($UnifiOutput.NetworkCount -lt "3") { write-host "Not enough networks found. Only 3 are present." }
if ($UnifiOutput.SpeedTestEnabled -eq $false) { write-host "Speedtest disabled" }
if ($UnifiOutput.SpeedTestInterval -gt "20") { write-host "Speedtest is not set to run every 20 minutes." }
if ($UnifiOutput.SpeedTestInterval -gt "20") { write-host "Speedtest is not set to run every 20 minutes." }
if ($UnifiOutput.Modules.sip_module -eq $true) { Write-Host "SIP ALG Module is enabled." }

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

Using PowerShell to generate and deploy Group Policies for non-domain environments

So I’ve been having a hard time coming up with a title for this one. As I’ve stated in previous blogs we’re moving more and more clients to cloud only environments using Azure AD, Teams, and Onedrive as their collaboration and file sharing solution. The issue with this was that it became quite difficult to deploy GPOs. This was especially bad when wanting to use ADMX templates. We could use Intune but found that even there we had so many limitation it would not work for us.

Full disclosure; There are some third party applications that offer GPO deployment. We found most of them to be overkill for what we needed, or not very suitable for the MSP market. I mean – We already have our RMM system, and tacking on another application just did not seem right to me.

So to solve the issues with GPO deployment for Azure AD environments, or workgroup environments I’ve created a PowerShell script that allows you to deploy group policies. I’ve also created a script to monitor if the deployment ran correctly. That way you can use your RMM to see who received the new policy and who has not.

Execution Script

Before we get started on the script, you will need the following two items: LGPO.exe, which we’ll use to export and deploy the policy, and winrar which will create our setup file. We download the LGPO.exe for you (Please host this somewhere you trust.). Winrar you’ll need to install yourself.

Disclaimer/warning: Please note that the script is destructive to the currently installed local group policies. Please run the script inside of a VM or machine that does not have any local policies. It will clear all policies by destroying the actual policy files. You have been warned 🙂

So the steps are straightforward – Save the script and execute it with the parameters you need. The script will delete the current GPOs, and then open the group policy editor. Import your ADMX files, edit the settings, and close the editor. Then the script will finish up and put 2 files on your desktop – One to apply the policy, the other to remove the policy in case you no longer need it.

<#
.SYNOPSIS
  Creates an execuble that can apply and remove a local Group Policy Object
.DESCRIPTION
 Creates an execuble that can apply and remove a specific local policy. The executable is a self-extracting winrar archive. Winrar installation is required for processing.
 The execuble will work in "MERGE" mode, meaning that settings that are duplicate will be overwritten, other settings will not be touched.

 Parameters are not required, but optional. Script will fail if LGPO and Winrar are not present.

 WARNING: SCRIPT IS DESTRUCTIVE TO LOCAL GROUP POLICIES. DO NOT RUN ON PRODUCTION MACHINES. Use at own risk.
.PARAMETER DownloadURL
    Specificies where to download LGPO from if not installed.
.PARAMETER DownloadLocation
    Specificies where to download LGPO to if not installed. Defaults to C:\Temp\LGPO
.PARAMETER WorkingPath
    Specificies where to put temporary files. Defaults to C:\Temp\LGPO
.PARAMETER WinrarPath
    Path where winrar is found. Defaults to C:\Program Files\WinRAR\WinRAR.exe
.PARAMETER GPOName
    Decides part of the name of the file that will be placed in C:\ProgramData\
.PARAMETER GPOVersion
    Decides part of the name of the file that will be placed in C:\ProgramData\
.INPUTS
  none
.OUTPUTS
  executable generated and stored in user desktop location
.NOTES
  Version:        0.4
  Author:         Kelvin Tegelaar
  Creation Date:  02/2020
  Purpose/Change: Initial script. Beta.

#>
Param(
    [string]$DownloadURL = "http://cyberdrain.com/wp-content/uploads/2020/02/LGPO.exe",
    [string]$DownloadLocation = "C:\Temp\LGPO",
    [string]$WinrarPath = "C:\Program Files\WinRAR\WinRAR.exe",
    [string]$GPOName = "GPO",
    [string]$GPOVersion = "1.0"
)

write-host "Checking if base folder exists in $DownloadLocation and if not, creating it." -ForegroundColor Green
try {
    $TestDownloadLocation = Test-Path $DownloadLocation
    if (!$TestDownloadLocation) {
        write-host "Creating Folder to download LGPO." -ForegroundColor Green
        new-item $DownloadLocation -ItemType Directory -force
    }
    $TestDownloadLocationExe = Test-Path "$DownloadLocation\LGPO.exe"
    if (!$TestDownloadLocationExe) { 
        write-host "Download LGPO." -ForegroundColor Green
        Invoke-WebRequest -UseBasicParsing -Uri $DownloadURL -OutFile "$DownloadLocation\LGPO.exe" 
    }
    $TestDownloadLocationBat = Test-Path "$DownloadLocation\LGPOExecute.bat"
    if (!$TestDownloadLocationBat) { 
        write-host "Generating configuration batch file." -ForegroundColor Green

        @"
LGPO.exe /t ComputerPolicy.txt /v > "C:\ProgramData\$GPOName $GPOVersion Computer.log"
LGPO.exe /t UserPolicy.txt /v > "C:\ProgramData\$GPOName $GPOVersion User.log"
"@ | Out-File -Encoding ascii "$DownloadLocation\LGPOExecute.bat" -Force
    
    }
}
catch {
    write-host "The download and extraction of LGPO.EXE failed. Error: $($_.Exception.Message)" -ForegroundColor Red
    exit 1
}
write-host "Clearing all existing policies." -ForegroundColor Green
#Clears all local policies
remove-item -Recurse -Path "$($ENV:windir)\System32\GroupPolicyUsers" -Force -erroraction silentlycontinue
remove-item -Recurse -Path "$($ENV:windir)\System32\GroupPolicy" -Force -erroraction silentlycontinue
remove-item -Recurse -Path "$DownloadLocation\ComputerPolicy.txt" -Force -erroraction silentlycontinue
remove-item -Recurse -Path "$DownloadLocation\Userpolicy.txt" -Force -erroraction silentlycontinue
write-host "Running GPUpdate to clear local policy cache." -ForegroundColor Green
gpupdate /force
write-host "Starting GPEdit. Please create your policy. After closing GPEdit we will resume." -ForegroundColor Green
start-process "gpedit.msc" -Wait
write-host "Exporting policies with LGPO." -ForegroundColor Green
& "$DownloadLocation\LGPO.EXE" /parse /m "$($ENV:windir)\System32\GroupPolicy\Machine\Registry.pol" > $DownloadLocation\ComputerPolicy.txt
& "$DownloadLocation\LGPO.EXE"  /parse /u "$($ENV:windir)\System32\GroupPolicy\User\Registry.pol" > $DownloadLocation\Userpolicy.txt
write-host "Sleeping for 10 seconds to give LGPO a chance to export all settings if GPO is large." -ForegroundColor Green
start-sleep 10
$UserDesktop = [Environment]::GetFolderPath("Desktop")
@"
Setup=LGPOExecute.bat
TempMode
Silent=1
"@ | out-file "$DownloadLocation\SFXConfig.conf" -Force

write-host "Creating Apply executable and placing on current user desktop." -ForegroundColor Green
& $WinrarPath -s a -ep1 -r -o+ -dh -ibck -sfx  -iadm -z"C:\temp\LGPO\SFXConfig.conf" "$UserDesktop\$GPOName $GPOVersion Apply Policy.exe" "$DownloadLocation\*"
start-sleep 3

write-host "Creating Remove executable and placing on current user desktop." -ForegroundColor Green
$ComputerPolicy = get-content "$DownloadLocation\ComputerPolicy.txt"
$UserPolicy = get-content "$DownloadLocation\UserPolicy.txt"
$ReplacementArray = @("DELETEKEYS", "DELETE", "QWORD", "SZ", "EXSZ", "MULTISZ", "BINARY", "CREATEKEY", "DELETEALLVALUES", "DWORD")
foreach ($Replacement in $ReplacementArray) {
    $ComputerPolicy = $ComputerPolicy | Foreach-Object { $_ -replace "^.*$replacement.*$", "CLEAR" }
    $UserPolicy = $UserPolicy | Foreach-Object { $_ -replace "^.*$replacement.*$", "CLEAR" }
}
$UserPolicy | out-file "$DownloadLocation\Userpolicy.txt"
$ComputerPolicy | out-file "$DownloadLocation\ComputerPolicy.txt"

& $winrarPath -s a -ep1 -r -o+ -dh -ibck -sfx  -iadm -z"C:\temp\LGPO\SFXConfig.conf" "$UserDesktop\$GPOName $GPOVersion Remove Policy.exe" "$DownloadLocation\*"
remove-item -Recurse -Path "$DownloadLocation\LGPOExecute.bat" -Force -erroraction silentlycontinue

Deploy the files on your desktop as-if its an application via your RMM.

Monitoring the deployment

So you can run the executable and when it runs it will create 2 log files in C:\ProgramData, one for User policies, the other for Computer policies. To monitor these, you check if the file exists and if it contains “Policy saved” in the last 3 lines.

$GPOFile = "C:\ProgramData\GPO 1.0 User.log"

$Check = Test-Path "C:\ProgramData\GPO 1.0 User.log"
if (!$Check) { 
    $Healthstate = "GPO has not deployed. Log file does not exist."
}
else {
    $State = get-content $GPOFile | Select-Object -last 3
    if ($state[0] -ne "POLICY SAVED.") { $Healthstate = "GPO Log found but policy not saved." } else { $Healthstate = "Healthy" }
}

And that’s it! With this you’ll be able to deploy GPOs as if you still have your local AD domain, something we really missed in our cloud only deployments. As always, Happy PowerShelling.

Monitoring with PowerShell: Monitoring Dell Driver Updates (DCU 3.1)

Previously I’ve written a blog about Dell Command Update and its ability to monitoring and download updates. This blog was based on Dell Command Update 2. As it is with all applications this started working less on newer machines. To resolve this Dell released a new major update for Dell Command Update which according to Dell, works on 99% of the Dell devices.

I really like monitoring if the device drivers are up to date, and all versions are as current as can be. Dell Command Update also allows you to install the updates on the device for remediation\

Updates Detection Script

The monitoring script downloads the installation file with the Dell Command Update utility. You can host the file yourself if you do not trust Dell as a source. The script installs DCU, sets the DCU service to manual, and runs the DCU-cli with the Report Parameter, I would advise to only run this set on an hourly or even daily schedule, using your RMM system of course.

You can choose what variables to alert on yourself – I like reporting on the count of updates, but I know others rather would alert on the title. At the bottom of the scripts I’ve added specific alerting options – You can choose which of these you find important.

$DownloadURL = "https://dl.dell.com/FOLDER05944445M/1/Dell-Command-Update_V104D_WIN_3.1.0_A00.EXE"
$DownloadLocation = "C:\Temp"

try {
    $TestDownloadLocation = Test-Path $DownloadLocation
    if (!$TestDownloadLocation) { new-item $DownloadLocation -ItemType Directory -force }
    $TestDownloadLocationZip = Test-Path "$DownloadLocation\DellCommandUpdate.exe"
    if (!$TestDownloadLocationZip) { 
        Invoke-WebRequest -UseBasicParsing -Uri $DownloadURL -OutFile "$($DownloadLocation)\DellCommandUpdate.exe"
        Start-Process -FilePath "$($DownloadLocation)\DellCommandUpdate.exe" -ArgumentList '/s' -Verbose -Wait
        set-service -name 'DellClientManagementService' -StartupType Manual
    }

}
catch {
    write-host "The download and installation of DCUCli failed. Error: $($_.Exception.Message)"
    exit 1
}

start-process "C:\Program Files\Dell\CommandUpdate\dcu-cli.exe" -ArgumentList "/scan -report=$DownloadLocation" -Wait
[ xml]$XMLReport = get-content "$DownloadLocation\DCUApplicableUpdates.xml" 
#We now remove the item, because we don't need it anymore, and sometimes fails to overwrite
remove-item "$DownloadLocation\DCUApplicableUpdates.xml" -Force

$AvailableUpdates = $XMLReport.updates.update

$BIOSUpdates = ($XMLReport.updates.update | Where-Object { $_.type -eq "BIOS" }).name.Count
$ApplicationUpdates = ($XMLReport.updates.update | Where-Object { $_.type -eq "Application" }).name.Count
$DriverUpdates = ($XMLReport.updates.update | Where-Object { $_.type -eq "Driver" }).name.Count
$FirmwareUpdates = ($XMLReport.updates.update | Where-Object { $_.type -eq "Firmware" }).name.Count
$OtherUpdates = ($XMLReport.updates.update | Where-Object { $_.type -eq "Other" }).name.Count
$PatchUpdates = ($XMLReport.updates.update | Where-Object { $_.type -eq "Patch" }).name.Count
$UtilityUpdates = ($XMLReport.updates.update | Where-Object { $_.type -eq "Utility" }).name.Count
$UrgentUpdates = ($XMLReport.updates.update | Where-Object { $_.Urgency -eq "Urgent" }).name.Count

So that’s the detecting updates portion, of course we also have the commandline to install the updates. Lets get started with that.

Remediation

Remediation is fairly straight forward. When using the switch /ApplyUpdates the updates start immediately. Of course we like having a little more control, so all the options are listed here. I’ve also included some examples:

Installing all updates, disable bitlocker, and reboot if required:

$DownloadLocation = "C:\Program Files\Dell\CommandUpdate"
start-process "$($DownloadLocation)\dcu-cli.exe" -ArgumentList "/applyUpdates -autoSuspendBitLocker=enable -reboot=enable" -Wait

This installs all available update found during the last scan including BIOS updates, suspends bitlocker, and reboots the computer immediately.

Installing all updates, do not disable Bitlocker, and do not reboot

$DownloadLocation = "C:\Program Files\Dell\CommandUpdate"
start-process "$($DownloadLocation)\dcu-cli.exe" -ArgumentList "/applyUpdates -autoSuspendBitLocker=disable -reboot=disable" -Wait

This installs all available update found during the last scan excluding BIOS updates, because we aren’t suspending bitlocker, and lets the user reboot the computer.

Install BIOS updates, suspend bitlocker, reboot

$DownloadLocation = "C:\Program Files\Dell\CommandUpdate"
start-process "$($DownloadLocation)\dcu-cli.exe" -ArgumentList "/applyUpdates -autoSuspendBitLocker=enable -reboot=enable -updateType=bios" -Wait

And this one installs only the BIOS updates. I think with these examples and the manual I’ve posted above you can figure out your exact preferred settings.

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

Documenting with Powershell: Documenting Hyper-V settings

It’s been a couple of weeks since I’ve touched my Documenting with PowerShell series. I figured to get it started again we get going with Hyper-v. I use Hyper-v for nearly all our virtualized deployments. This script documents the following items:

  • The current Virtual Machines
  • The Virtual Machine network settings
  • The host network settings
  • The host settings
  • and the Virtual Replication settings

So this script has both been tested on a larger hyper-v cluster and a local hyper-v machine. We use this information if we ever need to do a rebuild or just check how the system is setup.

As always I’ve made two versions. One for IT-Glue, and one that generates a HTML file.

IT-Glue version

The IT-Glue version of the script uploads a new Flexible asset if it does not exist, and fills the data for you. If you don’t feel confident with leaving your API key in a script because your RMM cannot handle credentials that well, please check out this blog I wrote about the IT-Glue API.

########################## IT-Glue ############################
$APIKEy = "ITGLUEAPIKEY"
$APIEndpoint = "https://api.eu.itglue.com"
$FlexAssetName = "Hyper-v AutoDoc v2"
$OrgID = "YOURORGID"
$Description = "A network one-page document that displays the current Hyper-V Settings and virtual machines"
#some layout options, change if you want colours to be different or do not like the whitespace.
$TableHeader = "<table class=`"table table-bordered table-hover`" style=`"width:80%`">"
$Whitespace = "<br/>"
$TableStyling = "<th>", "<th style=`"background-color:#4CAF50`">"
########################## IT-Glue ############################
#Grabbing ITGlue Module and installing.
If (Get-Module -ListAvailable -Name "ITGlueAPI") { 
    Import-module ITGlueAPI 
}
Else { 
    Install-Module ITGlueAPI -Force
    Import-Module ITGlueAPI
}
#Settings IT-Glue logon information
Add-ITGlueBaseURI -base_uri $APIEndpoint
Add-ITGlueAPIKey $APIKEy
write-host "Checking if Flexible Asset exists in IT-Glue." -foregroundColor green
$FilterID = (Get-ITGlueFlexibleAssetTypes -filter_name $FlexAssetName).data
if (!$FilterID) { 
    write-host "Does not exist, creating new." -foregroundColor green
    $NewFlexAssetData = 
    @{
        type          = 'flexible-asset-types'
        attributes    = @{
            name        = $FlexAssetName
            icon        = 'sitemap'
            description = $description
        }
        relationships = @{
            "flexible-asset-fields" = @{
                data = @(
                    @{
                        type       = "flexible_asset_fields"
                        attributes = @{
                            order           = 1
                            name            = "Host name"
                            kind            = "Text"
                            required        = $true
                            "show-in-list"  = $true
                            "use-for-title" = $true
                        }
                    },
                    @{
                        type       = "flexible_asset_fields"
                        attributes = @{
                            order          = 2
                            name           = "Virtual Machines"
                            kind           = "Textbox"
                            required       = $false
                            "show-in-list" = $false
                        }
                    },
                    @{
                        type       = "flexible_asset_fields"
                        attributes = @{
                            order          = 3
                            name           = "Network Settings"
                            kind           = "Textbox"
                            required       = $false
                            "show-in-list" = $false
                        }
                    },
                    @{
                        type       = "flexible_asset_fields"
                        attributes = @{
                            order          = 4
                            name           = "Replication Settings"
                            kind           = "Textbox"
                            required       = $false
                            "show-in-list" = $false
                        }
                    },
                    @{
                        type       = "flexible_asset_fields"
                        attributes = @{
                            order          = 5
                            name           = "Host Settings"
                            kind           = "Textbox"
                            required       = $false
                            "show-in-list" = $false
                        }
                    }
                )
            }
        }
    }
    New-ITGlueFlexibleAssetTypes -Data $NewFlexAssetData 
    $FilterID = (Get-ITGlueFlexibleAssetTypes -filter_name $FlexAssetName).data
}

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

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

$FlexAssetBody =
@{
    type       = 'flexible-assets'
    attributes = @{
        traits = @{
            'host-name'            = $env:COMPUTERNAME
            'virtual-machines'     = $VirtualMachines
            'network-settings'     = $NetworkSettings
            'replication-settings' = $ReplicationSettings
            'host-settings'        = $HostSettings
        }
    }
}

write-host "Documenting to IT-Glue"  -ForegroundColor Green
$ExistingFlexAsset = (Get-ITGlueFlexibleAssets -filter_flexible_asset_type_id $($filterID.ID) -filter_organization_id $OrgID).data | Where-Object { $_.attributes.traits.'host-name' -eq $ENV:computername }
#If the Asset does not exist, we edit the body to be in the form of a new asset, if not, we just upload.
if (!$ExistingFlexAsset) {
    $FlexAssetBody.attributes.add('organization-id', $OrgID)
    $FlexAssetBody.attributes.add('flexible-asset-type-id', $($filterID.ID))
    write-host "  Creating Hyper-v into IT-Glue organisation $OrgID" -ForegroundColor Green
    New-ITGlueFlexibleAssets -data $FlexAssetBody
}
else {
    write-host "  Editing Hyper-v into IT-Glue organisation $OrgID"  -ForegroundColor Green
    $ExistingFlexAsset = $ExistingFlexAsset[-1]
    Set-ITGlueFlexibleAssets -id $ExistingFlexAsset.id -data $FlexAssetBody
}

Generic HTML version

########################## IT-Glue ############################
$TableHeader = "<table class=`"table table-bordered table-hover`" style=`"width:80%`">"
$Whitespace = "<br/>"
$TableStyling = "<th>", "<th style=`"background-color:#4CAF50`">"
########################## IT-Glue ############################

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

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

$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>
"@
$head,$VirtualMachines,$NetworkSettings,$ReplicationSettings,$HostSettings |  Out-File "C:\temp\Hyper-v.html"

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

Monitoring with PowerShell: Monitoring SMART status using SmartCTL.

Some time ago I wrote a blog about monitoring SMART status with CrystalDiskInfo. After bringing this script over to our production RMM environment everything seemed good. But when I looked a little deeper I found that the script failed on NVME drives. NVME drives handle SMART-Status different from ‘regular’ SATA drives.

This started me on a quest for a solution that also worked on NVME drives. I’ve decided to use SmartMonTools as it has the same benefits as crystaldiskmark – It’s portable, does not require an installation, and is small enough to be downloaded on demand.

The script is fairly straightforward, it downloads the utility from a host, extracts the utility and runs an update for SmartCTL so it can fill in the data correctly. After this for each HDD in the system it will run a compare to the thresholds you’ve setup.

I’ve also had a request for disk monitoring on specifically the available spare count. The script can be edited to monitor this too – that way you can decide your own thresholds over what the manufacturer said is default for the disk.

The script

############ Thresholds #############
$PowerOnTime = 35063 #about 4 years constant runtime.
$PowerCycles = 4000 #4000 times of turning drive on and off
$Temperature = 60 #60 degrees celcius
############ End Thresholds #########
$DownloadURL = "https://cyberdrain.com/wp-content/uploads/2020/02/Smartmontools.zip"
$DownloadLocation = "$($Env:ProgramData)\SmartmonTools"
try {
    $TestDownloadLocation = Test-Path $DownloadLocation
    if (!$TestDownloadLocation) { new-item $DownloadLocation -ItemType Directory -force }
    $TestDownloadLocationZip = Test-Path "$DownloadLocation\Smartmontools.zip"
    if (!$TestDownloadLocationZip) { Invoke-WebRequest -UseBasicParsing -Uri $DownloadURL -OutFile "$($DownloadLocation)\Smartmontools.zip" }
    $TestDownloadLocationExe = Test-Path "$DownloadLocation\smartctl.exe"
    if (!$TestDownloadLocationExe) { Expand-Archive "$($DownloadLocation)\Smartmontools.zip" -DestinationPath $DownloadLocation -Force }
}
catch {
    write-host "The download and extraction of SMARTCTL failed. Error: $($_.Exception.Message)"
    exit 1
}
#update the smartmontools database
start-process -filepath "$DownloadLocation\update-smart-drivedb.exe" -ArgumentList "/S" -Wait
#find all connected HDDs
$HDDs = (& "$DownloadLocation\smartctl.exe" --scan -j | ConvertFrom-Json).devices
$HDDInfo = foreach ($HDD in $HDDs) {
    (& "$DownloadLocation\smartctl.exe" -t short -a -j $HDD.name) | convertfrom-json
}
$DiskHealth = @{}
#Checking SMART status
$SmartFailed = $HDDInfo | Where-Object { $_.Smart_Status.Passed -ne $true }
if ($SmartFailed) { $DiskHealth.add('SmartErrors',"Smart Failed for disks: $($SmartFailed.serial_number)") }
#checking Temp Status
$TempFailed = $HDDInfo | Where-Object { $_.temperature.current -ge $Temperature }
if ($TempFailed) { $DiskHealth.add('TempErrors',"Temperature failed for disks: $($TempFailed.serial_number)") }
#Checking Power Cycle Count status
$PCCFailed = $HDDInfo | Where-Object { $_.Power_Cycle_Count -ge $PowerCycles }
if ($PCCFailed ) { $DiskHealth.add('PCCErrors',"Power Cycle Count Failed for disks: $($PCCFailed.serial_number)") }
#Checking Power on Time Status
$POTFailed = $HDDInfo | Where-Object { $_.Power_on_time.hours -ge $PowerOnTime }
if ($POTFailed) { $DiskHealth.add('POTErrors',"Power on Time for disks failed : $($POTFailed.serial_number)") }

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

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