Skip to content

Commit

Permalink
Add PowerDNS plugin and guide (#525)
Browse files Browse the repository at this point in the history
  • Loading branch information
rmbolger authored Dec 12, 2023
1 parent f1becaa commit 4526350
Show file tree
Hide file tree
Showing 4 changed files with 365 additions and 0 deletions.
327 changes: 327 additions & 0 deletions Posh-ACME/Plugins/PowerDNS.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,327 @@
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
}
1 change: 1 addition & 0 deletions Posh-ACME/Private/Import-PluginDetail.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ function Import-PluginDetail {
'PointDNS' = [pscustomobject]@{PSTypeName = 'PoshACME.PAPluginDetail'; ChallengeType = 'dns-01'; Path = ''; Name = 'PointDNS'}
'Porkbun' = [pscustomobject]@{PSTypeName = 'PoshACME.PAPluginDetail'; ChallengeType = 'dns-01'; Path = ''; Name = 'Porkbun'}
'PortsManagement' = [pscustomobject]@{PSTypeName = 'PoshACME.PAPluginDetail'; ChallengeType = 'dns-01'; Path = ''; Name = 'PortsManagement'}
'PowerDNS' = [pscustomobject]@{PSTypeName = 'PoshACME.PAPluginDetail'; ChallengeType = 'dns-01'; Path = ''; Name = 'PowerDNS'}
'Rackspace' = [pscustomobject]@{PSTypeName = 'PoshACME.PAPluginDetail'; ChallengeType = 'dns-01'; Path = ''; Name = 'Rackspace'}
'Regru' = [pscustomobject]@{PSTypeName = 'PoshACME.PAPluginDetail'; ChallengeType = 'dns-01'; Path = ''; Name = 'Regru'}
'RFC2136' = [pscustomobject]@{PSTypeName = 'PoshACME.PAPluginDetail'; ChallengeType = 'dns-01'; Path = ''; Name = 'RFC2136'}
Expand Down
36 changes: 36 additions & 0 deletions docs/Plugins/PowerDNS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
title: PowerDNS

# How To Use the PowerDNS Plugin

This plugin works against the [PowerDNS](https://www.powerdns.com/powerdns-authoritative-server) Authoritative Server. It is assumed that the server is already running with the DNS zone(s) you will be working against.

## Setup

You'll need to [enable the API](https://doc.powerdns.com/authoritative/http-api/index.html#enabling-the-api) if it hasn't been already. You'll also need the value of the `api-key` setting from the config file.

While PowerDNS doesn't directly support using HTTPS against the API, it's possible to do so by running a reverse proxy in front of it which has the certificate and terminates the TLS connections. Make sure you know the hostname or IP and port number of your server as well as whether you need to use HTTP or HTTPS with the API.

## Using the Plugin

The minimum parameters you need to provide to the plugin are the hostname or IP address using `PowerDNSApiHost` and the API Key as a SecureString parameter using `PowerDNSApiKey`. This will use an API Url assuming default values for server name (`localhost`), port (`8081`), and use HTTP rather than HTTPS.

```powershell
$pArgs = @{
PowerDNSApiHost = 'pdns.example.com'
PowerDNSApiKey = (Read-Host "API Key" -AsSecureString)
}
New-PACertificate example.com -Plugin PowerDNS -PluginArgs $pArgs
```

When using an HTTPS reverse proxy in front of the server, you would add `PowerDNSUseTLS`, `PowerDNSPort`, and possibly `PowerDNSServerName` parameters like this:

```powershell
$pArgs = @{
PowerDNSApiHost = 'pdns.example.com'
PowerDNSApiKey = (Read-Host "API Key" -AsSecureString)
PowerDNSUseTLS = $true
PowerDNSPort = 443
PowerDNSServerName = 'localhost'
}
New-PACertificate example.com -Plugin PowerDNS -PluginArgs $pArgs
```
1 change: 1 addition & 0 deletions docs/Plugins/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ OVH | [OVH.com](https://www.ovh.com) | [Usage Guide](OVH.md) | :white_check_mark
PointDNS | [PointDNS](https://pointhq.com/) | [Usage Guide](PointDNS.md) | :white_check_mark:
Porkbun | [Porkbun](https://porkbun.com/) | [Usage Guide](Porkbun.md) | :white_check_mark:
PortsManagement | [PortsManagement](https://portsgroup.com/en/service/ports-management/) | [Usage Guide](PortsManagement.md) | :white_check_mark:
PowerDNS | [PowerDNS](https://www.powerdns.com/powerdns-authoritative-server) | [Usage Guide](PowerDNS.md) | :white_check_mark:
Rackspace | [Rackspace Cloud DNS](https://www.rackspace.com/cloud/dns) | [Usage Guide](Rackspace.md) | :white_check_mark:
Regru | [Reg.ru](https://reg.ru/) and [Reg.com](https://reg.com/) | [Usage Guide](Regru.md) | :white_check_mark:
RFC2136 | [RFC 2136](https://tools.ietf.org/html/rfc2136) | [Usage Guide](RFC2136.md) | :white_check_mark:
Expand Down

0 comments on commit 4526350

Please sign in to comment.