diff --git a/Cloudflare ITGlue Powershell Module/CloudflareITGlue/CloudflareITGlue.psd1 b/Cloudflare ITGlue Powershell Module/CloudflareITGlue/CloudflareITGlue.psd1
new file mode 100644
index 0000000..5343f25
--- /dev/null
+++ b/Cloudflare ITGlue Powershell Module/CloudflareITGlue/CloudflareITGlue.psd1
@@ -0,0 +1,143 @@
+#
+# Module manifest for module 'CloudflareITGlue'
+#
+# Generated by: Jeremy Colby
+#
+# Generated on: 06/27/2019
+#
+
+@{
+
+ # Script module or binary module file associated with this manifest.
+ RootModule = 'CloudflareITGlue.psm1'
+
+ # Version number of this module.
+ ModuleVersion = '1.2.0'
+ # 1.0: Matching zones to ITGlue orgs via txt record mechanism
+ # 1.1: Matching zones to ITGlue orgs via Domain tracker + minor improvements
+ # 1.2: Full logging functionality
+ # Cloudflare's zone file export format changed, modified to account for this
+ # - Also, re Cloudflare zone file import/upload - it is normal for Cloudflare to show error when reading the SOA record from the zone file
+ # - All other records are still imported normally, This happens with an unmodified exported zone files as well - SOA is a backend setting in Cloudflare
+ # Files with the same name in ITGlue on a flex asset are not unique, revision history would only show the newest file,
+ # - Zone files now have unique filename via utc timestamp, revision history keeps copies of each file
+ # Running the sync command automatically creates the flex asset type if it does not exist
+ # Related items tagging
+ # Lowered Cloudflare request buffer
+ # Formatting + minor improvements
+
+ # Supported PSEditions
+ # CompatiblePSEditions = @()
+
+ # ID used to uniquely identify this module
+ GUID = '55a62423-f6e4-4548-ba2a-7387a32ff6d3'
+
+ # Author of this module
+ Author = 'Jeremy Colby'
+
+ # Company or vendor of this module
+ # CompanyName = ''
+
+ # Copyright statement for this module
+ Copyright = '(c) 2019 Jeremy Colby. All rights reserved.'
+
+ # Description of the functionality provided by this module
+ Description = 'Sync Cloudflare DNS Zones to ITGlue Client Organizations as Flex Assets'
+
+ # Minimum version of the Windows PowerShell engine required by this module
+ PowerShellVersion = '5.0' # New-TemporaryFile seems like only incompatiblity with 4.0
+
+ # Name of the Windows PowerShell host required by this module
+ # PowerShellHostName = ''
+
+ # Minimum version of the Windows PowerShell host required by this module
+ # PowerShellHostVersion = ''
+
+ # Minimum version of Microsoft .NET Framework required by this module. This prerequisite is valid for the PowerShell Desktop edition only.
+ # DotNetFrameworkVersion = ''
+
+ # Minimum version of the common language runtime (CLR) required by this module. This prerequisite is valid for the PowerShell Desktop edition only.
+ # CLRVersion = ''
+
+ # Processor architecture (None, X86, Amd64) required by this module
+ # ProcessorArchitecture = ''
+
+ # Modules that must be imported into the global environment prior to importing this module
+ # RequiredModules = @()
+
+ # Assemblies that must be loaded prior to importing this module
+ # RequiredAssemblies = @()
+
+ # Script files (.ps1) that are run in the caller's environment prior to importing this module.
+ # ScriptsToProcess = @()
+
+ # Type files (.ps1xml) to be loaded when importing this module
+ # TypesToProcess = @()
+
+ # Format files (.ps1xml) to be loaded when importing this module
+ # FormatsToProcess = @()
+
+ # Modules to import as nested modules of the module specified in RootModule/ModuleToProcess
+ NestedModules = 'Private\CloudflareWebRequest.ps1',
+ 'Private\CloudflareZoneData.ps1',
+ 'Private\ITGlueWebRequest.ps1',
+ 'Private\New-CloudflareITGlueFlexAssetType.ps1',
+ 'Public\CloudflareITGlueAPIAuth.ps1',
+ 'Public\Sync-CloudflareITGlueFlexibleAssets.ps1'
+
+ # Functions to export from this module, for best performance, do not use wildcards and do not delete the entry, use an empty array if there are no functions to export.
+ FunctionsToExport = 'Add-CloudflareITGlueAPIAuth',
+ 'Get-CloudflareITGlueAPIAuth',
+ 'Remove-CloudflareITGlueAPIAuth',
+ 'Sync-CloudflareITGlueFlexibleAssets'
+
+ # Cmdlets to export from this module, for best performance, do not use wildcards and do not delete the entry, use an empty array if there are no cmdlets to export.
+ CmdletsToExport = @()
+
+ # Variables to export from this module
+ VariablesToExport = @()
+
+ # Aliases to export from this module, for best performance, do not use wildcards and do not delete the entry, use an empty array if there are no aliases to export.
+ AliasesToExport = @()
+
+ # DSC resources to export from this module
+ # DscResourcesToExport = @()
+
+ # List of all modules packaged with this module
+ # ModuleList = @()
+
+ # List of all files packaged with this module
+ # FileList = @()
+
+ # Private data to pass to the module specified in RootModule/ModuleToProcess. This may also contain a PSData hashtable with additional module metadata used by PowerShell.
+ PrivateData = @{
+
+ PSData = @{
+
+ # Tags applied to this module. These help with module discovery in online galleries.
+ # Tags = @()
+
+ # A URL to the license for this module.
+ # LicenseUri = ''
+
+ # A URL to the main website for this project.
+ # ProjectUri = ''
+
+ # A URL to an icon representing this module.
+ # IconUri = ''
+
+ # ReleaseNotes of this module
+ # ReleaseNotes = ''
+
+ } # End of PSData hashtable
+
+ } # End of PrivateData hashtable
+
+ # HelpInfo URI of this module
+ # HelpInfoURI = ''
+
+ # Default prefix for commands exported from this module. Override the default prefix using Import-Module -Prefix.
+ # DefaultCommandPrefix = ''
+
+}
+
diff --git a/Cloudflare ITGlue Powershell Module/CloudflareITGlue/CloudflareITGlue.psm1 b/Cloudflare ITGlue Powershell Module/CloudflareITGlue/CloudflareITGlue.psm1
new file mode 100644
index 0000000..64eac4a
--- /dev/null
+++ b/Cloudflare ITGlue Powershell Module/CloudflareITGlue/CloudflareITGlue.psm1
@@ -0,0 +1,13 @@
+$ModuleBase = Get-Module CloudflareITGlue -ListAvailable | ForEach-Object ModuleBase
+
+if (Test-Path "$ModuleBase\$env:username.auth") {
+ Write-Host "CloudflareITGlue: Auth detected for $env:username" -ForegroundColor Green
+ $Auth = Import-Csv "$ModuleBase\$env:username.auth"
+ $Global:CloudflareAPIEmail = $Auth.CloudflareEmail
+ $Global:CloudflareAPIKey = ($Auth.CloudflareAPIKey | ConvertTo-SecureString)
+ $Global:ITGlueAPIKey = ($Auth.ITGlueAPIKey | ConvertTo-SecureString)
+}
+
+$Global:CFITGLog = $null
+
+[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
diff --git a/Cloudflare ITGlue Powershell Module/CloudflareITGlue/Private/CloudflareWebRequest.ps1 b/Cloudflare ITGlue Powershell Module/CloudflareITGlue/Private/CloudflareWebRequest.ps1
new file mode 100644
index 0000000..b881ffa
--- /dev/null
+++ b/Cloudflare ITGlue Powershell Module/CloudflareITGlue/Private/CloudflareWebRequest.ps1
@@ -0,0 +1,63 @@
+function New-CloudflareWebRequest {
+ param(
+ [Parameter(Mandatory = $true)][string]$Endpoint,
+ [ValidateSet('GET', 'POST', 'PUT', 'PATCH', 'DELETE')][string]$Method = 'GET',
+ [string]$Body = $null,
+ [int]$ResultsPerPage = 50,
+ [int]$PageNumber = 1
+ )
+
+ if ($CloudflareAPIKey) {
+ try {
+ $APIKey = [System.Runtime.InteropServices.Marshal]::PtrToStringAuto([System.Runtime.InteropServices.Marshal]::SecureStringToBSTR($CloudflareAPIKey))
+ }
+ catch {
+ Write-Warning 'Unable to decrypt auth info, run Add-CloudflareITGlueAPIAuth to re-add'
+ if ($CFITGLog) {
+ "[CF Request]$(Get-Date -Format G): Unable to decrypt auth info, run Add-CloudflareITGlueAPIAuth to re-add" | Out-File $CFITGLog -Append
+ }
+ break
+ }
+ }
+ else {
+ Write-Warning 'Run Add-CloudflareITGlueAPIAuth to add authorization info'
+ if ($CFITGLog) {
+ "[CF Request]$(Get-Date -Format G): Run Add-CloudflareITGlueAPIAuth to add authorization info" | Out-File $CFITGLog -Append
+ }
+ break
+ }
+
+ $RequestParams = @{
+ Uri = 'https://api.cloudflare.com/client/v4/' + $Endpoint + "?per_page=$ResultsPerPage&page=$PageNumber"
+ Method = $Method
+ Headers = @{
+ 'X-Auth-Key' = $APIKey
+ 'X-Auth-Email' = $CloudflareAPIEmail
+ 'Content-Type' = 'application/json'
+ }
+ }
+ if ($Body) { $RequestParams.Body = $Body }
+
+ try {
+ $Request = Invoke-RestMethod @RequestParams
+ Start-Sleep -Milliseconds 325
+ # RateLimit: 1200/5 min
+
+ if ($PageNumber -lt $Request.result_info.total_pages) {
+ $PageNumber++
+ New-CloudflareWebRequest -Endpoint $Endpoint -ResultsPerPage $ResultsPerPage -PageNumber $PageNumber
+ }
+ $APIKey = $null
+ $RequestParams = $null
+ return $Request
+ }
+ catch {
+ Write-Warning "Something went wrong with Cloudflare request:`n$_"
+ if ($CFITGLog) {
+ "[CF Request: $Endpoint]$(Get-Date -Format G): $_" | Out-File $CFITGLog -Append
+ }
+
+ $APIKey = $null
+ $RequestParams = $null
+ }
+}
diff --git a/Cloudflare ITGlue Powershell Module/CloudflareITGlue/Private/CloudflareZoneData.ps1 b/Cloudflare ITGlue Powershell Module/CloudflareITGlue/Private/CloudflareZoneData.ps1
new file mode 100644
index 0000000..8071ac7
--- /dev/null
+++ b/Cloudflare ITGlue Powershell Module/CloudflareITGlue/Private/CloudflareZoneData.ps1
@@ -0,0 +1,125 @@
+function Get-CloudflareZoneData {
+ param(
+ [Parameter(Mandatory = $true)][string]$ZoneId,
+ [Parameter(Mandatory = $true)][pscustomobject]$ITGMatch
+ )
+
+ $DateUTC = (Get-Date).ToUniversalTime().ToString('yyyy-M-d')
+ $AccountId = New-CloudflareWebRequest -Endpoint 'accounts' | ForEach-Object result | ForEach-Object id
+ $ZoneInfo = New-CloudflareWebRequest -Endpoint "zones/$ZoneId"
+ $ZoneRecords = New-CloudflareWebRequest -Endpoint "zones/$ZoneId/dns_records"
+ if ($ZoneRecords.result_info.count -eq 0) {
+ Write-Host "$($ZoneInfo.result.name): Empty Zone Detected" -ForegroundColor Yellow
+ if ($CFITGLog) {
+ "[CF]$(Get-Date -Format G): Empty Zone Detected - $($ZoneInfo.result.name)" | Out-File $CFITGLog -Append
+ }
+ break
+ }
+ $ZoneFileData = New-CloudflareWebRequest -Endpoint "zones/$ZoneId/dns_records/export"
+ $ZoneFileData = $ZoneFileData.Replace(
+ ';; SOA Record',
+ "`$ORIGIN $($ZoneInfo.result.name).`n`n;; SOA Record"
+ )
+ $ZoneFileData = $ZoneFileData.Replace(
+ "SOA`t$($ZoneInfo.result.name). root.$($ZoneInfo.result.name).",
+ "SOA`t$($ZoneInfo.result.name_servers[0]). $((($CloudflareAPIEmail -split '@')[0]).Replace('.','\.') + '.'+ ($CloudflareAPIEmail -split '@')[1])."
+ )
+ $ZoneFileData = $ZoneFileData.Replace(
+ ';; A Records',
+ ";; NS Records`n$($ZoneInfo.result.name). 1 IN NS $($ZoneInfo.result.name_servers[0]).`n$($ZoneInfo.result.name). 1 IN NS $($ZoneInfo.result.name_servers[1]).`n`n;; A Records"
+ )
+ $ZoneFileData = $ZoneFileData.Replace(
+ ';; -- update the SOA record with the correct authoritative name server',
+ ";; -- update the SOA record with the correct authoritative name server`n;; ** CloudflareITGlue Module: Updated $($DateUTC)"
+ )
+ $ZoneFileData = $ZoneFileData.Replace(
+ ';; -- update the SOA record with the contact e-mail address information',
+ ";; -- update the SOA record with the contact e-mail address information`n;; ** CloudflareITGlue Module: Updated $($DateUTC)"
+ )
+ $ZoneFileData = $ZoneFileData.Replace(
+ ';; -- update the NS record(s) with the authoritative name servers for this domain.',
+ ";; -- update the NS record(s) with the authoritative name servers for this domain.`n;; ** CloudflareITGlue Module: Updated $($DateUTC)`n;; ** CloudflareITGlue Module: Added `$ORIGIN directive"
+ )
+ $RecordsHtml =
+ '
+
+ Open in Cloudflare
+
+
+
+ | Type |
+ Name |
+ Value |
+ Priority |
+ TTL |
+ Proxied |
+ Modified |
+
+ ' +
+ $(foreach ($Record in $ZoneRecords.result) {
+ "
+ | $($Record.type) |
+ $($Record.name) |
+ $($Record.content) |
+ $($Record.priority) |
+ $(if ($Record.ttl -eq 1){'Auto'}else{$Record.ttl}) |
+ $($Record.proxied) |
+ $(($Record.modified_on.Replace('T', ' ') -split '\.')[0]) |
+
"
+ }) +
+ '
+
+
'
+
+ $ZoneData = [ordered]@{
+ Name = $ZoneInfo.result.name
+ SyncDate = $DateUTC
+ CfNameServers = $ZoneInfo.result.name_servers
+ Status = $ZoneInfo.result.status
+ ZoneFileData = $ZoneFileData
+ RecordsHtml = $RecordsHtml
+ ITGOrg = $Match.OrgMatchId
+ DomainTracker = $Match.DomainTrackerId
+ }
+ $ZoneData
+}
+
+function Get-CloudflareZoneDataArray {
+ $Progress = 0
+ Write-Progress -Activity 'CloudflareAPI' -Status 'Getting Zone Data' -PercentComplete 0 -Id 1
+ $ZoneDataArray = @()
+ $AllZones = New-CloudflareWebRequest -Endpoint 'zones'
+ [pscustomobject]$ITGDomains = New-ITGlueWebRequest -Endpoint 'domains' | ForEach-Object data
+
+ foreach ($Zone in $AllZones.result) {
+ Write-Progress -Activity 'CloudflareAPI' -Status 'Getting Zone Data' -CurrentOperation $Zone.name -PercentComplete ($Progress / ($AllZones.result | Measure-Object | ForEach-Object count) * 100) -Id 1
+
+ $ITGMatches = @()
+ foreach ($ITGDomain in $ITGDomains) {
+ if ($Zone.name.ToLower() -eq $ITGDomain.attributes.name.ToLower()) {
+ $Match = @{
+ OrgMatchId = $ITGDomain.attributes.'organization-id'
+ DomainTrackerId = $ITGDomain.id
+ }
+ $ITGMatches += [pscustomobject]$Match
+ }
+ }
+ if ($ITGMatches) {
+ foreach ($Match in $ITGMatches) {
+ $ZoneData = Get-CloudflareZoneData -ZoneId $Zone.id -ITGMatch $Match
+ if ($ZoneData) {
+ $ZoneDataArray += [pscustomobject]$ZoneData
+ }
+ }
+ }
+ else {
+ Write-Host "$($Zone.name): Add to domain tracker" -ForegroundColor Yellow
+ if ($CFITGLog) {
+ "[CFITG]$(Get-Date -Format G): $($Zone.name) - Add to ITG domain tracker" | Out-File $CFITGLog -Append
+ }
+ }
+ $Progress++
+ }
+ Write-Progress -Activity 'CloudflareAPI' -Status 'Complete' -PercentComplete 100 -Id 1
+ $ZoneDataArray
+}
diff --git a/Cloudflare ITGlue Powershell Module/CloudflareITGlue/Private/ITGlueWebRequest.ps1 b/Cloudflare ITGlue Powershell Module/CloudflareITGlue/Private/ITGlueWebRequest.ps1
new file mode 100644
index 0000000..353dea5
--- /dev/null
+++ b/Cloudflare ITGlue Powershell Module/CloudflareITGlue/Private/ITGlueWebRequest.ps1
@@ -0,0 +1,61 @@
+function New-ITGlueWebRequest {
+ param(
+ [Parameter(Mandatory = $true)][string]$Endpoint,
+ [ValidateSet('GET', 'POST', 'PATCH', 'DELETE')][string]$Method = 'GET',
+ [string]$Body = $null,
+ [int]$ResultsPerPage = 50,
+ [int]$PageNumber = 1
+ )
+
+ if ($ITGlueAPIKey) {
+ try {
+ $APIKey = [System.Runtime.InteropServices.Marshal]::PtrToStringAuto([System.Runtime.InteropServices.Marshal]::SecureStringToBSTR($ITGlueAPIKey))
+ }
+ catch {
+ Write-Warning 'Unable to decrypt auth info, run Add-CloudflareITGlueAPIAuth to re-add'
+ if ($CFITGLog) {
+ "[ITG Request]$(Get-Date -Format G): Unable to decrypt auth info, run Add-CloudflareITGlueAPIAuth to re-add" | Out-File $CFITGLog -Append
+ }
+ break
+ }
+ }
+ else {
+ Write-Warning 'Run Add-CloudflareITGlueAPIAuth to add authorization info'
+ if ($CFITGLog) {
+ "[ITG Request]$(Get-Date -Format G): Run Add-CloudflareITGlueAPIAuth to add authorization info" | Out-File $CFITGLog -Append
+ }
+ break
+ }
+
+ $RequestParams = @{
+ Uri = 'https://api.itglue.com/' + $Endpoint + "?page[size]=$ResultsPerPage&page[number]=$PageNumber"
+ Method = $Method
+ Headers = @{
+ 'x-api-key' = $APIKey
+ 'Content-Type' = 'application/vnd.api+json'
+ }
+ }
+ if ($Body) { $RequestParams.Body = $Body }
+
+ try {
+ $Request = Invoke-RestMethod @RequestParams
+ # RateLimit: 10k/day
+
+ if ($PageNumber -lt $Request.meta.'total-pages') {
+ $PageNumber++
+ New-ITGlueWebRequest -Endpoint $Endpoint -Body $Body -ResultsPerPage $ResultsPerPage -PageNumber $PageNumber
+ }
+ $APIKey = $null
+ $RequestParams = $null
+ return $Request
+ }
+ catch {
+ Write-Warning "Something went wrong with ITGlue request:`n$_"
+ if ($CFITGLog) {
+ "[ITG Request: $Endpoint]$(Get-Date -Format G): $_" | Out-File $CFITGLog -Append
+ }
+
+ $APIKey = $null
+ $RequestParams = $null
+ }
+}
diff --git a/Cloudflare ITGlue Powershell Module/CloudflareITGlue/Private/New-CloudflareITGlueFlexAssetType.ps1 b/Cloudflare ITGlue Powershell Module/CloudflareITGlue/Private/New-CloudflareITGlueFlexAssetType.ps1
new file mode 100644
index 0000000..c0bef1d
--- /dev/null
+++ b/Cloudflare ITGlue Powershell Module/CloudflareITGlue/Private/New-CloudflareITGlueFlexAssetType.ps1
@@ -0,0 +1,94 @@
+function New-CloudflareITGlueFlexAssetType {
+ param(
+ [string]$Name = 'Cloudflare DNS'
+ )
+
+ $Body = @{
+ Data = @{
+ type = 'flexible_asset_types'
+ attributes = @{
+ name = $Name
+ description = 'DNS Zones from Cloudflare.'
+ icon = 'cloud'
+ enabled = $true
+ show_in_menu = $false
+ }
+
+ relationships = @{
+ flexible_asset_fields = @{
+ data = @(
+ @{
+ type = 'flexible_asset_fields'
+ attributes = @{
+ order = 1
+ name = 'Name'
+ kind = 'Text'
+ hint = 'Name of the DNS Zone'
+ required = $true
+ show_in_list = $true
+ use_for_title = $true
+ }
+ },
+ @{
+ type = 'flexible_asset_fields'
+ attributes = @{
+ order = 2
+ name = 'Last Sync'
+ kind = 'Text'
+ hint = 'When zone last synced (UTC)'
+ required = $true
+ show_in_list = $false
+ }
+ },
+ @{
+ type = 'flexible_asset_fields'
+ attributes = @{
+ order = 3
+ name = 'Nameservers'
+ kind = 'Textbox'
+ hint = 'Cloudflare provided nameservers for this zone'
+ required = $true
+ show_in_list = $false
+ }
+ },
+ @{
+ type = 'flexible_asset_fields'
+ attributes = @{
+ order = 4
+ name = 'Status'
+ kind = 'Text'
+ hint = 'Status of the Cloudflare Zone'
+ required = $false
+ show_in_list = $true
+ }
+ },
+ @{
+ type = 'flexible_asset_fields'
+ attributes = @{
+ order = 5
+ name = 'Zone File'
+ kind = 'Upload'
+ hint = 'Exported zone file in BIND format. You can upload this to Cloudflare.'
+ required = $false
+ show_in_list = $false
+ }
+ },
+ @{
+ type = 'flexible_asset_fields'
+ attributes = @{
+ order = 6
+ name = 'DNS Records'
+ kind = 'Textbox'
+ hint = 'Table of DNS records in the zone'
+ required = $true
+ show_in_list = $false
+ }
+ }
+ )
+ }
+ }
+ }
+ }
+ $Body = $Body | ConvertTo-Json -Depth 6
+ New-ITGlueWebRequest -Endpoint 'flexible_asset_types' -Method 'POST' -Body $Body
+}
diff --git a/Cloudflare ITGlue Powershell Module/CloudflareITGlue/Public/CloudflareITGlueAPIAuth.ps1 b/Cloudflare ITGlue Powershell Module/CloudflareITGlue/Public/CloudflareITGlueAPIAuth.ps1
new file mode 100644
index 0000000..d6fe2c5
--- /dev/null
+++ b/Cloudflare ITGlue Powershell Module/CloudflareITGlue/Public/CloudflareITGlueAPIAuth.ps1
@@ -0,0 +1,75 @@
+function Add-CloudflareITGlueAPIAuth {
+ if (!([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole] 'Administrator')) {
+ Write-Host 'Add/removing API Auth require admin access' -ForegroundColor Yellow
+ break
+ }
+ [pscredential]$CloudflareCredentials = $Host.UI.PromptForCredential('Cloudflare API Authentication', "User name: Cloudflare Email`r`nPassword: Cloudflare API Key", '', '')
+ [pscredential]$ITGCredentials = $Host.UI.PromptForCredential('ITGlue API Authentication', 'Password: ITGlue API Key', 'ITGlue', '')
+ $Global:CloudflareAPIEmail = $CloudflareCredentials.username
+ $Global:CloudflareAPIKey = $CloudflareCredentials.Password
+ $Global:ITGlueAPIKey = $ITGCredentials.Password
+
+ if (!$CloudflareAPIEmail -or !$CloudflareAPIKey -or !$ITGlueAPIKey) {
+ Write-Host 'Cancelled' -ForegroundColor Yellow
+ break
+ }
+ if ($CloudflareAPIEmail -notmatch "\A[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*@(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\z") {
+ Write-Host 'Invalid email address format' -ForegroundColor Yellow
+ break
+ }
+ if (!$CloudflareCredentials.GetNetworkCredential().Password -or !$ITGCredentials.GetNetworkCredential().Password) {
+ Write-Warning 'API key(s) not entered'
+ break
+ }
+ $Credentials = @{
+ CloudflareEmail = $CloudflareAPIEmail
+ CloudflareAPIKey = ($CloudflareAPIKey | ConvertFrom-SecureString)
+ ITGlueAPIKey = ($ITGlueAPIKey | ConvertFrom-SecureString)
+ }
+ $Auth = @()
+ $Auth += [pscustomobject]$Credentials
+ $ModuleBase = Get-Module CloudflareITGlue | ForEach-Object ModuleBase
+
+ $Auth | Export-Csv "$ModuleBase\$env:username.auth" -NoTypeInformation -Force
+}
+
+function Get-CloudflareITGlueAPIAuth {
+ if (Test-Path "$ModuleBase\$env:username.auth") {
+ Write-Host 'Auth file detected' -ForegroundColor Green
+ $Auth = Import-Csv "$ModuleBase\$env:username.auth"
+
+ try {
+ $cfkey = [System.Runtime.InteropServices.Marshal]::PtrToStringAuto([System.Runtime.InteropServices.Marshal]::SecureStringToBSTR($($Auth.CloudflareAPIKey | ConvertTo-SecureString)))
+ $itgkey = [System.Runtime.InteropServices.Marshal]::PtrToStringAuto([System.Runtime.InteropServices.Marshal]::SecureStringToBSTR($($Auth.ITGlueAPIKey | ConvertTo-SecureString)))
+ $cfkeyhalf = [int]($cfkey | Measure-Object -Character | ForEach-Object Characters) / 2
+ $itgkeyhalf = [int]($itgkey | Measure-Object -Character | ForEach-Object Characters) / 2
+ Write-Host "Cloudflare Email: $($Auth.CloudflareEmail)"
+ Write-Host "Cloudflare API Key: $($cfkey.Substring(0,$cfkeyhalf))********************" -ErrorAction Ignore
+ Write-Host "ITGlue API Key: $($itgkey.Substring(0,$itgkeyhalf))********************`n" -ErrorAction Ignore
+ $cfkey = $null
+ $itgkey = $null
+ }
+ catch {
+ Write-Warning 'Invalid format or unable to decrypt'
+ Write-Warning 'Run Add-CloudflareITGlueAPIAuth to re-add auth info for the current account'
+ $cfkey = $null
+ $itgkey = $null
+ }
+ }
+ else {
+ Write-Host "No auth detected for $env:username" -ForegroundColor Yellow
+ }
+}
+
+function Remove-CloudflareITGlueAPIAuth {
+ if (!([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole] 'Administrator')) {
+ Write-Host 'Add/removing API Auth require admin access' -ForegroundColor Yellow
+ break
+ }
+ if (Test-Path "$ModuleBase\$env:username.auth") {
+ Remove-Item "$ModuleBase\$env:username.auth" -Force
+ }
+ else {
+ Write-Host 'Not added' -ForegroundColor Yellow
+ }
+}
diff --git a/Cloudflare ITGlue Powershell Module/CloudflareITGlue/Public/Sync-CloudflareITGlueFlexibleAssets.ps1 b/Cloudflare ITGlue Powershell Module/CloudflareITGlue/Public/Sync-CloudflareITGlueFlexibleAssets.ps1
new file mode 100644
index 0000000..2d96f3b
--- /dev/null
+++ b/Cloudflare ITGlue Powershell Module/CloudflareITGlue/Public/Sync-CloudflareITGlueFlexibleAssets.ps1
@@ -0,0 +1,112 @@
+function Sync-CloudflareITGlueFlexibleAssets {
+ param(
+ [string]$FlexAssetType = 'Cloudflare DNS',
+ [string]$Log
+ )
+ $Progress = 0
+
+ if ($Log) {
+ if (Test-Path $Log) {
+ $Global:CFITGLog = $Log
+ }
+ else {
+ New-Item -ItemType File -Path $Log -ErrorAction Ignore | Out-Null
+ if (Test-Path $Log) {
+ $Global:CFITGLog = $Log
+ }
+ else {
+ Write-Warning "Unable to create log file: $Log - Invalid path or access denied"
+ return
+ }
+ }
+ }
+
+ $ZoneDataArray = Get-CloudflareZoneDataArray
+ $FlexAssetTypeId = New-ITGlueWebRequest -Endpoint 'flexible_asset_types' -Method 'GET' | ForEach-Object data | Where-Object { $_.attributes.name -eq $FlexAssetType } | ForEach-Object id
+ if (!$FlexAssetTypeId) {
+ New-CloudflareITGlueFlexAssetType -Name $FlexAssetType | Out-Null
+ $FlexAssetTypeId = New-ITGlueWebRequest -Endpoint 'flexible_asset_types' -Method 'GET' | ForEach-Object data | Where-Object { $_.attributes.name -eq $FlexAssetType } | ForEach-Object id
+ }
+
+ foreach ($ZoneData in $ZoneDataArray) {
+ Write-Progress -Activity 'ITGlueAPI' -Status 'Syncing Flexible Assets' -CurrentOperation $ZoneData.name -PercentComplete ($Progress / ($ZoneDataArray | Measure-Object | ForEach-Object count) * 100) -Id 2
+
+ $TempFile = New-TemporaryFile
+ $ZoneData.ZoneFileData | Out-file $TempFile -Force -Encoding ascii
+ $Base64ZoneFile = ([System.Convert]::ToBase64String([System.IO.File]::ReadAllBytes($TempFile)))
+ Remove-Item $TempFile -Force
+ $Body = @{
+ data = @{
+ 'type' = 'flexible-assets'
+ 'attributes' = @{
+ 'organization-id' = $ZoneData.ITGOrg
+ 'flexible-asset-type-id' = $FlexAssetTypeId
+ 'traits' = @{
+ 'name' = $ZoneData.Name
+ 'last-sync' = $ZoneData.SyncDate
+ 'nameservers' = $ZoneData.CfNameServers -join '
'
+ 'status' = $ZoneData.Status
+ 'zone-file' = @{
+ 'content' = $Base64ZoneFile
+ 'file_name' = "$($ZoneData.Name)_$((Get-Date).ToUniversalTime() | Get-Date -Format "yyyy-MM-ddTHHmmssK").txt"
+ }
+ 'dns-records' = $ZoneData.RecordsHtml
+ }
+ }
+ }
+ }
+ $Body = $Body | ConvertTo-Json -Depth 4
+ $FlexAssets = New-ITGlueWebRequest -Endpoint "flexible_assets?filter[flexible_asset_type_id]=$FlexAssetTypeId&filter[organization_id]=$($ZoneData.ITGOrg)" -Method 'GET' | ForEach-Object data
+ $PatchId = $null
+
+ foreach ($FlexAsset in $FlexAssets) {
+ if ($FlexAsset.attributes.traits.name -eq $ZoneData.name) {
+ $PatchId = $FlexAsset.id
+ }
+ }
+ if ($PatchId) {
+ try {
+ $FlexAssetId = New-ITGlueWebRequest -Endpoint "flexible_assets/$PatchId" -Method 'PATCH' -Body $Body
+ if ($CFITGLog) {
+ "[ITG]$(Get-Date -Format G): Updating $($ZoneData.Name)" | Out-File $CFITGLog -Append
+ }
+ }
+ catch {
+ Write-Warning "Something went wrong updating $($ZoneData.Name)`n$_"
+ if ($CFITGLog) {
+ "[ITG]$(Get-Date -Format G): Something went wrong updating $($ZoneData.Name)`n$_" | Out-File $CFITGLog -Append
+ }
+ continue
+ }
+ }
+ else {
+ try {
+ $FlexAssetId = New-ITGlueWebRequest -Endpoint 'flexible_assets' -Method 'POST' -Body $Body
+ if ($CFITGLog) {
+ "[ITG]$(Get-Date -Format G): Creating $($ZoneData.Name)" | Out-File $CFITGLog -Append
+ }
+ }
+ catch {
+ Write-Warning "Something went wrong creating $($ZoneData.Name)`n$_"
+ if ($CFITGLog) {
+ "[ITG]$(Get-Date -Format G): Something went wrong creating $($ZoneData.Name)`n$_" | Out-File $CFITGLog -Append
+ }
+ continue
+ }
+ }
+ $TagBody = @{
+ data = @{
+ type = 'related_items'
+ attributes = @{
+ 'destination_id' = $ZoneData.DomainTracker
+ 'destination_type' = 'Domain'
+ }
+ }
+ }
+ $TagBody = $TagBody | ConvertTo-Json -Depth 4
+ New-ITGlueWebRequest -Endpoint "flexible_assets/$($FlexAssetId.data.id)/relationships/related_items" -Method POST -Body $TagBody | Out-Null
+
+ $Progress++
+ }
+ Write-Progress -Activity 'ITGlueAPI' -Status 'Syncing Flexible Assets' -PercentComplete 100 -Id 2
+}
diff --git a/Cloudflare ITGlue Powershell Module/readme.md b/Cloudflare ITGlue Powershell Module/readme.md
new file mode 100644
index 0000000..2bc7dbf
--- /dev/null
+++ b/Cloudflare ITGlue Powershell Module/readme.md
@@ -0,0 +1,119 @@
+# CloudflareITGlue Powershell Module
+
+## What does this do
+
+- Sync Cloudflare DNS Zones to ITGlue Client Organizations as Flex Assets
+
+
+
+>**Name:** Name of the Cloudflare DNS Zone
+>**Last Sync:** UTC datestamp
+>**Nameservers:** Nameservers designated by Cloudflare
+>**Status:** Status of the Cloudflare DNS Zone
+>**Zone File:** BIND format zone file
+>**DNS Records:** Table of all DNS records in the zone and a link to the zone page in Cloudflare
+>**Related Items:** Domain Tracker Tag
+>**Revisions:** Flex assets contain revision history by nature (Cloudflare does not!)
+
+- [Installing the module](#Installing-the-module)
+- [API authorization](#API-Authorization)
+- [Usage](#Usage)
+- [Version info](#Version-History)
+
+## Configuration
+
+### Installing the module
+
+Copy the CloudflareITGlue module folder into the Powershell module directory, default path:
+>`C:\Program Files\WindowsPowerShell\Modules\CloudflareITGlue`
+
+### API authorization
+
+#### Obtain API keys
+
+- Cloudflare
+ - Login to Cloudflare.
+ - Go to **My Profile**.
+ - Scroll down to **API Keys** and locate _Global API Key_.
+ - Select **API Key**.
+
+- ITGlue
+ - Login to ITGlue.
+ - Select the **Account** tab.
+ - Select the **API Keys** tab.
+ - Click the **+** symbol to add a new API key.
+ - **Enter Name**.
+ - Select **Generate API Key**
+
+#### Add authorization
+
+```powershell
+Add-CloudflareITGlueAPIAuth
+```
+
+>This will prompt you for your API keys and Cloudflare email. The API keys will be encrypted with your user account and stored in the module directory. Requires elevated permissions for file creation.
+
+#### Viewing/Removing authorization info
+
+```powershell
+Get-CloudflareITGlueAPIAuth
+Remove-CloudflareITGlueAPIAuth
+```
+
+>Use these to view/delete the auth that's been entered.
+>API keys are not shown in full. Removal requires elevated permissions to delete file.
+
+## Usage
+
+```powershell
+Sync-CloudflareITGlueFlexibleAssets
+```
+
+>This command will create a new flex asset type in ITGlue called Cloudflare DNS.
+>It will then match Cloudflare zones to ITGlue orgs using the Domain Tracker and sync the zones to their respective ITGlue organizations.
+>Cloudflare zones that are not in the Domain Tracker will be output to the console and log file.
+>Set this up to run at an interval of your choosing however you like.
+>
+>There is optional logging functionality:
+>`Sync-CloudflareITGlueFlexibleAssets -Log 'C:\Temp\cfitg.log'`
+>
+>You can use a custom name for the flex asset type via the optional FlexAssetType parameter:
+>`Sync-CloudflareITGlueFlexibleAssets -FlexAssetType 'My Cloudflare DNS'`
+
+- Heres a quick Powershell script you can use to create a scheduled task:
+
+>```powershell
+>$Action = New-ScheduledTaskAction -Execute 'Powershell.exe' `
+> -Argument '-NoProfile -WindowStyle Hidden -Command "& Sync-CloudflareITGlueFlexibleAssets -Log C:\Temp\cfitg.log"'
+>$Trigger = New-ScheduledTaskTrigger -Daily -At 8am
+>$Principal = New-ScheduledTaskPrincipal -UserID '%USERNAME%' -LogonType S4U
+>Register-ScheduledTask -TaskName 'Sync zones' -Action $Action -Trigger $Trigger -Principal $Principal
+># Be sure you've added auth info for %USERNAME%
+>```
+
+## Version info
+
+- 1.0
+ - Dns zones are matched to ITGlue orgs via custom txt record mechanism
+- 1.1
+ - Dns zones are matched to ITGlue orgs automatically via Domain tracker
+- 1.2
+ - Full logging functionality
+ - Files with the same name in ITGlue on a flex asset do not appear to be unique, revision history only shows the latest file, Zone files now have a unique filename via utc timestamp and revision history now keeps copies of each file
+ - Running the sync command automatically creates the flex asset type if it does not exist
+ - Related items tagging
+ - Lowered Cloudflare request buffer
+ - Re: Zone file export format
+ - Cloudflare export format changed, modified to account for this
+ - Upon import/upload, it is normal for Cloudflare to show an error when reading the SOA record. All records are imported correctly and the SOA is not configurable by Cloudflare. The same behavior happens with an unmodified zone file export
+
+## References
+
+[Invoke-RestMethod Documentation](https://docs.microsoft.com/en-us/powershell/module/microsoft.powershell.utility/invoke-restmethod/)
+[ITGlue API Documentation](https://api.itglue.com/developer/)
+[Cloudflare API Documentation](https://api.cloudflare.com/)
+
+### ITGlue contest submission info
+
+[IT Glue's API Contest](https://www.itglue.com/api-contest/)
+Submitted by: Jeremy Colby <>, [Nucleus Networks](https://yournucleus.ca/), June 28 2019