CloudFlare
Automatically Document CloudFlare Zones to NinjaOne
Last updated
Automatically Document CloudFlare Zones to NinjaOne
Last updated
This script will generate a NinjaOne Document template and NinjaOne documents for every domain contained in CloudFlare
Make sure the Getting Started guide has been followed
Core RMM
NinjaOne Documentation
Once per day
Display Name | Name | Type | Permissions | Description |
---|---|---|---|---|
CloudFlare API Token | cloudflareToken | Secure | Technician: Editable Automations: Read/Write API: None | Your CloudFlare API Token |
None
To run this script create a new CloudFlare API Token with the following permissions:
All accounts - Account Settings:Read
All zones - Origin Rules:Read, Zone Settings:Read, Zone:Read, DNS:Read, SSL and Certificates:Read, Logs:Read, Page Rules:Read, Firewall Services:Read, Analytics:Read
Once this is created edit the CloudFlare API Token field on your script running device to add in this key.
Domains are matched using NinjaOne Cloud Monitors. In order to match a domain to an Organization configure a cloud monitor that points to that domain as the target of the Cloud Monitor.
Alternatively if you do not wish to create a Cloud Monitor, you can run the script once and then create a blank Cloudflare document under the correct organization with a name that matches the domain in CloudFlare.
$Start = Get-Date
$NinjaOneInstance = Ninja-Property-Get ninjaoneInstance
$NinjaOneClientID = Ninja-Property-Get ninjaoneClientId
$NinjaOneClientSecret = Ninja-Property-Get ninjaoneClientSecret
$CloudFlareToken = Ninja-Property-Get cloudflareToken
try {
$moduleName = "NinjaOneDocs"
if (-not (Get-Module -ListAvailable -Name $moduleName)) {
Install-Module -Name $moduleName -Force -AllowClobber
} else {
$latestVersion = (Find-Module -Name $moduleName).Version
$installedVersion = (Get-Module -ListAvailable -Name $moduleName).Version | Sort-Object -Descending | Select-Object -First 1
if ($installedVersion -ne $latestVersion) {
Update-Module -Name $moduleName -Force
}
}
Import-Module $moduleName
$BaseURL = 'https://api.cloudflare.com/client/v4'
function Get-CloudFlarePage {
param (
[string]$Uri
)
$Page = 0
[System.Collections.Generic.List[PSCustomObject]]$Array = @()
do {
$Page++
$Result = (Invoke-WebRequest -URI "$($Uri)?per_page=50&page=$Page" -Method GET -Headers $Script:CloudFlareAuthHeaders -UseBasicParsing).content | convertfrom-json
$Result.result | foreach-object {
$Array.add($_)
}
} while ($Page -lt $Result.result_info.total_pages)
Return $Array
}
function Compare-NestedObjects($obj1, $obj2) {
$props1 = $obj1 | Get-Member -MemberType Properties | Select-Object -ExpandProperty Name
$props2 = $obj2 | Get-Member -MemberType Properties | Select-Object -ExpandProperty Name
$allProps = $props1 + $props2 | Select-Object -Unique
foreach ($prop in $allProps) {
$value1 = $obj1.$prop
$value2 = $obj2.$prop
if ($value1 -is [PSCustomObject] -and $value2 -is [PSCustomObject]) {
# Recursive call for nested objects
Compare-NestedObjects $value1 $value2
} elseif ($value1 -ne $value2) {
# Output the difference
$ReturnItem = [PSCustomObject]@{
Property = $prop
}
if ($value1) {
$ReturnItem | Add-Member -NotePropertyName 'Original' -NotePropertyValue "$($value1 | Out-String)"
}
if ($value2) {
$ReturnItem | Add-Member -NotePropertyName 'New' -NotePropertyValue "$($value2 | Out-String)"
}
$ReturnItem
}
}
}
Connect-NinjaOne -NinjaOneInstance $NinjaOneInstance -NinjaOneClientID $NinjaOneClientID -NinjaOneClientSecret $NinjaOneClientSecret
$CloudFlareTemplate = [PSCustomObject]@{
name = 'Cloudflare'
allowMultiple = $true
fields = @([PSCustomObject]@{
fieldLabel = 'Link'
fieldName = 'link'
fieldType = 'WYSIWYG'
fieldTechnicianPermission = 'READ_ONLY'
fieldScriptPermission = 'NONE'
fieldApiPermission = 'READ_WRITE'
fieldContent = @{
required = $False
advancedSettings = @{
expandLargeValueOnRender = $True
}
}
},
[PSCustomObject]@{
fieldLabel = 'Status'
fieldName = 'status'
fieldType = 'TEXT'
fieldTechnicianPermission = 'READ_ONLY'
fieldScriptPermission = 'NONE'
fieldApiPermission = 'READ_WRITE'
},
[PSCustomObject]@{
fieldLabel = 'Name Servers'
fieldName = 'nameServers'
fieldType = 'TEXT'
fieldTechnicianPermission = 'READ_ONLY'
fieldScriptPermission = 'NONE'
fieldApiPermission = 'READ_WRITE'
},
[PSCustomObject]@{
fieldLabel = 'Original Name Servers'
fieldName = 'originalNameServers'
fieldType = 'TEXT'
fieldTechnicianPermission = 'READ_ONLY'
fieldScriptPermission = 'NONE'
fieldApiPermission = 'READ_WRITE'
},
[PSCustomObject]@{
fieldLabel = 'Original Registrar'
fieldName = 'originalRegistrar'
fieldType = 'TEXT'
fieldTechnicianPermission = 'READ_ONLY'
fieldScriptPermission = 'NONE'
fieldApiPermission = 'READ_WRITE'
},
[PSCustomObject]@{
fieldLabel = 'Modified On'
fieldName = 'modifiedOn'
fieldType = 'DATE_TIME'
fieldTechnicianPermission = 'READ_ONLY'
fieldScriptPermission = 'NONE'
fieldApiPermission = 'READ_WRITE'
},
[PSCustomObject]@{
fieldLabel = 'Account'
fieldName = 'account'
fieldType = 'TEXT'
fieldTechnicianPermission = 'READ_ONLY'
fieldScriptPermission = 'NONE'
fieldApiPermission = 'READ_WRITE'
},
[PSCustomObject]@{
fieldLabel = 'Plan'
fieldName = 'plan'
fieldType = 'TEXT'
fieldTechnicianPermission = 'READ_ONLY'
fieldScriptPermission = 'NONE'
fieldApiPermission = 'READ_WRITE'
},
[PSCustomObject]@{
fieldLabel = 'Plan Cost'
fieldName = 'planCost'
fieldType = 'TEXT'
fieldTechnicianPermission = 'READ_ONLY'
fieldScriptPermission = 'NONE'
fieldApiPermission = 'READ_WRITE'
},
[PSCustomObject]@{
fieldLabel = 'DNSSEC Status'
fieldName = 'dnssecStatus'
fieldType = 'TEXT'
fieldTechnicianPermission = 'READ_ONLY'
fieldScriptPermission = 'NONE'
fieldApiPermission = 'READ_WRITE'
},
[PSCustomObject]@{
fieldLabel = 'DNS Records'
fieldName = 'dnsRecords'
fieldType = 'WYSIWYG'
fieldTechnicianPermission = 'READ_ONLY'
fieldScriptPermission = 'NONE'
fieldApiPermission = 'READ_WRITE'
fieldContent = @{
required = $False
advancedSettings = @{
expandLargeValueOnRender = $True
}
}
},
[PSCustomObject]@{
fieldLabel = 'Zone Settings'
fieldName = 'zoneSettings'
fieldType = 'WYSIWYG'
fieldTechnicianPermission = 'READ_ONLY'
fieldScriptPermission = 'NONE'
fieldApiPermission = 'READ_WRITE'
fieldContent = @{
required = $False
advancedSettings = @{
expandLargeValueOnRender = $False
}
}
},
[PSCustomObject]@{
fieldLabel = 'BIND File'
fieldName = 'bindFile'
fieldType = 'WYSIWYG'
fieldTechnicianPermission = 'READ_ONLY'
fieldScriptPermission = 'NONE'
fieldApiPermission = 'READ_WRITE'
fieldContent = @{
required = $False
advancedSettings = @{
expandLargeValueOnRender = $False
}
}
},
[PSCustomObject]@{
fieldLabel = 'Last 20 Audit Log Entries'
fieldName = 'auditLog'
fieldType = 'WYSIWYG'
fieldTechnicianPermission = 'READ_ONLY'
fieldScriptPermission = 'NONE'
fieldApiPermission = 'READ_WRITE'
fieldContent = @{
required = $False
advancedSettings = @{
expandLargeValueOnRender = $False
}
}
}
)
}
$CFDocTemplate = Invoke-NinjaOneDocumentTemplate $CloudFlareTemplate
$CloudFlareDocs = Invoke-NinjaOneRequest -Method GET -Path 'organization/documents' -QueryParams "templateIds=$($CFDocTemplate.id)"
[System.Collections.Generic.List[PSCustomObject]]$NinjaDocUpdates = @()
[System.Collections.Generic.List[PSCustomObject]]$NinjaDocCreation = @()
$NinjaOneCloudMonitors = Invoke-NinjaOneRequest -Method GET -Path 'devices-detailed' -QueryParams "df=class=CLOUD_MONITOR_TARGET" -Paginate
$Script:CloudFlareAuthHeaders = @{
'Authorization' = "Bearer $CloudFlareToken"
}
$Zones = Get-CloudFlarePage -URI "$BaseURL/zones"
[System.Collections.Generic.List[PSCustomObject]]$UnmatchedZones = @()
foreach ($Zone in $Zones) {
try {
$MatchedDoc = $CloudFlareDocs | Where-Object { $_.documentName -eq $Zone.name }
$MatchCount = ($MatchedDoc | measure-object).count
# Match to a CloudMonitor
if ($MatchCount -eq 0) {
$NinjaMatch = ($NinjaOneCloudMonitors | where-object { $Zone.name -eq ((($_.target -replace 'https://', '') -replace 'www.', '') -split '/')[0] } | Select-Object organizationId -Unique).organizationId
$MatchCFCount = ($NinjaMatch | measure-object).count
if ($MatchCFCount -ne 1) {
$UnmatchedZones.add($Zone)
continue
}
} elseif ($MatchCount -gt 1) {
Throw "Multiple NinjaOne Documents ($($MatchedDoc.documentId -join '')) matched to $($Zone.name)"
continue
} else {
$NinjaMatch = $MatchedDoc.organizationId
}
$AuditLogs = ((Invoke-WebRequest -URI "$BaseURL/accounts/$($Zone.account.id)/audit_logs?zone.name=$($Zone.name)&direction=desc&per_page=20" -Method GET -Headers $Script:CloudFlareAuthHeaders -UseBasicParsing).Content | ConvertFrom-Json).result
[System.Collections.Generic.List[string]]$LogTable = @()
$LogTable.add('<div class="g-3">')
foreach ($Log in $AuditLogs) {
if ($Log.actor.type -eq 'System') {
$ActorName = 'System'
$ActorIP = 'N/A'
} else {
$ActorName = $Log.actor.email
$ActorIP = $Log.actor.ip
}
if ($Log.oldValueJson -and $Log.newValueJson ) {
$Diff = Compare-NestedObjects $Log.oldValueJson $Log.newValueJson -ea Stop
$DiffParsed = foreach ($Change in $Diff) {
if ($Change.Original -ne $Change.New) {
$Change
}
}
$DiffTable = "$(($DiffParsed | ConvertTo-HTML -As Table -Fragment) -replace '<th>','<th style="white-space: nowrap;">')"
} else {
$DiffTable = 'N/A'
}
$LogData = [PSCustomObject]@{
'Date / Time' = $Log.when
'Action Type' = $Log.action.type
'Action Info' = $Log.action.info
'Changed By' = $ActorName
'Changed By IP' = $ActorIP
}
$LogCard = Get-NinjaOneInfoCard -Title "Log Details" -Data $LogData
$ChangeCard = Get-NinjaOneCard -Title 'Changed' -Body $DiffTable
$LogRow = '<div class="row g-3 pb-3"><div class="col-xl-4 col-lg-4 col-md-12 col-sm-12 d-flex">' + $LogCard + '</div><div class="col-xl-8 col-lg-8 col-md-12 col-sm-12 d-flex">' + $ChangeCard + '</div></div>'
$LogTable.add($LogRow)
}
$Logtable.add('</div>')
$ZoneRecords = Get-CloudFlarePage -URI "$BaseURL/zones/$($Zone.ID)/dns_records"
$ZoneHTML = $ZoneRecords | Select-Object @{N = 'Name'; E = { $_.name } }, @{N = 'Type'; E = { '<span style="white-space: nowrap;">' + "$($_.type)</span>" } }, @{N = 'Content'; E = { $_.content } }, @{N = 'Proxied'; E = { '<span style="white-space: nowrap;">' + "$($_.proxied)</span>" } }, @{N = 'TTL'; E = { '<span style="white-space: nowrap;">' + "$($_.ttl)</span>" } }, @{N = 'Modified'; E = { '<span style="white-space: nowrap;">' + "$($_.modified_on)</span>" } } | convertto-html -as Table -Fragment | out-String
$ZoneHTML = [System.Web.HttpUtility]::HtmlDecode(($ZoneHTML -replace '<th>', '<th style="white-space: nowrap;">'))
$ZoneSettings = Get-CloudFlarePage -URI "$BaseURL/zones/$($Zone.ID)/settings"
$ZoneSettingsHTML = $ZoneSettings | Select-Object @{N = 'Setting'; E = { (Get-Culture).TextInfo.ToTitleCase(($_.id -replace '_', ' ').ToLower()) } }, @{N = 'Value'; E = { (Get-Culture).TextInfo.ToTitleCase(($_.value -replace '_', ' ').ToLower()) } }, @{N = 'Modified'; E = { $_.modified_on } } | convertto-html -as Table -Fragment | out-string
$DNSSec = Get-CloudFlarePage -URI "$BaseURL/zones/$($Zone.ID)/dnssec"
$FirewallRules = Get-CloudFlarePage -URI "$BaseURL/zones/$($Zone.ID)/firewall/rules" | convertto-html -as Table -Fragment | out-string
$FirewallRules = [System.Web.HttpUtility]::HtmlDecode(($FirewallRules -replace '<th>', '<th style="white-space: nowrap;">'))
$PageRules = Get-CloudFlarePage -URI "$BaseURL/zones/$($Zone.ID)/pagerules" | convertto-html -as Table -Fragment | out-string
[System.Collections.Generic.List[PSCustomObject]]$WidgetData = @()
$WidgetData.add([PSCustomObject]@{
Value = ($ZoneRecords | Measure-Object).count
Description = 'DNS Records'
Colour = '#337AB7'
Link = "https://dash.cloudflare.com/$($Zone.account.id)/$($Zone.name)/dns/settings"
})
$WidgetData.add([PSCustomObject]@{
Value = ($FirewallRules | Measure-Object).count
Description = 'Firewall Rules'
Colour = '#337AB7'
Link = "https://dash.cloudflare.com/$($Zone.account.id)/$($Zone.name)/security/waf/custom-rules"
})
$WidgetData.add([PSCustomObject]@{
Value = ($PageRules | Measure-Object).count
Description = 'Page Rules'
Colour = '#337AB7'
Link = "https://dash.cloudflare.com/$($Zone.account.id)/$($Zone.name)/rules"
})
if ( $DNSSec.status -eq 'active') {
$DNSSecStatus = '<i class="fas fa-circle-check"></i>'
$DNSSecCol = '#337AB7'
} else {
$DNSSecStatus = '<i class="fas fa-circle-xmark"></i>'
$DNSSecCol = '#D53948'
}
$WidgetData.add([PSCustomObject]@{
Value = $DNSSecStatus
Description = 'DNSSEC'
Colour = $DNSSecCol
Link = "https://dash.cloudflare.com/$($Zone.account.id)/$($Zone.name)/dns/settings"
})
$WidgetData.add([PSCustomObject]@{
Value = '<i class="fas fa-cloud"></i>'
Description = 'Open CloudFlare'
Colour = '#337AB7'
Link = "https://dash.cloudflare.com/$($Zone.account.id)/$($Zone.name)/rules"
})
$WidgetData.add([PSCustomObject]@{
Value = '<i class="fas fas fa-globe"></i>'
Description = 'View Website'
Colour = '#337AB7'
Link = "https://dash.cloudflare.com/$($Zone.account.id)/$($Zone.name)/rules"
})
$SummaryDetailsCardHTML = Get-NinjaOneWidgetCard -Data $WidgetData -Icon 'fas fa-building' -SmallCols 2 -MedCols 3 -LargeCols 4 -XLCols 4 -NoCard
$SummaryHTML = '<div style="row">' + $SummaryDetailsCardHTML + '</div>'
$Response = Invoke-WebRequest -Headers $Script:CloudFlareAuthHeaders -Uri "$BaseURL/zones/$($Zone.ID)/dns_records/export" -Method GET -UseBasicParsing
$BindFile = [System.Text.Encoding]::UTF8.GetString($response.Content)
$DocFields = @{
'link' = @{'html' = $SummaryHTML }
'status' = $Zone.status
'nameServers' = $Zone.name_servers -join ', '
'originalNameServers' = $Zone.original_name_servers -join ', '
'originalRegistrar' = $Zone.original_registrar
'modifiedOn' = Get-NinjaOneTime -Date (Get-Date($Zone.modified_on))
'account' = $Zone.account.name
'plan' = $Zone.plan.name
'planCost' = "$($Zone.plan.price) $($Zone.plan.currency)"
'dnssecStatus' = $DNSSec.status
'dnsRecords' = @{'html' = $ZoneHTML }
'zoneSettings' = @{'html' = $ZoneSettingsHTML }
'bindFile' = @{'html' = "<pre>$BindFile</pre>" }
'auditLog' = @{'html' = "$LogTable" }
}
if ($MatchedDoc) {
$UpdateObject = [PSCustomObject]@{
documentId = $MatchedDoc.documentId
documentName = $Zone.name
fields = $DocFields
}
$NinjaDocUpdates.Add($UpdateObject)
} else {
$CreateObject = [PSCustomObject]@{
documentName = $Zone.name
documentTemplateId = $CFDocTemplate.id
organizationId = [int]$NinjaMatch
fields = $DocFields
}
$NinjaDocCreation.Add($CreateObject)
}
} catch {
Write-Error "Failed processing zone $($Zone.name).Linenumber: $($_.InvocationInfo.ScriptLineNumber) Error: $($_.Exception.message)"
}
}
## Perform the bulk updates of data
try {
# Create New Users
if (($NinjaDocCreation | Measure-Object).count -ge 1) {
Write-Host "Creating Documents"
$CreatedDocs = Invoke-NinjaOneRequest -Path "organization/documents" -Method POST -InputObject $NinjaDocCreation -AsArray
Write-Host "Created $(($CreatedDocs | Measure-Object).count) Documents"
}
} Catch {
Write-Host "Bulk Creation Error, but may have been successful as only 1 record with an issue could have been the cause: $_"
}
try {
# Update Users
if (($NinjaDocUpdates | Measure-Object).count -ge 1) {
Write-Host "Updating Documents"
$UpdatedDocs = Invoke-NinjaOneRequest -Path "organization/documents" -Method PATCH -InputObject $NinjaDocUpdates -AsArray
Write-Host "Updated $(($UpdatedDocs | Measure-Object).count) Documents"
}
} Catch {
Write-Host "Bulk Update Errored, but may have been successful as only 1 record with an issue could have been the cause: $_"
}
Write-Host "The following domains were not matched to a CloudFlare Document or Cloud Monitor in NinjaOne. Please add a CloudFlare Apps and Services docment with a name matching the domain or Cloud Monitor under the correct Organization for them"
$UnmatchedZones | Select-Object name, account.name
Write-Output "$(Get-Date): Complete Total Runtime: $((New-TimeSpan -Start $Start -End (Get-Date)).TotalSeconds) seconds"
} catch {
Write-Output "Failed to Generate Documentation. Linenumber: $($_.InvocationInfo.ScriptLineNumber) Error: $($_.Exception.message)"
exit 1
}