Plugins/PowerDNS.ps1

function Get-CurrentPluginType { 'dns-01' }

function Add-DnsTxt {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory,Position=0)]
        [string]$RecordName,
        [Parameter(Mandatory,Position=1)]
        [string]$TxtValue,
        [Parameter(Mandatory)]
        [string]$PowerDNSApiHost,
        [Parameter(Mandatory)]
        [securestring]$PowerDNSApiKey,
        [string]$PowerDNSServerName='localhost',
        [int]$PowerDNSPort=8081,
        [switch]$PowerDNSUseTLS,
        [Parameter(ValueFromRemainingArguments)]
        $ExtraParams
    )

    # get the plaintext version of the api key
    $ApiKey = [pscredential]::new('a',$PowerDNSApiKey).GetNetworkCredential().Password

    # build the API root url
    $proto = if ($PowerDNSUseTLS) {'https'} else {'http'}
    $port = if ($PowerDNSUseTLS -and $PowerDNSPort -eq 443) {''} else {":$PowerDNSPort"}
    $ApiBase = "{0}://{1}{2}/api/v1/servers/{3}" -f $proto,$PowerDNSApiHost,$port,$PowerDNSServerName

    Write-Verbose "Attempting to find hosted zone for $RecordName"
    $zoneName = Find-Zone $RecordName $ApiBase $ApiKey
    if (-not $zoneName) {
        throw "Unable to find PowerDNS zone for $RecordName"
    }
    $zoneBase = '{0}/zones/{1}' -f $ApiBase,$zoneName

    # check if the record already exists
    $queryParams = @{
        Uri = '{0}?rrsets=true&rrset_name={1}.&rrset_type=TXT' -f $zoneBase,$RecordName
        Headers = @{'X-API-Key' = $ApiKey}
        ContentType = 'application/json'
        ErrorAction = 'Stop'
        Verbose = $false
    }
    Write-Debug "GET $($queryParams.Uri)"
    $rrset = Invoke-RestMethod @queryParams @script:UseBasic | Select-Object -Expand rrsets

    if (-not $rrset) {
        # no matching record at all yet
        # so build a new one
        $rrsets = @{
            rrsets = @(
                @{
                    name       = "$RecordName."
                    type       = 'TXT'
                    ttl        = 60
                    changetype = 'REPLACE'
                    records    = @(
                        @{ content = "`"$TxtValue`"" }
                    )
                }
            )
        }
    }
    elseif ("`"$TxtValue`"" -notin $rrset.records.content) {
        # no matching value in the existing record
        # so add it to the existing rrset
        $rrset.records += [pscustomobject]@{content = "`"$TxtValue`""}
        $rrset | Add-Member 'changetype' 'REPLACE'
        $rrsets = @{
            rrsets = @($rrset)
        }
    }
    else {
        Write-Debug "Record $RecordName with value $TxtValue already exists. Nothing to do."
        return
    }

    # write the updated rrset
    $queryParams = @{
        Uri = $zoneBase
        Method = 'PATCH'
        Body = ($rrsets | ConvertTo-Json -Dep 10)
        Headers = @{'X-API-Key' = $ApiKey}
        ContentType = 'application/json'
        ErrorAction = 'Stop'
        Verbose = $false
    }
    Write-Verbose "Adding $RecordName with value $TxtValue"
    Write-Debug "PATCH $($queryParams.Uri)`n$($queryParams.Body)"
    Invoke-RestMethod @queryParams @script:UseBasic

    <#
    .SYNOPSIS
        Add a DNS TXT record to PowerDNS.
 
    .DESCRIPTION
        Add a DNS TXT record to PowerDNS.
 
    .PARAMETER RecordName
        The fully qualified name of the TXT record.
 
    .PARAMETER TxtValue
        The value of the TXT record.
 
    .PARAMETER PowerDNSApiHost
        The hostname or IP address of the Power DNS API
 
    .PARAMETER PowerDNSApiKey
        The Power DNS API Key
 
    .PARAMETER PowerDNSServerName
        The internal name of the server. Defaults to "localhost"
 
    .PARAMETER PowerDNSPort
        The TCP port number the API is listening on. Defaults to 8081
 
    .PARAMETER PowerDNSUseTLS
        When specified, try to use HTTPS to connect to the API. Otherwise, HTTP.
 
    .PARAMETER ExtraParams
        This parameter can be ignored and is only used to prevent errors when splatting with more parameters than this function supports.
 
    .EXAMPLE
        $key = Read-Host 'API Key' -AsSecureString
        $pluginArgs = @{PowerDNSApiHost='pdns.example.com'; PowerDNSApiKey=$key}
        Add-DnsTxt '_acme-challenge.example.com' 'txt-value' @pluginArgs
 
        Adds a TXT record for the specified site/value.
    #>

}

function Remove-DnsTxt {
    [CmdletBinding(DefaultParameterSetName='Secure')]
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingPlainTextForPassword','')]
    param(
        [Parameter(Mandatory,Position=0)]
        [string]$RecordName,
        [Parameter(Mandatory,Position=1)]
        [string]$TxtValue,
        [Parameter(Mandatory)]
        [string]$PowerDNSApiHost,
        [Parameter(Mandatory)]
        [securestring]$PowerDNSApiKey,
        [string]$PowerDNSServerName='localhost',
        [int]$PowerDNSPort=8081,
        [switch]$PowerDNSUseTLS,
        [Parameter(ValueFromRemainingArguments)]
        $ExtraParams
    )

    # get the plaintext version of the api key
    $ApiKey = [pscredential]::new('a',$PowerDNSApiKey).GetNetworkCredential().Password

    # build the API root url
    $proto = if ($PowerDNSUseTLS) {'https'} else {'http'}
    $port = if ($PowerDNSUseTLS -and $PowerDNSPort -eq 443) {''} else {":$PowerDNSPort"}
    $ApiBase = "{0}://{1}{2}/api/v1/servers/{3}" -f $proto,$PowerDNSApiHost,$port,$PowerDNSServerName

    Write-Verbose "Attempting to find hosted zone for $RecordName"
    $zoneName = Find-Zone $RecordName $ApiBase $ApiKey
    if (-not $zoneName) {
        throw "Unable to find PowerDNS zone for $RecordName"
    }
    $zoneBase = '{0}/zones/{1}' -f $ApiBase,$zoneName

    # check if the record already exists
    $queryParams = @{
        Uri = '{0}?rrsets=true&rrset_name={1}.&rrset_type=TXT' -f $zoneBase,$RecordName
        Headers = @{'X-API-Key' = $ApiKey}
        ContentType = 'application/json'
        ErrorAction = 'Stop'
        Verbose = $false
    }
    Write-Debug "GET $($queryParams.Uri)"
    $rrset = Invoke-RestMethod @queryParams @script:UseBasic | Select-Object -Expand rrsets

    if (-not $rrset -or "`"$TxtValue`"" -notin $rrset.records.content) {
        Write-Debug "Record $RecordName with value $TxtValue does not exist. Nothing to do."
        return
    }
    elseif ($rrset.records.Count -gt 1) {
        Write-Debug "records count = $($rrset.records.Count)"
        # more than one value exists with ours
        # so remove it from the existing rrset
        $rrset.records = @($rrset.records | Where-Object { $_.content -ne "`"$TxtValue`"" })
        $rrset | Add-Member 'changetype' 'REPLACE'
        $rrsets = @{
            rrsets = @($rrset)
        }
    }
    else {
        # our value is the only one left, so delete the whole record
        $rrset | Add-Member 'changetype' 'DELETE'
        $rrsets = @{
            rrsets = @($rrset)
        }
    }

    # write the updated rrset
    $queryParams = @{
        Uri = $zoneBase
        Method = 'PATCH'
        Body = ($rrsets | ConvertTo-Json -Dep 10)
        Headers = @{'X-API-Key' = $ApiKey}
        ContentType = 'application/json'
        ErrorAction = 'Stop'
        Verbose = $false
    }
    Write-Verbose "Removing $RecordName with value $TxtValue"
    Write-Debug "PATCH $($queryParams.Uri)`n$($queryParams.Body)"
    Invoke-RestMethod @queryParams @script:UseBasic

    <#
    .SYNOPSIS
        Remove a DNS TXT record from PowerDNS.
 
    .DESCRIPTION
        Remove a DNS TXT record from PowerDNS.
 
    .PARAMETER RecordName
        The fully qualified name of the TXT record.
 
    .PARAMETER TxtValue
        The value of the TXT record.
 
    .PARAMETER PowerDNSApiHost
        The hostname or IP address of the Power DNS API
 
    .PARAMETER PowerDNSApiKey
        The Power DNS API Key
 
    .PARAMETER PowerDNSServerName
        The internal name of the server. Defaults to "localhost"
 
    .PARAMETER PowerDNSPort
        The TCP port number the API is listening on. Defaults to 8081
 
    .PARAMETER PowerDNSUseTLS
        When specified, try to use HTTPS to connect to the API. Otherwise, HTTP.
 
    .PARAMETER ExtraParams
        This parameter can be ignored and is only used to prevent errors when splatting with more parameters than this function supports.
 
    .EXAMPLE
        $key = Read-Host 'API Key' -AsSecureString
        $pluginArgs = @{PowerDNSApiHost='pdns.example.com'; PowerDNSApiKey=$key}
        Remove-DnsTxt '_acme-challenge.example.com' 'txt-value' @pluginArgs
 
        Removes a TXT record for the specified site/value.
    #>

}

function Save-DnsTxt {
    [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
############################

# https://doc.powerdns.com/authoritative/http-api/index.html#working-with-the-api

function Find-Zone {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory,Position=0)]
        [string]$RecordName,
        [Parameter(Mandatory,Position=1)]
        [string]$ApiBase,
        [Parameter(Mandatory,Position=2)]
        [string]$ApiKey
    )

    # setup a module variable to cache the record to zone mapping
    # so it's quicker to find later
    if (!$script:PowerDNSRecordZones) { $script:PowerDNSRecordZones = @{} }

    # check for the record in the cache
    if ($script:PowerDNSRecordZones.ContainsKey($RecordName)) {
        return $script:PowerDNSRecordZones.$RecordName
    }

    # Find the closest/deepest sub-zone that would hold the record.
    $pieces = $RecordName.Split('.')
    for ($i=0; $i -lt ($pieces.Count-1); $i++) {
        $zoneTest = $pieces[$i..($pieces.Count-1)] -join '.'
        Write-Debug "Checking $zoneTest"

        try {
            $queryParams = @{
                Uri = "$ApiBase/zones/$zoneTest."   # PowerDNS very strict about trailing "."
                Headers = @{'X-API-Key' = $ApiKey}
                ContentType = 'application/json'
                ErrorAction = 'Stop'
                Verbose = $false
            }
            Write-Debug "GET $($queryParams.Uri)"
            $response = Invoke-RestMethod @queryParams @script:UseBasic
        } catch {
            # 404 responses mean the zone wasn't found, so skip to the next check
            if (404 -eq $_.Exception.Response.StatusCode) {
                continue
            }
            # re-throw anything else
            throw
        }

        if ($response) {
            $script:PowerDNSRecordZones.$RecordName = $response.name
            return $response.name
        }
    }

    return $null
}