Options to Run PowerShell Automation Scripts
The question I have been asked the most since starting to release scripts is “How should I run them?” I originally started writing this blog post a couple of months ago, but in the process I realised I wasn’t entirely happy with how I was running scripts and the recommendations I was coming up with. Since then I have spent a lot of time thinking and have finally arrived at a solution I am happy with.
The most important thing to consider with scripts such as this is where you are sending/storing your API keys. One of the core things I wasn’t happy with is scripts running on remote user devices and having access to API keys. If a remote device was compromised it would mean access to your keys could be as well. With Datto RMM if you set a site variable with your keys, this is then passed every time any script is executed on any device in that site, this includes monitors. Having keys set at a component level would be a nightmare to manage.
From thinking about this I came up with three primary ways I was running scripts, which need to utilise API keys:
- On a central server when running bulk scripts such as Microsoft 365 based ones.
- On my local machine when doing development
- On remote devices when generating device specific documentation
On top of this I also had some other requirements I wanted to improve management and security.
- A single location for all keys to be stored securely.
- No API keys to be passed to any customer devices.
- Devices to only be able to update their own records, no view or delete access.
Central Scripts (Such as M365 Based Ones)
To tackle my problems, I already knew the best way to address this for bulk scripts. This is to convert them to run as Azure Functions and then make use of Azure Key Vault for storing credentials. Microsoft have made it incredibly easy to access Azure Key Vault secrets from Azure Functions. Using this as a base I decided to make Azure Key Vault my single store. To see how this works check out the Microsoft Docs on the subject https://docs.microsoft.com/en-us/azure/app-service/app-service-key-vault-references
For details on setting up Azure Functions check out a recent video Kelvin from Cyberdrain put out explaining how to do it https://www.cyberdrain.com/tech-in-5-minutes-azure-functions/
The biggest problem you might run into with these is the requirement to work with PowerShell 7. I recently rewrote my M365 Magic Dash module to work with Powershell 7 as an Azure Function. You can see the script here https://github.com/lwhitelock/HuduAutomation/blob/main/Hudu-M365-Sync-AzureFunction.ps1 The primary change I had to make was switching to using the AzureAD.Standard.Preview preview module instead of AzureADPreview and MSOnline.
Local Scripts
For my local development I decided to utilise certificate based authentication. I developed a small script that will generate a self signed certificate, store it in my local user’s certificate store and register it to allow access to Azure Key Vault. To see how to do this have a check of Gavin Stone’s blog, I found this after I had completed my setup, but it’s essentially the same process I use https://www.gavsto.com/msp-powershell-for-beginners-part-2-securely-store-credentials-passwords-api-keys-and-secrets/
You then replace the start of your scripts with the code to login to Azure Key Vault and retrieve your secrets to set your keys.
Remote Devices
The largest problem was figuring out how to deal with devices which needed to be able to provide information back to Hudu. In the end I created an Azure function script to act as a proxy. I pass a key through Datto RMM which is stored as an account level variable. Remote scripts then post this key along with the hostname and Datto RMM device ID, to the Azure Function Script. The function then does all the communicating to Datto RMM and Hudu keeping sensitive keys away from end devices. The remote function will first check the account key matches. It will then fetch the device from Datto RMM and check the hostname matches what was passed in. From here it will start to contact Hudu, it will download the relevant device asset layout and the device asset itself, that has been synchronised from Datto RMM to Hudu. This will also handle linking to the device asset from the asset being created / updated.
At the moment I have written it specifically for Datto RMM, but there is definitely scope to expand it to be more generic in the future so it becomes more like AzGlue. Before doing this I think it is best wait to see what Hudu looks like after the permissions rewrite to see if it is needed.
The Datto RMM Device to Hudu Proxy Script
To achieve this you need to split up your script into three parts. First you need to split out the asset layout creation to a standalone script you run once. Next you need to split out the data gathering and submission into a component you run in Datto. Finally you need to have the Azure function (You only need one of these and it is generic across all components you want to run)
Config
The first configuration you will need to do and then reuse in most scripts is to define the mapping you use in Hudu to map Datto RMM device type to Hudu Asset Layouts. In Hudu navigate to Admin -> Integrations -> Find Datto RMM and click Configure Integration -> Click Edit Settings on the left. Replicate the settings you have defined there in the HuduAssetTypeMapping object. Datto RMM Types need to be on the left Hudu Asset Layout name on the right. If you just use one layout for everything just set the default value to the name of the layout.
$HuduAssetTypeMapping = @{
'Default' = 'Misc Devices'
'Desktop' = 'Desktops / Laptops'
'Laptop' = 'Desktops / Laptops'
'Network Device (NAS)' = 'NAS Devices'
'Network Device (Network Appliance)' = 'Misc Devices'
'Network Device (Switch)' = 'Network Devices'
'Network Device (Router)' = 'Network Devices'
'Server' = 'Servers'
'ESXi Host' = 'Servers'
}
For this examples I am using the Device – Logbook script which I converted from Kelvin’s original Cyberdrain script https://github.com/lwhitelock/HuduAutomation/blob/main/CyberdrainRewrite/Hudu-Device-AuditLogs.ps1
The Azure Function Script
This script you can reuse for multiple different scripts.
In your function app you will need to configure the following Application Settings I recommend you store the actual values in Azure Key Vault and reference them using this method: https://docs.microsoft.com/en-us/azure/app-service/app-service-key-vault-references
Name | Description |
HuduAPIKey | Your Hudu API Key from https://yourhududomain.com/admin/api_keys |
HuduBaseDomain | Your base domain of your Hudu instance without a trailing / |
DattoURL | The Datto URL provided when setting up a Datto RMM API Key. View Activate the API here https://help.aem.autotask.net/en/Content/2SETUP/APIv2.htm |
DattoKey | The Key provided to you when you setup the Datto RMM API User |
DattoSecretKey | The Secret Key provided to you when you setup the Datto RMM API User |
DattoHuduDeviceKey | Generate a long random string to use to authenticate to the function. |
Create a new function in your app with an HTTP trigger, set to receive POST requests and set it to anonymous authentication.
Edit the script to add your Device mappings to $HuduAssetTypeMapping from the values you prepared before.
using namespace System.Net
# Input bindings are passed in via param block.
param($Request, $TriggerMetadata)
# Write to the Azure Functions log stream.
Write-Host "PowerShell Datto RMM Device to Hudu function processed a request."
#######################################################
#####################################################################
# Get a Hudu API Key from https://yourhududomain.com/admin/api_keys
$HuduAPIKey = $Env:HuduAPIKey
# Set the base domain of your Hudu instance without a trailing /
$HuduBaseDomain = $Env:HuduBaseDomain
######################### Datto RMM Settings ###########################
$DattoURL = $Env:DattoURL
$DattoKey = $Env:DattoKey
$DattoSecretKey = $Env:DattoSecretKey
$DattoHuduDeviceKey = $Env:DattoHuduDeviceKey
$HuduAssetTypeMapping = @{
'Default' = 'Misc Devices'
'Desktop' = 'Desktops / Laptops'
'Laptop' = 'Desktops / Laptops'
'Network Device (NAS)' = 'NAS Devices'
'Network Device (Network Appliance)' = 'Misc Devices'
'Network Device (Switch)' = 'Network Devices'
'Network Device (Router)' = 'Network Devices'
'Server' = 'Servers'
'ESXi Host' = 'Servers'
}
function Get-HuduType{
param(
[String]$DeviceType
)
$HuduType = $HuduAssetTypeMapping.$DeviceType
if (!$HuduType){
$HuduType = $HuduAssetTypeMapping.Default
}
Return $HuduType
}
######### Start
Import-Module HuduAPI
Import-Module DattoRMM
#Set Hudu logon information
New-HuduAPIKey $HuduAPIKey
New-HuduBaseUrl $HuduBaseDomain
# Provide API Parameters
$params = @{
Url = $DattoURL
Key = $DattoKey
SecretKey = $DattoSecretKey
}
# Set API Parameters
Set-DrmmApiParameters @params
# Process Post Data
$DeviceData = $Request.RawBody | ConvertFrom-Json
$DeviceID = $DeviceData.DeviceID
$DeviceName = $DeviceData.DeviceName
$DeviceKey = $DeviceData.DeviceKey
$ReturnResultCode = 200
$ReturnResultMessage = ""
try{
# Check if global key matches
If ($DeviceKey -cne $DattoHuduDeviceKey){
$ReturnResultCode = 401
$ReturnResultMessage = "Incorrect Device Key"
Throw
}
Write-Host "Get Datto Device"
# Fetch device from Datto RMM
$DattoDevice = Get-DrmmDevice $DeviceID
# Check if we found a matching device in Datto
if (!(($DattoDevice | Measure-Object).count -eq 1 -and $DattoDevice.hostname -eq $DeviceName)){
$ReturnResultCode = 401
$ReturnResultMessage = "Device ID or Name not matched in Datto"
Throw
}
Write-Host "Get Passed Layout"
# Fetch passed in layout from post
$Layout = Get-HuduAssetLayouts -name $DeviceData.HuduAssetLayoutName
# Check if we found a matching layout in Hudu
if (($Layout | Measure-Object).count -ne 1){
$ReturnResultCode = 400
$ReturnResultMessage = "Asset Layout Not Found"
Throw
}
Write-Host "Get Device Layout"
# Fetch the layout for the device based on its type in Datto
$DeviceLayoutName = $(Get-HuduType($DattoDevice.deviceType.category))
$DeviceLayout = Get-HuduAssetLayouts -name $DeviceLayoutName
# Check if we found a matching layout for the device type in Hudu
if (($DeviceLayout | Measure-Object).count -ne 1){
$ReturnResultCode = 400
$ReturnResultMessage = "Device Asset Layout Not Found"
Throw
}
Write-Host "Get Hudu Device"
# Fetch the synced device from Hudu
$HuduDevice = Get-HuduAssets -name $DeviceName -assetlayoutid $DeviceLayout.id
# Check we found a matching device in Hudu
if (($HuduDevice | Measure-Object).count -ne 1){
$ReturnResultCode = 400
$ReturnResultMessage = "Device Not Found in Hudu, please make sure it has synced from Datto RMM"
Throw
}
# Check we have the device synced between Datto RMM and Hudu
if ($HuduDevice.cards.sync_identifier -ne $DeviceData.DeviceID) {
$ReturnResultCode = 400
$ReturnResultMessage = "Device from Hudu did not match Datto RMM ID"
Throw
}
# Checks all passed now we can update the asset
$AssetFields = $DeviceData.HuduAssetData
Write-Host "Get Linked Field"
# Prepare the asset link field
if ($DeviceData.HuduDeviceLinkField -ne "") {
$LinkRaw = @{
id = $HuduDevice.id
name = $HuduDevice.name
}
$Link = $LinkRaw | convertto-json -compress -AsArray | Out-String
$AssetFields | add-member -membertype NoteProperty -name $DeviceData.HuduDeviceLinkField -value $Link
}
Write-Host "Create in Hudu"
$AssetName = "$($DeviceData.HuduNamePrefix)$($DeviceName)"
$Asset = Get-HuduAssets -name $AssetName -companyid $HuduDevice.comapny_id -assetlayoutid $Layout.id
#See if we need to update or create
if (!$Asset) {
Write-Host "Creating new Asset"
$ReturnAsset = (New-HuduAsset -name $AssetName -company_id $HuduDevice.company_id -asset_layout_id $Layout.id -fields $AssetFields).asset
$ReturnResultMessage = "Asset Created: $($ReturnAsset.ID)"
}
else {
Write-Host "Updating Asset"
$ReturnAsset = (Set-HuduAsset -asset_id $Asset.id -name $AssetName -company_id $Asset.company_id -asset_layout_id $Layout.id -fields $AssetFields).asset
$ReturnResultMessage = "Asset Updated: $($ReturnAsset.ID)"
}
} catch {
if ($ReturnResultCode -eq 200){
Write-Host "An Error Occured: $_"
} else {
Write-Host "Checks Failed $ReturnResultCode - $ReturnResultMessage"
}
}
# Associate values to output bindings by calling 'Push-OutputBinding'.
Push-OutputBinding -Name Response -Value ([HttpResponseContext]@{
StatusCode = $ReturnResultCode
Body = $ReturnResultMessage
})
Once you have created the script you need to determine what the URL for it is. By default it is http://<APP_NAME>.azurewebsites.net/api/<FUNCTION_NAME> but you can customise this if you wish.
The asset layout creation script
This script will create the asset layouts needed and will only need to be run one time. In this script it will prompt you for your Hudu Credentials. You would need to set the $DeviceTypes to the Asset Layout names which match the devices you are going to run the script on in Hudu. So for me I synchronise all Desktops and Laptops to one Layout Type and all Servers to another.
For asset linking to work you have to do one layout per device layout as you can only do a link field to one asset layout type.
Once you have set these to the matching layouts from Hudu run the script once to create the layouts that will be populated by your devices.
#####################################
#### Hudu Settings ####
$HuduAPIKey = Read-Host "Enter Hudu API Key"
# Set the base domain of your Hudu instance without a trailing /
$HuduBaseDomain = Read-Host "Enter Hudu URL without a trailing /"
# This will be appended to the name of the Asset type this computer is created in Hudu as.
$HuduAppendedAssetLayoutName = " - Logbook"
# Create a list of Hudu Asset Layouts for the device Types this script will be run on
$DeviceTypes = @('Desktops / Laptops', 'Servers')
#####################################################################
#Get the Hudu API Module if not installed
if (Get-Module -ListAvailable -Name HuduAPI) {
Import-Module HuduAPI
} else {
Install-Module HuduAPI -Force
Import-Module HuduAPI
}
#Set Hudu logon information
New-HuduAPIKey $HuduAPIKey
New-HuduBaseUrl $HuduBaseDomain
foreach ($LayoutName in $DeviceTypes){
$HuduAssetLayoutName = $LayoutName + $HuduAppendedAssetLayoutName
$ParentLayout = Get-HuduAssetLayouts -name $LayoutName
if($ParentLayout){
$Layout = Get-HuduAssetLayouts -name $HuduAssetLayoutName
if (!$Layout) {
$AssetLayoutFields = @(
@{
label = 'Device Name'
field_type = 'Text'
show_in_list = 'true'
position = 1
},
@{
label = 'Device'
field_type = 'AssetTag'
show_in_list = 'false'
linkable_id = $ParentLayout.id
position = 2
},
@{
label = 'Events'
field_type = 'RichText'
show_in_list = 'false'
position = 3
},
@{
label = 'User Profiles'
field_type = 'RichText'
show_in_list = 'false'
position = 4
},
@{
label = 'Installed Updates'
field_type = 'RichText'
show_in_list = 'false'
position = 5
},
@{
label = 'Installed Software'
field_type = 'RichText'
show_in_list = 'false'
position = 6
}
)
Write-Host "Creating New Asset Layout $HuduAssetLayoutName"
$null = New-HuduAssetLayout -name $HuduAssetLayoutName -icon "fas fa-book" -color "#4CAF50" -icon_color "#ffffff" -include_passwords $false -include_photos $false -include_comments $false -include_files $false -fields $AssetLayoutFields
$Layout = Get-HuduAssetLayouts -name $HuduAssetLayoutName
}
} else {
Write-Host "Device Layout: $LayoutName Not Found"
}
}
Modifying the Datto Component Script
To modify a script to run with the Azure Function you first need to remove the Asset Layout creation parts. You then need to replace the section that would create or update assets in Hudu with
$DeviceData = @{
DeviceID = $DeviceID
DeviceName =$DeviceName
DeviceKey = $DeviceKey
HuduNamePrefix = ''
HuduAssetLayoutName = $AssetLayoutName
HuduAssetData = $AssetFields
HuduDeviceLinkField = 'Device'
}
$DeviceJSON = $DeviceData | ConvertTo-Json -Depth 100
Invoke-RestMethod -Method Post -uri $AzureFunctionURL -ContentType 'application/json' -body $DeviceJSON
The DeviceData object contains all the settings you need to post the the Azure function. Make sure you set the HuduDeviceLinkField to the same name as the AssetTag field in the Asset Layout if you changed it. The Azure function will handle linking the device object to the Asset being created and also handle the create of update logic.
When run if successful the Azure function will return “Asset Created” or “Asset Updated” with the Asset ID that was created / updated.
The Datto RMM Component
This script is the one you will create in Datto RMM.
You will need to create a couple of account level variables to hold the settings the script needs:
Name | Description |
DattoHuduDeviceKey | The randomly generated code you created above |
DattoHuduProxyURL | This is the URL to your function in your function app |
Because of the nature of Hudu you can have multiple different asset layouts for different device types this script needs to replicate that. This uses a function to determine if the device is a desktop, laptop or server. I have tried my best to reverse engineer the method Datto uses to determine this, but I would recommend you test the Get-DeviceType function to make sure it returns the correct types in your environment.
Create the script as a new PowerShell component and run it against a device of each type to test it creates assets. Once you are sure it is working you can schedule it to run once every 24 hours against the devices you wish to monitor.
# Based on the original script by Kelvin Tegelaar https://github.com/KelvinTegelaar/AutomaticDocumentation
#####################################################################
$TableStyling = "<th>", "<th style=`"background-color:#4CAF50`">"
# This will be appended to the name of the Asset type this computer is created in Hudu as.
$HuduAppendedAssetLayoutName = " - Logbook"
#####################################################################
$DeviceID = (Get-ItemProperty -Path HKLM:\SOFTWARE\CentraStage\ -name DeviceID).DeviceID
$DeviceName = $env:computername
$DeviceKey = $env:DattoHuduDeviceKey
$AzureFunctionURL = $env:DattoHuduProxyURL
$HuduAssetTypeMapping = @{
'Default' = 'Misc Devices'
'Desktop' = 'Desktops / Laptops'
'Laptop' = 'Desktops / Laptops'
'Network Device (NAS)' = 'NAS Devices'
'Network Device (Network Appliance)' = 'Misc Devices'
'Network Device (Switch)' = 'Network Devices'
'Network Device (Router)' = 'Network Devices'
'Server' = 'Servers'
'ESXi Host' = 'Servers'
}
function Get-HuduType{
param(
[String]$DeviceType
)
$HuduType = $HuduAssetTypeMapping.$DeviceType
if (!$HuduType){
$HuduType = $HuduAssetTypeMapping.Default
}
Return $HuduType
}
function Get-DeviceType{
if ((Get-CimInstance Win32_OperatingSystem).caption -match "Server"){
Return 'Server'
} else {
if ("$((Get-CimInstance -ClassName Win32_SystemEnclosure -Property ChassisTypes).ChassisTypes)" -in @("8","9","10","14","30","31","32")) {
return 'Laptop'
} else {
return 'Desktop'
}
}
}
$DeviceType = Get-DeviceType
$ComputerName = $($Env:COMPUTERNAME)
$AssetLayoutName = $(Get-HuduType($DeviceType)) + $HuduAppendedAssetLayoutName
write-host "Starting documentation process." -foregroundColor green
write-host "Getting update history." -foregroundColor green
$date = Get-Date
$hotfixesInstalled = get-hotfix
write-host "Getting User Profiles." -foregroundColor green
$UsersProfiles = Get-CimInstance win32_userprofile | Where-Object { $_.special -eq $false } | select-object localpath, LastUseTime, Username
$UsersProfiles = foreach ($Profile in $UsersProfiles) {
$profile.username = ($profile.localpath -split '\', -1, 'simplematch') | Select-Object -Last 1
$Profile
}
write-host "Getting Installed applications." -foregroundColor green
$InstalledSoftware = (Get-ChildItem "HKLM:\Software\Microsoft\Windows\CurrentVersion\Uninstall\" | Get-ItemProperty) + ($software += Get-ChildItem "HKLM:\Software\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall\" | Get-ItemProperty) | Select-Object Displayname, Publisher, Displayversion, InstallLocation, InstallDate
$installedSoftware = foreach ($Application in $installedSoftware) {
if ($null -eq $application.InstallLocation) { continue }
if ($null -eq $Application.InstallDate) { $application.installdate = (get-item $application.InstallLocation -ErrorAction SilentlyContinue).CreationTime.ToString('yyyyMMdd') }
$Application.InstallDate = [datetime]::parseexact($Application.InstallDate, 'yyyyMMdd', $null).ToString('yyyy-MM-dd HH:mm')
if ($null -eq $application.InstallDate) { continue }
$application
}
write-host "Checking WAN IP" -foregroundColor green
$events = @()
$previousIP = get-content "$($env:ProgramData)/LastIP.txt" -ErrorAction SilentlyContinue | Select-Object -first 1
if (!$previousIP) { Write-Host "No previous IP found. Compare will fail." }
$Currentip = (Invoke-RestMethod -Uri "https://ipinfo.io/ip") -replace "`n", ""
$Currentip | out-file "$($env:ProgramData)/LastIP.txt" -Force
if ($Currentip -ne $previousIP) {
$Events += [pscustomobject]@{
date = $date.ToString('yyyy-MM-dd HH:mm')
Event = "WAN IP has changed from $PreviousIP to $CurrentIP"
type = "WAN Event"
}
}
write-host "Getting Installed applications in last 24 hours for events list" -foregroundColor green
$InstalledInLast24Hours = $installedsoftware | where-object { $_.installDate -ge $date.addhours(-24).tostring('yyyy-MM-dd') }
foreach ($installation in $InstalledInLast24Hours) {
$Events += [pscustomobject]@{
date = $installation.InstallDate
Event = "New Software: $($Installation.displayname) has been installed or updated."
type = "Software Event"
}
}
write-host "Getting KBs in last 24 hours for events list" -foregroundColor green
$hotfixesInstalled = get-hotfix | where-object { $_.InstalledOn -ge $date.adddays(-2) }
foreach ($InstalledHotfix in $hotfixesInstalled) {
$Events += [pscustomobject]@{
date = $InstalledHotfix.installedOn.tostring('yyyy-MM-dd HH:mm')
Event = "Update $($InstalledHotfix.Hotfixid) has been installed."
type = "Update Event"
}
}
write-host "Getting user logon/logoff events of last 24 hours." -foregroundColor green
$UserProfilesDir = get-childitem "C:\Users"
foreach ($Users in $UserProfilesDir) {
if ($users.CreationTime -gt $date.AddDays(-1)) {
$Events += [pscustomobject]@{
date = $users.CreationTime.tostring('yyyy-MM-dd HH:mm')
Event = "First time logon: $($Users.name) has logged on for the first time."
type = "User event"
}
}
$NTUser = get-item "$($users.FullName)\NTUser.dat" -force -ErrorAction SilentlyContinue
if ($NTUser.LastWriteTime -gt $date.AddDays(-1)) {
$Events += [pscustomobject]@{
date = $NTUser.LastWriteTime.tostring('yyyy-MM-dd HH:mm')
Event = "Logoff: $($Users.name) has logged off or restarted the computer."
type = "User event"
}
}
if ($NTUser.LastAccessTime -gt $date.AddDays(-1)) {
$Events += [pscustomobject]@{
date = $NTUser.LastAccessTime.tostring('yyyy-MM-dd HH:mm')
Event = "Logon: $($Users.name) has logged on."
type = "User event"
}
}
}
$events = $events | Sort-Object -Property date -Descending
$eventshtml = ($Events | convertto-html -fragment | out-string) -replace $TableStyling
$ProfilesHTML = ($UsersProfiles | convertto-html -Fragment | out-string) -replace $TableStyling
$updatesHTML = ($hotfixesInstalled | select-object InstalledOn, Hotfixid, caption, InstalledBy | convertto-html -Fragment | out-string) -replace $TableStyling
$SoftwareHTML = ($installedSoftware | convertto-html -Fragment | out-string) -replace $TableStyling
# Populate Asset Fields
$AssetFields = @{
'device_name' = $DeviceName
'events' = $eventshtml
'user_profiles' = $ProfilesHTML
'installed_updates' = $UpdatesHTML
'installed_software' = $SoftwareHTML
}
# Prepare the postdata and submit
$DeviceData = @{
DeviceID = $DeviceID
DeviceName =$DeviceName
DeviceKey = $DeviceKey
HuduNamePrefix = ''
HuduAssetLayoutName = $AssetLayoutName
HuduAssetData = $AssetFields
HuduDeviceLinkField = 'Device'
}
$DeviceJSON = $DeviceData | ConvertTo-Json -Depth 100
Invoke-RestMethod -Method Post -uri $AzureFunctionURL -ContentType 'application/json' -body $DeviceJSON
Great article. One thing I ran into trouble with was importing the HuduAPI into the Azure Function. Maybe I missed it, but you have to add a dependency for it. I used the sites below to add the dependency. I was also able to modify it to use with Syncro and the Hypver V documentation script. Thanks again.
https://docs.microsoft.com/en-us/azure/azure-functions/functions-reference-powershell?tabs=portal#dependency-management
https://tech.nicolonsky.ch/azure-functions-powershell-modules/