Last active
November 27, 2025 17:24
-
-
Save PanosGreg/a16b5dc4f5471e477ca8604bbc762b39 to your computer and use it in GitHub Desktop.
DNS Made Easy helper functions. Might expand and write a proper module for this at some stage.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <# | |
| .SYNOPSIS | |
| DNS Made Easy helper functions | |
| Context: I needed to do some bulk operations in DNS Made Easy at work, | |
| I didn't find anything adequate online and so I wrote this. | |
| .DESCRIPTION | |
| This file exposes 3 functions, an enum and a class. | |
| The functions are: | |
| - Get-DMEAuthHeader | |
| - Get-DMEDomain | |
| - New-DMERecord | |
| The types are: | |
| - DMERecordType [enum] | |
| - DMERecord [type] | |
| .EXAMPLE | |
| # load the functions and the classes | |
| . C:\temp\DNSME-API.ps1 | |
| # store the DNS ME api key and secret key into variables | |
| $ApiKey = 'xxx...' | |
| $SecKey = 'yyy...' | |
| # generate a list of records that we'll create in DNS Made Easy | |
| $List = @( | |
| 'aaa' | |
| 'bbb' | |
| 'ccc' | |
| ) | |
| $data = $list | foreach {[DMERecord]::new("$_-stage",'stage','CNAME',600)} | |
| # finally create the records in DNS ME | |
| $records = New-DMERecord -ApiKey $ApiKey -SecKey $SecKey -DomainId 999999 -Data $data -Verbose | |
| # check the results to see if the operation failed or not | |
| $records | |
| In this example we load the helper functions and then we create a number of CNAME records in DNS ME | |
| Do note that each record takes a few seconds to create. | |
| Note: the domain ID 999999 that is used here is just an example | |
| .NOTES | |
| Author: Panos Grigoriadis | |
| Date: 27-Nov-2025 | |
| Other notes: | |
| - Documentation: https://api-docs.dnsmadeeasy.com/ | |
| Also record management docs: https://api-docs.dnsmadeeasy.com/#99ec6f53-ecd1-4ca5-b0bf-c997014108ef | |
| Note: you can change the language to PowerShell in the docs page at the top, to see examples in PS | |
| - Rate Limiting: | |
| 150 requests per 5 minute scrolling window. | |
| For example, 100 requests could be made in one minute, followed by a 5 minute wait, following by 150 requests. | |
| - Record type Values: A, AAAA, CNAME, HTTPRED, MX, NS, PTR, SRV, TXT | |
| - Input validation | |
| The json data given to DNS ME is case-sensitive and also order matters. | |
| So this thing for example: | |
| { | |
| "name": "aaa-stage", | |
| "type": "CNAME", | |
| "value": "stage", | |
| "gtdLocation": "DEFAULT", | |
| "ttl": 600 | |
| } | |
| The property order matters, and the key names are case sensitive. | |
| - HTTP Methods & their equivalent Actions | |
| HTTP Methods and their corresponding actions based on the DNSME documentation | |
| POST = create | |
| GET = read | |
| PUT = update | |
| DELETE = delete | |
| - Authentication with DNSME | |
| Every time you send a request to DNSME, you have to get a fresh new $Auth variable | |
| this is needed in order for the header to be close in time with the request. | |
| Otherwise you get this error: | |
| Invoke-RestMethod: {"error": ["Request sent with date header too far out of sync. Difference in times is 79125, header value is 1720619037000"]} | |
| - Gtd = Global Traffic Director | |
| - Timestamps | |
| The "created" and "updated" timestamps returned from DNSME are the number of seconds since the domain was last created in Epoch time | |
| #> | |
| #Requires -Version 7 | |
| function Get-DMEAuthHeader { | |
| <# | |
| .SYNOPSIS | |
| This function just prepares the web request header that we need to send to DNSME in order to authenticate. | |
| But this function does not authenticate with DNSME. | |
| The output of this function needs to be used in Invoke-RestMethod to authenticate with DNSME. | |
| #> | |
| [cmdletbinding()] | |
| param ( | |
| [string]$ApiKey, | |
| [string]$SecKey | |
| ) | |
| $SecBytes = [Text.Encoding]::UTF8.GetBytes($SecKey) | |
| $Hmac = [Security.Cryptography.HMACSHA1]::new($SecBytes,$true) | |
| $ReqDate = [System.DateTimeOffset]::Now.ToString('r') | |
| $DateBytes = [Text.Encoding]::UTF8.GetBytes($ReqDate) | |
| $DateHash = [BitConverter]::ToString($Hmac.ComputeHash($DateBytes)).Replace('-','').ToLower() | |
| $AuthHeader = @{ | |
| 'x-dnsme-apiKey' = $ApiKey | |
| 'x-dnsme-requestDate' = $ReqDate | |
| 'x-dnsme-hmac' = $DateHash | |
| } | |
| $params = @{ | |
| Headers = $AuthHeader | |
| ContentType = 'application/json' | |
| } | |
| Write-Output $params | |
| } #function | |
| function Get-DMEDomain { | |
| [OutputType([psobject])] # <-- the object we receive from DNS Made-Easy | |
| [cmdletbinding()] | |
| param ( | |
| [Parameter(Mandatory)] | |
| [string]$ApiKey, | |
| [Parameter(Mandatory)] | |
| [string]$SecKey, | |
| [string]$BaseUrl = 'https://api.dnsmadeeasy.com/V2.0/dns/managed' | |
| ) | |
| # get the domain list from DNSME | |
| $Auth = Get-DMEAuthHeader -ApiKey $ApiKey -SecKey $SecKey | |
| $list = (Invoke-RestMethod $BaseUrl @auth -Verbose:$false).data | |
| # calculate the domain creation date and add it as a custom property | |
| $Utc = [System.DateTimeKind]::Utc | |
| $list | foreach { | |
| $val = [DateTime]::new(1970, 1, 1, 0, 0, 0, 0, $Utc).AddSeconds($_.created/1000) | |
| $_ | Add-Member -NotePropertyName createdAt -NotePropertyValue $val | |
| } | |
| # set the default view of the object | |
| $Def = 'name','id','createdAt' | |
| $Class = [Management.Automation.PSPropertySet] | |
| $DDPS = $Class::new('DefaultDisplayPropertySet',[string[]]$Def) | |
| $Std = [Management.Automation.PSMemberInfo[]]@($DDPS) | |
| # finally return the results | |
| $list | Add-Member -MemberType MemberSet -Name PSStandardMembers -Value $Std -PassThru | |
| } | |
| enum DMERecordType { | |
| A | |
| CNAME | |
| } | |
| ## TODO: [maybe] re-write this in C# (with proper private method and constructor overloads) | |
| class DMERecord { | |
| [string] $Name | |
| [DMERecordType] $Type = [DMERecordType]::A | |
| [string] $Value | |
| [string] $GtdLocation = 'DEFAULT' | |
| [uint16] $Ttl = 600 | |
| ## constructors | |
| # constructor overload with a default TTL and default Type | |
| DMERecord ([string]$Name,$Value) { | |
| if (-not $this.HasCorrectValue($Value,$this.Type)) {throw 'Cannot create the record'} | |
| $this.Name = $Name | |
| $this.Value = $Value | |
| } | |
| # constructor overload with a default TTL | |
| DMERecord ([string]$Name,$Value,[DMERecordType]$Type) { | |
| if (-not $this.HasCorrectValue($Value,$Type)) {throw 'Cannot create the record'} | |
| $this.Name = $Name | |
| $this.Value = $Value | |
| $this.Type = $Type | |
| } | |
| # constructor overload with a default type | |
| DMERecord ([string]$Name,$Value,[uint16]$Ttl) { | |
| if (-not $this.HasCorrectValue($Value,$this.Type)) {throw 'Cannot create the record'} | |
| $this.Name = $Name | |
| $this.Value = $Value | |
| $this.Ttl = $Ttl | |
| } | |
| # constructor overload with a param to provide both the TTL and the type | |
| DMERecord ([string]$Name,$Value,[DMERecordType]$Type,[uint16]$Ttl) { | |
| if (-not $this.HasCorrectValue($Value,$Type)) {throw 'Cannot create the record'} | |
| $this.Name = $Name | |
| $this.Value = $Value | |
| $this.Type = $Type | |
| $this.Ttl = $Ttl | |
| } | |
| ## methods | |
| # "private" method (not really private since PS cant do that) | |
| hidden [bool] HasCorrectValue ($Value,[DMERecordType]$Type) { | |
| $ValueIsIP = [System.Net.IPAddress]::TryParse($Value,[ref]$null) | |
| $result = $false | |
| if ($Type.ToString() -eq 'A') { # <-- if A record, then value must be an IP | |
| if (-not $ValueIsIP) {Write-Warning 'You must provide a valid IP Address for the value'} | |
| $result = $ValueIsIP | |
| } | |
| elseif ($Type.ToString() -eq 'CNAME') { # <-- if CNAME then value must be a string | |
| if ($ValueIsIP) {Write-Warning 'You must provide a name for the value, not an IP Address'} | |
| $result = -not $ValueIsIP | |
| } | |
| return $result | |
| } #method HasCorrectValue | |
| [string] ToJson () { # Note: it maintains the property order and has all keys in lower-case | |
| $json = [ordered] @{ | |
| name = $this.Name | |
| type = $this.Type.ToString() | |
| value = $this.Value | |
| gtdLocation = $this.GtdLocation | |
| ttl = $this.Ttl | |
| } | ConvertTo-Json -Compress | |
| return $json | |
| } #method ToJson | |
| [string] ToString () { # <-- this overwrites the default .ToString() method | |
| $result = [string]::Empty | |
| if ($this.Type.ToString() -eq 'A') { | |
| $result = '{0} record "{1}" resolves to "{2}"' -f $this.Type,$this.Name,$this.Value | |
| } | |
| elseif ($this.Type.ToString() -eq 'CNAME') { | |
| $result = '{0} record "{1}" points to "{2}"' -f $this.Type,$this.Name,$this.Value | |
| } | |
| return $result # <-- ex. CNAME record "aaa-stage" points to "stage" | |
| } | |
| } #class DMERecord | |
| function New-DMERecord { | |
| <# | |
| .EXAMPLE | |
| $data = [DMERecord]::new('aaa-stage','stage','CNAME',600) | |
| New-DMERecord -ApiKey $ApiKey -SecKey $SecKey -DomainId 999999 -Data $data | |
| .EXAMPLE | |
| $List = @( | |
| 'aaa' | |
| 'bbb' | |
| 'ccc' | |
| ) | |
| $data = $list | foreach {[DMERecord]::new("$_-stage",'stage','CNAME',600)} | |
| $c = New-DMERecord -ApiKey $ApiKey -SecKey $SecKey -DomainId 999999 -Data $data -Verbose | |
| Create multiple records in a DNS zone | |
| Note: I explicitly did not use the DNSME REST API endpoint /records/createMulti due to issues. | |
| .NOTES | |
| If the value does not end in a dot, your domain name will be appended to the value. | |
| #> | |
| [OutputType([psobject])] # <-- the object we receive from DNSME | |
| [cmdletbinding()] | |
| param ( | |
| [Parameter(Mandatory)] | |
| [string]$ApiKey, | |
| [Parameter(Mandatory)] | |
| [string]$SecKey, | |
| [Parameter(Mandatory)] | |
| [string]$DomainId, | |
| [Parameter(Mandatory)] | |
| [DMERecord[]]$Data, | |
| [string]$BaseUrl = 'https://api.dnsmadeeasy.com/V2.0/dns/managed' | |
| ) | |
| ## TODO | |
| # [maybe] add checks: if the domainid exists, if the record already exists. | |
| # Although these are handled by DNSME and they'll just slow down the function | |
| # (and will also add requests for the rate limit if you run this in parallel) | |
| # Also [maybe] write a class for the output object | |
| $Url = "$($BaseUrl.TrimEnd('/'))/$DomainId/records/" | |
| # create the records in DNSME | |
| $Results = foreach ($Record in $Data) { | |
| Write-Verbose "Creating $($Record.Type) record for $($Record.Name)" | |
| $Auth = Get-DMEAuthHeader -ApiKey $ApiKey -SecKey $SecKey # <-- need to do this on each request | |
| $params = @{ | |
| Uri = $Url | |
| Method = 'Post' | |
| Body = $Record.ToJson() | |
| AllowInsecureRedirect = $true | |
| Verbose = $false | |
| ErrorAction = 'Stop' | |
| } | |
| try {Invoke-RestMethod @Auth} | |
| catch { | |
| $ErrorMsg = ($_.ErrorDetails.Message | ConvertFrom-Json).error | |
| $Response = $_.Exception.Response | |
| $ErrorCode = 'Status Code: {0}, Reason: {1}' -f $Response.StatusCode.value__,$Response.ReasonPhrase | |
| $Context = "Error while creating the $($Record.Type) record for $($Record.Name)" | |
| Write-Error -Message "$Context`n$ErrorCode`n$ErrorMsg" # <-- non-terminating error | |
| } | |
| } | |
| # set the default view of the object | |
| $Def = 'name','type','id','failed' | |
| $Class = [Management.Automation.PSPropertySet] | |
| $DDPS = $Class::new('DefaultDisplayPropertySet',[string[]]$Def) | |
| $Std = [Management.Automation.PSMemberInfo[]]@($DDPS) | |
| # finally return the results | |
| $Results | Add-Member -MemberType MemberSet -Name PSStandardMembers -Value $Std -PassThru | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment