DnsPlugins/Route53.ps1
function Add-DnsTxtRoute53 { [CmdletBinding(DefaultParameterSetName='Keys')] param( [Parameter(Mandatory,Position=0)] [string]$RecordName, [Parameter(Mandatory,Position=1)] [string]$TxtValue, [Parameter(ParameterSetName='Keys',Mandatory,Position=2)] [Parameter(ParameterSetName='KeysInsecure',Mandatory,Position=2)] [string]$R53AccessKey, [Parameter(ParameterSetName='Keys',Mandatory,Position=3)] [securestring]$R53SecretKey, [Parameter(ParameterSetName='KeysInsecure',Mandatory,Position=3)] [string]$R53SecretKeyInsecure, [Parameter(ParameterSetName='Profile',Mandatory)] [string]$R53ProfileName, [Parameter(ValueFromRemainingArguments)] $ExtraParams ) Initialize-R53Config @PSBoundParameters Write-Verbose "Attempting to find hosted zone for $RecordName" if (!($zoneID = Get-R53ZoneId $RecordName)) { throw "Unable to find Route53 hosted zone for $RecordName" } if ($script:AwsUseModule) { # Check for an existing TXT record with this name $response = Get-R53ResourceRecordSet $zoneID $RecordName 'TXT' @script:AwsCredParam $rrSet = $response.ResourceRecordSets | Where-Object { $_.Name -eq "$RecordName." -and $_.Type -eq 'TXT' } if ($rrSet) { if ("`"$TxtValue`"" -in $rrSet.ResourceRecords.Value) { Write-Debug "Record $RecordName already contains $TxtValue. Nothing to do." return } # add a value the existing record $rrSet.ResourceRecords += @{Value="`"$TxtValue`""} } else { # create a new rrset $rrSet = New-Object Amazon.Route53.Model.ResourceRecordSet $rrSet.Name = $RecordName $rrSet.Type = 'TXT' $rrSet.TTL = 60 $rrSet.ResourceRecords.Add(@{Value="`"$TxtValue`""}) } # send the change Write-Verbose "Adding the record to zone ID $zoneID" $change = New-Object Amazon.Route53.Model.Change $change.Action = 'UPSERT' $change.ResourceRecordSet = $rrSet $null = Edit-R53ResourceRecordSet -HostedZoneId $zoneID -ChangeBatch_Change $change @script:AwsCredParam } else { # Check for an existing TXT record with this name $ep = "/2013-04-01$zoneID/rrset" $qs = "name=$RecordName&type=TXT" $response = (Invoke-R53RestMethod -Endpoint $ep -QueryString $qs @script:AwsCredParam).ListResourceRecordSetsResponse $rrSet = $response.ResourceRecordSets.ResourceRecordSet | Where-Object { $_.Name -eq "$RecordName." -and $_.Type -eq 'TXT' } if ($rrSet) { if ("`"$TxtValue`"" -in $rrSet.ResourceRecords.ResourceRecord.Value) { Write-Debug "Record $RecordName already contains $TxtValue. Nothing to do." return } # parse out the list of <RecordSet> elements that we'll be appending to # There's probably a more elegant way to do this via builtin methods or xpath, but I'm lazy $rrSetXml = $rrSet.OuterXml $iStart = $rrSetXml.IndexOf('<ResourceRecord>') $rrXml = $rrSetXml.Substring($iStart) $iEnd = $rrXml.IndexOf('</ResourceRecords>') $rrXml = $rrXml.Substring(0,$iEnd) } # build the UPSERT xml $xmlBody = "<ChangeResourceRecordSetsRequest xmlns=`"https://route53.amazonaws.com/doc/2013-04-01/`"><ChangeBatch><Changes><Change><Action>UPSERT</Action><ResourceRecordSet><Name>$RecordName</Name><Type>TXT</Type><TTL>300</TTL><ResourceRecords>$rrXml<ResourceRecord><Value>`"$TxtValue`"</Value></ResourceRecord></ResourceRecords></ResourceRecordSet></Change></Changes></ChangeBatch></ChangeResourceRecordSetsRequest>" # send the update $null = Invoke-R53RestMethod -Endpoint $ep -UsePost -Data $xmlBody @script:AwsCredParam } <# .SYNOPSIS Add a DNS TXT record to a Route53 hosted zone. .DESCRIPTION This plugin currently requires the AwsPowershell module to be installed. For authentication to AWS, you can either specify an Access/Secret key pair or the name of an AWS credential profile previously stored using Set-AWSCredential. .PARAMETER RecordName The fully qualified name of the TXT record. .PARAMETER TxtValue The value of the TXT record. .PARAMETER R53AccessKey The Access Key ID for the IAM account with permissions to write to the specified hosted zone. .PARAMETER R53SecretKey The Secret Key for the IAM account specified by -R53AccessKey. This SecureString version should only be used on Windows. .PARAMETER R53SecretKeyInsecure The Secret Key for the IAM account specified by -R53AccessKey. This standard String version should be used on non-Windows OSes. .PARAMETER R53ProfileName The profile name of a previously stored credential using Set-AWSCredential from the AwsPowershell module. This only works if the AwsPowershell module is installed. .PARAMETER ExtraParams This parameter can be ignored and is only used to prevent errors when splatting with more parameters than this function supports. .EXAMPLE Add-DnsTxtRoute53 '_acme-challenge.site1.example.com' 'asdfqwer12345678' -R53ProfileName 'myprofile' Add a TXT record using a profile name saved in the AwsPowershell module. .EXAMPLE $seckey = Read-Host -Prompt 'Secret Key:' -AsSecureString PS C:\>Add-DnsTxtRoute53 '_acme-challenge.site1.example.com' 'asdfqwer12345678' -R53AccessKey 'xxxxxxxx' -R53SecretKey $seckey Add a TXT record using an explicit Access Key and Secret key from Windows. .EXAMPLE Add-DnsTxtRoute53 '_acme-challenge.site1.example.com' 'asdfqwer12345678' -R53AccessKey 'xxxxxxxx' -R53SecretKeyInsecure 'yyyyyyyy' Add a TXT record using an explicit Access Key and Secret key from a non-Windows OS. #> } function Remove-DnsTxtRoute53 { [CmdletBinding(DefaultParameterSetName='Keys')] param( [Parameter(Mandatory,Position=0)] [string]$RecordName, [Parameter(Mandatory,Position=1)] [string]$TxtValue, [Parameter(ParameterSetName='Keys',Mandatory,Position=2)] [Parameter(ParameterSetName='KeysInsecure',Mandatory,Position=2)] [string]$R53AccessKey, [Parameter(ParameterSetName='Keys',Mandatory,Position=3)] [securestring]$R53SecretKey, [Parameter(ParameterSetName='KeysInsecure',Mandatory,Position=3)] [string]$R53SecretKeyInsecure, [Parameter(ParameterSetName='Profile',Mandatory)] [string]$R53ProfileName, [Parameter(ValueFromRemainingArguments)] $ExtraParams ) Initialize-R53Config @PSBoundParameters Write-Verbose "Attempting to find hosted zone for $RecordName" if (!($zoneID = Get-R53ZoneId $RecordName)) { throw "Unable to find Route53 hosted zone for $RecordName" } if ($script:AwsUseModule) { # Check for an existing TXT record with this name $response = Get-R53ResourceRecordSet $zoneID $RecordName 'TXT' @script:AwsCredParam $rrSet = $response.ResourceRecordSets | Where-Object { $_.Name -eq "$RecordName." -and $_.Type -eq 'TXT' } if (-not $rrSet -or "`"$TxtValue`"" -notin $rrSet.ResourceRecords.Value) { Write-Debug "Record $RecordName with value $TxtValue doesn't exist. Nothing to do." return } else { # begin a change request $change = New-Object Amazon.Route53.Model.Change $change.ResourceRecordSet = $rrSet if ($rrSet.ResourceRecords.Count -gt 1) { # update the values to exclude one we want to delete $change.Action = 'UPSERT' $rrSet.ResourceRecords = $rrSet.ResourceRecords | Where-Object { $_.Value -ne "`"$TxtValue`"" } } else { # just delete the record $change.Action = 'DELETE' } # remove the record Write-Verbose "Removing the record from zone ID $zoneID" $null = Edit-R53ResourceRecordSet -HostedZoneId $zoneID -ChangeBatch_Change $change @script:AwsCredParam } } else { # Check for an existing TXT record with this name $ep = "/2013-04-01$zoneID/rrset" $qs = "name=$RecordName&type=TXT" $response = (Invoke-R53RestMethod -Endpoint $ep -QueryString $qs @script:AwsCredParam).ListResourceRecordSetsResponse $rrSet = $response.ResourceRecordSets.ResourceRecordSet | Where-Object { $_.Name -eq "$RecordName." -and $_.Type -eq 'TXT' } if (-not $rrSet -or "`"$TxtValue`"" -notin $rrSet.ResourceRecords.ResourceRecord.Value) { Write-Debug "Record $RecordName with value $TxtValue doesn't exist. Nothing to do." return } else { # parse out the list of <RecordSet> elements that we'll be removing from # There's probably a more elegant way to do this via builtin methods or xpath, but I'm lazy $rrSetXml = $rrSet.OuterXml $iStart = $rrSetXml.IndexOf('<ResourceRecord>') $rrXml = $rrSetXml.Substring($iStart) $iEnd = $rrXml.IndexOf('</ResourceRecords>') $rrXml = $rrXml.Substring(0,$iEnd) # check if this is the last value or not if (@($rrSet.ResourceRecords.ResourceRecord).Count -gt 1) { # remove the RecordSet value that we're deleting $rrXml = $rrXml.Replace("<ResourceRecord><Value>`"$TxtValue`"</Value></ResourceRecord>",'') $action = 'UPSERT' } else { $action = 'DELETE' } } # build the xml body $xmlBody = "<ChangeResourceRecordSetsRequest xmlns=`"https://route53.amazonaws.com/doc/2013-04-01/`"><ChangeBatch><Changes><Change><Action>$action</Action><ResourceRecordSet><Name>$RecordName</Name><Type>TXT</Type><TTL>300</TTL><ResourceRecords>$rrXml</ResourceRecords></ResourceRecordSet></Change></Changes></ChangeBatch></ChangeResourceRecordSetsRequest>" # send the update $null = Invoke-R53RestMethod -Endpoint $ep -UsePost -Data $xmlBody @script:AwsCredParam } <# .SYNOPSIS Remove a DNS TXT record from a Route53 hosted zone. .DESCRIPTION This plugin currently requires the AwsPowershell module to be installed. For authentication to AWS, you can either specify an Access/Secret key pair or the name of an AWS credential profile previously stored using Set-AWSCredential. .PARAMETER RecordName The fully qualified name of the TXT record. .PARAMETER TxtValue The value of the TXT record. .PARAMETER R53AccessKey The Access Key ID for the IAM account with permissions to write to the specified hosted zone. .PARAMETER R53SecretKey The Secret Key for the IAM account specified by -R53AccessKey. This SecureString version should only be used on Windows. .PARAMETER R53SecretKeyInsecure The Secret Key for the IAM account specified by -R53AccessKey. This standard String version should be used on non-Windows OSes. .PARAMETER R53ProfileName The profile name of a previously stored credential using Set-AWSCredential from the AwsPowershell module. This only works if the AwsPowershell module is installed. .PARAMETER ExtraParams This parameter can be ignored and is only used to prevent errors when splatting with more parameters than this function supports. .EXAMPLE Remove-DnsTxtRoute53 '_acme-challenge.site1.example.com' 'asdfqwer12345678' -R53ProfileName 'myprofile' Remove a TXT record using a profile name saved in the AwsPowershell module. .EXAMPLE $seckey = Read-Host -Prompt 'Secret Key:' -AsSecureString PS C:\>Remove-DnsTxtRoute53 '_acme-challenge.site1.example.com' 'asdfqwer12345678' -R53AccessKey 'xxxxxxxx' -R53SecretKey $seckey Remove a TXT record using an explicit Access Key and Secret key from Windows. .EXAMPLE Remove-DnsTxtRoute53 '_acme-challenge.site1.example.com' 'asdfqwer12345678' -R53AccessKey 'xxxxxxxx' -R53SecretKeyInsecure 'yyyyyyyy' Remove a TXT record using an explicit Access Key and Secret key from a non-Windows OS. #> } function Save-DnsTxtRoute53 { [CmdletBinding()] param( [Parameter(ValueFromRemainingArguments)] $ExtraParams ) <# .SYNOPSIS Not required. .DESCRIPTION This provider does not require calling this function to commit changes to DNS records. .PARAMETER ExtraParams This parameter can be ignored and is only used to prevent errors when splatting with more parameters than this function supports. #> } ############################ # Helper Functions ############################ function Initialize-R53Config { [CmdletBinding(DefaultParameterSetName='Keys')] param ( [Parameter(ParameterSetName='Keys',Mandatory,Position=0)] [Parameter(ParameterSetName='KeysInsecure',Mandatory,Position=0)] [string]$R53AccessKey, [Parameter(ParameterSetName='Keys',Mandatory,Position=1)] [securestring]$R53SecretKey, [Parameter(ParameterSetName='KeysInsecure',Mandatory,Position=1)] [string]$R53SecretKeyInsecure, [Parameter(ParameterSetName='Profile',Mandatory)] [string]$R53ProfileName, [Parameter(ValueFromRemainingArguments)] $ExtraParamsConfig ) # We now have the ability to do direct REST calls against AWS without the AwsPowerShell dependency # as long as the user provided explicit keys rather than a profile name. # However, we're still going to prefer using the module if it's installed because it's less likely # to break over time if AWS updates the REST API requirements. Or rather, fixing it should be as simple # as installing an updated version of the AwsPowerhell module. # check for AwsPowershell module availability if ($PSEdition -eq 'Core') { $modName = 'AwsPowershell.NetCore' } else { $modName = 'AwsPowershell2' } $modAvailable = $null -ne (Get-Module -ListAvailable $modName -Verbose:$false) if ($modAvailable) { Import-Module $modName -Verbose:$false $script:AwsUseModule = $true } else { Write-Verbose "The $modName module was not found." $script:AwsUseModule = $false } # build and save the credential parameter(s) switch ($PSCmdlet.ParameterSetName) { 'Keys' { $secPlain = (New-Object PSCredential "user",$R53SecretKey).GetNetworkCredential().Password $script:AwsCredParam = @{AccessKey=$R53AccessKey; SecretKey=$secPlain} break } 'KeysInsecure' { $script:AwsCredParam = @{AccessKey=$R53AccessKey; SecretKey=$R53SecretKeyInsecure} break } default { # the only thing left is profile name which requires the module # so error if we didn't find it if (-not $modAvailable) { throw "The $modName module is required to use this plugin with the R53ProfileName parameter." } $script:AwsCredParam = @{ProfileName=$R53ProfileName} } } } function Get-AwsHash { [CmdletBinding()] param ( [Parameter(Position=0)] [string]$Data ) # Need a SHA256 hash in lowercase hex with no dashes $sha256 = [Security.Cryptography.SHA256]::Create() $hash = $sha256.ComputeHash([Text.Encoding]::UTF8.GetBytes($Data)) return ([BitConverter]::ToString($hash) -replace '-','').ToLower() } function Invoke-R53RestMethod { [CmdletBinding()] param( [Parameter(Mandatory)] [string]$AccessKey, [Parameter(Mandatory)] [string]$SecretKey, [switch]$UsePost, # default to GET unless this is used [string]$Endpoint='/', # e.g. CanonicalUri like "/2013-04-01/hostedzone" [string]$QueryString=[String]::Empty, # e.g. CanonicalQueryString like "name=example.com&type=TXT" [string]$Data=[String]::Empty ) # The convoluted process that is authenticating against AWS using Signature Version 4 # https://docs.aws.amazon.com/general/latest/gr/sigv4_signing.html # Since we're only ever using Route53, we can hard code a few things $awsHost = 'route53.amazonaws.com' $region = 'us-east-1' $service = 'route53' $terminator = 'aws4_request' # For some reason we need two differently formatted date strings $now = [DateTimeOffset]::UtcNow $nowDate = $now.ToString("yyyyMMdd") $nowDateTime = $now.ToString("yyyyMMddTHHmmssZ") # https://docs.aws.amazon.com/general/latest/gr/sigv4-create-canonical-request.html $CanonicalHeaders = "host:$awsHost`nx-amz-date:$nowDateTime`n" $SignedHeaders = "host;x-amz-date" $Method = if ($UsePost) { 'POST' } else { 'GET' } $CanonicalRequest = "$Method`n$Endpoint`n$QueryString`n$CanonicalHeaders`n$SignedHeaders`n$((Get-AwsHash $Data))" Write-Debug "CanonicalRequest:`n$CanonicalRequest" $CanonicalRequestHash = Get-AwsHash $CanonicalRequest # https://docs.aws.amazon.com/general/latest/gr/sigv4-create-string-to-sign.html $CredentialScope = "$nowDate/$region/$service/$terminator" $StringToSign = "AWS4-HMAC-SHA256`n$nowDateTime`n$CredentialScope`n$CanonicalRequestHash" Write-Debug "StringToSign:`n$StringToSign" # https://docs.aws.amazon.com/general/latest/gr/sigv4-calculate-signature.html $hmac = New-Object System.Security.Cryptography.HMACSHA256 $hmac.Key = [Text.Encoding]::UTF8.GetBytes("AWS4$SecretKey") $kDate = $hmac.ComputeHash([Text.Encoding]::UTF8.GetBytes($nowDate)) $hmac.Key = $kDate $kRegion = $hmac.ComputeHash([Text.Encoding]::UTF8.GetBytes($region)) $hmac.Key = $kRegion $kService = $hmac.ComputeHash([Text.Encoding]::UTF8.GetBytes($service)) $hmac.Key = $kService $kSigning = $hmac.ComputeHash([Text.Encoding]::UTF8.GetBytes($terminator)) $hmac.Key = $kSigning $signature = $hmac.ComputeHash([Text.Encoding]::UTF8.GetBytes($StringToSign)) $sigHex = ([BitConverter]::ToString($signature) -replace '-','').ToLower() # https://docs.aws.amazon.com/general/latest/gr/sigv4-add-signature-to-request.html $Authorization = "AWS4-HMAC-SHA256 Credential=$AccessKey/$CredentialScope, SignedHeaders=$SignedHeaders, Signature=$sigHex" Write-Debug "Auth:`n$Authorization" # build the request params header hashtable $headers = @{ 'x-amz-date' = $nowDateTime Authorization = $Authorization } $uri = "https://$awsHost$($Endpoint)" if ([String]::Empty -ne $QueryString) { $uri += "?$QueryString" } try { if ($UsePost) { $response = Invoke-RestMethod $uri -Headers $headers -Method Post -Body $Data @script:UseBasic } else { $response = Invoke-RestMethod $uri -Headers $headers @script:UseBasic } return $response } catch { throw } } function Get-R53ZoneId { [CmdletBinding()] param( [Parameter(Mandatory,Position=0)] [string]$RecordName ) # setup a module variable to cache the record to zone ID mapping # so it's quicker to find later if (!$script:R53RecordZones) { $script:R53RecordZones = @{} } # check for the record in the cache if ($script:R53RecordZones.ContainsKey($RecordName)) { return $script:R53RecordZones.$RecordName } # Since there's no good way to query the existence of a single zone, we have to fetch all of them if ($script:AwsUseModule) { # fetch via Module $zones = Get-R53HostedZoneList @script:AwsCredParam | Where-Object { $_.Config.PrivateZone -eq $false } } else { # fetch via REST $zones = @() $nextMarker = '' do { $response = (Invoke-R53RestMethod @script:AwsCredParam -Endpoint '/2013-04-01/hostedzone' -QueryString $nextMarker).ListHostedZonesResponse $zones += @(($response.HostedZones.HostedZone | Where-Object { $_.Config.PrivateZone -eq 'false' })) # check for paging if ([String]::IsNullOrWhiteSpace($response.NextMarker)) { break } $nextMarker = "marker=$($response.NextMarker)&" } while ($true) } Write-Debug "Total zones: $($zones.Count)" # Loop through increasingly general sub-zones to find the most specific # zone this record should live in. $pieces = $RecordName.Split('.') for ($i=1; $i -lt ($pieces.Count-1); $i++) { $zoneTest = "$( $pieces[$i..($pieces.Count-1)] -join '.' )." Write-Verbose "Checking $zoneTest" if ($zoneTest -in $zones.Name) { $zoneID = ($zones | Where-Object { $_.Name -eq $zoneTest }).Id $script:R53RecordZones.$RecordName = $zoneID return $zoneID } } return $null } |